@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,844 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { logger } from '../../shared/logging.js';
|
|
4
|
+
import { GitRunError, type IGitRunner } from '../../shared/git.contract.js';
|
|
5
|
+
import { isAbsence } from '../../shared/fs.contract.js';
|
|
6
|
+
import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
|
|
7
|
+
import type { AddWorktreeOptions, FetchOptions, IKbRepository } from './kb-repository.contract.js';
|
|
8
|
+
import { fetchInstant } from './fetch-instant.js';
|
|
9
|
+
import { readGitfile } from '../kb-fs/index.js';
|
|
10
|
+
import {
|
|
11
|
+
cloneCredentialConfigArgs,
|
|
12
|
+
cloneTrackingConfigArgs,
|
|
13
|
+
ORIGIN_FETCH_REFSPEC,
|
|
14
|
+
SAFE_IMPLICIT_FETCH_ARGS,
|
|
15
|
+
} from '../kb-fs/clone-config.js';
|
|
16
|
+
|
|
17
|
+
const log = logger('kb-repository');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ONE repository per knowledge base, and every branch workspace a worktree of
|
|
21
|
+
* it.
|
|
22
|
+
*
|
|
23
|
+
* A branch workspace used to be a full clone: every object of the history
|
|
24
|
+
* once per branch, and a fetch per branch. A workspace is only ever checked
|
|
25
|
+
* out on its own branch and never switches, so almost all of that was the
|
|
26
|
+
* same history copied over and over (142 branches, 60 GB, on 2026-10-14).
|
|
27
|
+
* Git's own answer to "many checkouts, one history" is a worktree: this
|
|
28
|
+
* repository holds the objects, the remote-tracking refs and each branch's
|
|
29
|
+
* local ref, and `<workspacesRoot>/<branch id>/<kb folder>` — the path a
|
|
30
|
+
* clone had — is a worktree of it. Its `.git` is a FILE pointing here.
|
|
31
|
+
*
|
|
32
|
+
* Bare, so no branch is checked out in the repository itself (a non-bare
|
|
33
|
+
* clone would hold the default branch, which is one of the workspaces), and
|
|
34
|
+
* with no local branches at all until a worktree adds one: the remote's
|
|
35
|
+
* branches arrive under `refs/remotes/origin/*`, exactly where a clone keeps
|
|
36
|
+
* them, so `origin/<branch>` reads the same from a worktree as from a clone.
|
|
37
|
+
*
|
|
38
|
+
* It lives OUTSIDE the workspaces root, so the orphan sweep (which removes
|
|
39
|
+
* every folder there that is not a known branch's) never sees it.
|
|
40
|
+
*
|
|
41
|
+
* Git was built for many worktrees of one repository and protects what they
|
|
42
|
+
* share with its own locks: one per ref while it is written, one for
|
|
43
|
+
* `packed-refs` (which git waits for, `core.packedRefsTimeout`), one per
|
|
44
|
+
* worktree's index, objects written to a temp file and renamed into place,
|
|
45
|
+
* and a worktree being added marked locked until it is. So git operations
|
|
46
|
+
* in two worktrees run side by side here, as two developers' worktrees do,
|
|
47
|
+
* under nothing but the per-workspace mutex the git layer always held.
|
|
48
|
+
*
|
|
49
|
+
* What git does NOT wait for is its one config file — a second writer is
|
|
50
|
+
* refused, not queued — and what nothing in git serializes is the making of
|
|
51
|
+
* the repository and the moving of its folders. Those three run as the
|
|
52
|
+
* repository's one administrator, {@link exclusive}: the only lock this
|
|
53
|
+
* class adds.
|
|
54
|
+
*
|
|
55
|
+
* Automatic garbage collection is off (`gc.auto 0`): a commit in one
|
|
56
|
+
* worktree would otherwise collect beside every other worktree's work. The
|
|
57
|
+
* startup phase runs {@link maintain} once per boot instead, when nothing
|
|
58
|
+
* is served.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** A first fetch of a large knowledge base takes minutes; the floor under its deadline. */
|
|
62
|
+
const FIRST_FETCH_TIMEOUT_MS = 600_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Git waits this long for another process's `packed-refs.lock` before failing
|
|
66
|
+
* (the default is one second). A fetch that prunes and a branch delete in
|
|
67
|
+
* another worktree both rewrite `packed-refs`, which is now one file for every
|
|
68
|
+
* workspace.
|
|
69
|
+
*/
|
|
70
|
+
const PACKED_REFS_TIMEOUT_MS = '10000';
|
|
71
|
+
|
|
72
|
+
/** Written into the repository last, once its first fetch has landed — see `exists`. */
|
|
73
|
+
const READY_MARKER = 'hexis-repository-ready';
|
|
74
|
+
|
|
75
|
+
/** Where a converted clone's stash and other local refs are kept, per workspace (see `importLocalRefs`). */
|
|
76
|
+
export const CONVERTED_REFS = 'refs/kb-converted';
|
|
77
|
+
|
|
78
|
+
/** Fallback committer identity; every workflow commit overrides it with `--author`. */
|
|
79
|
+
const BOT_NAME = 'Bevel Workflow';
|
|
80
|
+
const BOT_EMAIL = 'bevel-workflow@bevel.software';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where the repository is: `<root>/<kb folder>.git`, under `override` when the
|
|
84
|
+
* composition root names one (a folder on a persistent volume beside the
|
|
85
|
+
* workspaces root), else a folder next to the workspaces root named after it.
|
|
86
|
+
*
|
|
87
|
+
* The fallback is derived from the root's own name, so two workspaces roots
|
|
88
|
+
* that share a parent (every suite's temporary roots) never share a repository.
|
|
89
|
+
*/
|
|
90
|
+
export function repositoryDirFor(workspacesRoot: string, kbDirName: string, override?: string): string {
|
|
91
|
+
const root = override ?? `${path.resolve(workspacesRoot)}.repository`;
|
|
92
|
+
return path.join(root, `${kbDirName}.git`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a branch workspace's checkout folder is:
|
|
97
|
+
* - `worktree` — its `.git` is a file naming a folder that exists;
|
|
98
|
+
* - `clone` — its `.git` is a folder: a full clone, from a release before
|
|
99
|
+
* worktrees (converted at boot, and served as it is until it is);
|
|
100
|
+
* - `broken` — its `.git` is a file naming a folder that does not exist (the
|
|
101
|
+
* repository was lost, or the worktree was pruned from under it): an
|
|
102
|
+
* unfinished checkout, recreated as an unfinished clone is;
|
|
103
|
+
* - `none` — no `.git` at all.
|
|
104
|
+
*
|
|
105
|
+
* Errors other than absence propagate: a `.git` that could not be read has
|
|
106
|
+
* not been shown to be anything.
|
|
107
|
+
*/
|
|
108
|
+
export type WorkingCopyShape = 'worktree' | 'clone' | 'broken' | 'none';
|
|
109
|
+
|
|
110
|
+
export async function workingCopyShape(checkoutDir: string): Promise<WorkingCopyShape> {
|
|
111
|
+
const dotGit = path.join(checkoutDir, '.git');
|
|
112
|
+
let stat;
|
|
113
|
+
try {
|
|
114
|
+
stat = await fs.stat(dotGit);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (isAbsence(err)) return 'none';
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
if (stat.isDirectory()) return 'clone';
|
|
120
|
+
const gitdir = await readGitfile(dotGit);
|
|
121
|
+
if (gitdir === null) return 'broken';
|
|
122
|
+
let held;
|
|
123
|
+
try {
|
|
124
|
+
held = await fs.stat(gitdir);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Only absence shows the worktree gone; EACCES or EIO shows nothing, and a
|
|
127
|
+
// `broken` answer would have the checkout recreated over it.
|
|
128
|
+
if (isAbsence(err)) return 'broken';
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
return held.isDirectory() ? 'worktree' : 'broken';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Replace the contents of a file that exists, keeping the file. Git for
|
|
136
|
+
* Windows marks a worktree's `.git` file hidden, and Windows refuses to
|
|
137
|
+
* re-create a hidden file (`fs.writeFile` opens with `w`: EPERM) — so the
|
|
138
|
+
* file is opened as it is and truncated instead.
|
|
139
|
+
*/
|
|
140
|
+
async function rewriteFile(file: string, text: string): Promise<void> {
|
|
141
|
+
const handle = await fs.open(file, 'r+');
|
|
142
|
+
try {
|
|
143
|
+
await handle.truncate(0);
|
|
144
|
+
await handle.write(text, 0);
|
|
145
|
+
} finally {
|
|
146
|
+
await handle.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface KbRepositoryOptions {
|
|
151
|
+
/** The bare repository's folder — see {@link repositoryDirFor}. */
|
|
152
|
+
dir: string;
|
|
153
|
+
/** The configured remote, read at every use (the setup screen can change it). */
|
|
154
|
+
kbRepoUrl: () => string;
|
|
155
|
+
/** Injected by the composition root, as every other git caller's is. */
|
|
156
|
+
gitRunner: IGitRunner;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type { AddWorktreeOptions, FetchOptions, IKbRepository } from './kb-repository.contract.js';
|
|
160
|
+
|
|
161
|
+
export class KbRepository implements IKbRepository {
|
|
162
|
+
readonly dir: string;
|
|
163
|
+
private readonly kbRepoUrl: () => string;
|
|
164
|
+
private readonly gitRunner: IGitRunner;
|
|
165
|
+
|
|
166
|
+
/** The administrator's queue: `exclusive` sections run one after another, in call order. */
|
|
167
|
+
private admin: Promise<unknown> = Promise.resolve();
|
|
168
|
+
|
|
169
|
+
/** See the contract: the repository's one administrator for `fn`'s duration. Never nested. */
|
|
170
|
+
async exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
171
|
+
const next = this.admin.catch(() => undefined).then(fn);
|
|
172
|
+
this.admin = next;
|
|
173
|
+
return next;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** When the last fetch succeeded, and when the last attempt failed. */
|
|
177
|
+
private lastSuccessAt = 0;
|
|
178
|
+
private lastFailureAt = 0;
|
|
179
|
+
private lastOk: boolean | null = null;
|
|
180
|
+
private inFlightFetch: Promise<boolean> | null = null;
|
|
181
|
+
/** When the fetch in flight started: a caller that needs one from `fetchedSince` on joins only a later one. */
|
|
182
|
+
private inFlightStartedAt = 0;
|
|
183
|
+
/**
|
|
184
|
+
* Bumped whenever the repository is taken away (set aside): a fetch that was
|
|
185
|
+
* in flight for the one that went must not stamp its result on the new one.
|
|
186
|
+
*/
|
|
187
|
+
private generation = 0;
|
|
188
|
+
|
|
189
|
+
/** The credential fingerprint last stamped into the repository's config, this process. */
|
|
190
|
+
private stampedCredentials: string | null = null;
|
|
191
|
+
/** Whether this process has stamped the repository's own configuration yet. */
|
|
192
|
+
private configStamped = false;
|
|
193
|
+
|
|
194
|
+
constructor(opts: KbRepositoryOptions) {
|
|
195
|
+
this.dir = path.resolve(opts.dir);
|
|
196
|
+
this.kbRepoUrl = opts.kbRepoUrl;
|
|
197
|
+
this.gitRunner = opts.gitRunner;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private redact(text: string): string {
|
|
201
|
+
return redactSecret(text, [this.gitRunner.credentials.token(), ...urlQuerySecrets(this.kbRepoUrl())]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async git(args: string[], timeoutMs?: number): Promise<string> {
|
|
205
|
+
const { stdout } = await this.gitRunner.run(this.dir, args, timeoutMs ? { timeoutMs } : undefined);
|
|
206
|
+
return stdout;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether the repository is on disk and FINISHED: a bare repository whose
|
|
211
|
+
* first fetch landed. The marker is written last, so a repository whose
|
|
212
|
+
* making was cut short (a crash, a full disk) is not taken for one.
|
|
213
|
+
*/
|
|
214
|
+
async exists(): Promise<boolean> {
|
|
215
|
+
try {
|
|
216
|
+
await fs.access(path.join(this.dir, READY_MARKER));
|
|
217
|
+
return true;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Only absence is "not there". A marker that could not be read (EACCES,
|
|
220
|
+
// EIO, a process out of file descriptors) has shown nothing, and a false
|
|
221
|
+
// answer would have `ensureHeld` clear a repository that is whole, with
|
|
222
|
+
// every worktree's unpushed commits in it.
|
|
223
|
+
if (isAbsence(err)) return false;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Make sure the repository exists and has been fetched at least once; create
|
|
230
|
+
* it when it does not. As the administrator. Throws when it cannot be
|
|
231
|
+
* created or its first fetch fails — with git's words, redacted.
|
|
232
|
+
*/
|
|
233
|
+
async ensure(): Promise<void> {
|
|
234
|
+
await this.exclusive(() => this.ensureHeld());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async ensureHeld(): Promise<void> {
|
|
238
|
+
if (await this.exists()) {
|
|
239
|
+
await this.stampConfigIfNeeded();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const url = this.kbRepoUrl().trim();
|
|
243
|
+
if (url === '') throw new Error('The knowledge-base repository address is not configured.');
|
|
244
|
+
// Made in place, and marked finished only once it has the remote's refs.
|
|
245
|
+
// What a making cut short left here holds no worktree (none is added
|
|
246
|
+
// before the marker) and nothing that is not on the remote: it is
|
|
247
|
+
// cleared and made again.
|
|
248
|
+
await fs.rm(this.dir, { recursive: true, force: true });
|
|
249
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
250
|
+
let fetchedAt = fetchInstant();
|
|
251
|
+
let credentialsStamped = true;
|
|
252
|
+
try {
|
|
253
|
+
await this.gitRunner.run(this.dir, ['init', '--bare', '--quiet', this.dir]);
|
|
254
|
+
for (const args of this.configArgs(url)) await this.gitRunner.run(this.dir, args);
|
|
255
|
+
// The helper's write is not what makes the repository: one that failed
|
|
256
|
+
// is tried again by `refreshCredentials`, which is why it is not
|
|
257
|
+
// recorded as stamped below.
|
|
258
|
+
for (const args of cloneCredentialConfigArgs(this.gitRunner.credentials)) {
|
|
259
|
+
await this.gitRunner.run(this.dir, args).catch((err: unknown) => {
|
|
260
|
+
if (!(args.includes('--unset-all') && (err as GitRunError).exitCode === 5)) credentialsStamped = false;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
fetchedAt = fetchInstant();
|
|
264
|
+
await this.gitRunner.run(this.dir, ['fetch', '--prune', '--no-write-fetch-head', '--quiet', 'origin'], {
|
|
265
|
+
timeoutMs: Math.max(this.gitRunner.defaultTimeoutMs, FIRST_FETCH_TIMEOUT_MS),
|
|
266
|
+
});
|
|
267
|
+
await fs.writeFile(
|
|
268
|
+
path.join(this.dir, READY_MARKER),
|
|
269
|
+
'The knowledge base repository every branch workspace is a worktree of. Made by Hexis.\n',
|
|
270
|
+
);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
await fs.rm(this.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
this.configStamped = true;
|
|
276
|
+
this.stampedCredentials = credentialsStamped ? this.credentialFingerprint() : null;
|
|
277
|
+
this.noteFetched(true, fetchedAt);
|
|
278
|
+
log.info(`created the knowledge-base repository at ${this.dir}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The repository's own configuration — what every worktree of it reads. */
|
|
282
|
+
private configArgs(url: string): string[][] {
|
|
283
|
+
return [
|
|
284
|
+
['config', 'remote.origin.url', url],
|
|
285
|
+
['config', '--replace-all', 'remote.origin.fetch', ORIGIN_FETCH_REFSPEC],
|
|
286
|
+
// A KB has paths over 260 characters; without this a Windows checkout drops them.
|
|
287
|
+
['config', 'core.longpaths', 'true'],
|
|
288
|
+
['config', 'core.packedRefsTimeout', PACKED_REFS_TIMEOUT_MS],
|
|
289
|
+
// No automatic collection: it would run inside whichever worktree's
|
|
290
|
+
// commit tripped it, beside every other worktree's work. The startup
|
|
291
|
+
// phase collects once per boot instead (`maintain`).
|
|
292
|
+
['config', 'gc.auto', '0'],
|
|
293
|
+
// See `cloneTrackingConfigArgs`: an automatic gc forked into the
|
|
294
|
+
// background becomes a zombie under a node server.
|
|
295
|
+
['config', 'gc.autoDetach', 'false'],
|
|
296
|
+
['config', 'user.name', BOT_NAME],
|
|
297
|
+
['config', 'user.email', BOT_EMAIL],
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private credentialFingerprint(): string {
|
|
302
|
+
const { credentials } = this.gitRunner;
|
|
303
|
+
return `${credentials.username()}::${credentials.token() ? '1' : '0'}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stamp the repository's configuration once per process, and its credential
|
|
308
|
+
* helper again whenever the deployment's credential changed — once for every
|
|
309
|
+
* worktree, since they all read this one config. Never throws: a stamp that
|
|
310
|
+
* failed leaves the repository as usable as it was, and is tried again next
|
|
311
|
+
* time.
|
|
312
|
+
*/
|
|
313
|
+
private async stampConfigIfNeeded(): Promise<void> {
|
|
314
|
+
if (!this.configStamped) {
|
|
315
|
+
try {
|
|
316
|
+
// The address is NOT rewritten here: a repository of another address
|
|
317
|
+
// is set aside, never re-pointed (see `isOfAnotherRepository`).
|
|
318
|
+
for (const args of this.configArgs('').slice(1)) await this.git(args);
|
|
319
|
+
this.configStamped = true;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
log.warn('could not stamp the knowledge-base repository configuration:', {
|
|
322
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (this.stampedCredentials === this.credentialFingerprint()) return;
|
|
327
|
+
let argLists: string[][];
|
|
328
|
+
try {
|
|
329
|
+
argLists = cloneCredentialConfigArgs(this.gitRunner.credentials);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
log.warn('refusing to stamp the credential helper of the knowledge-base repository:', {
|
|
332
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let stamped = true;
|
|
337
|
+
for (const args of argLists) {
|
|
338
|
+
try {
|
|
339
|
+
await this.git(args);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// `--unset-all` of a helper that is not there exits 5: nothing to do.
|
|
342
|
+
if (!(args.includes('--unset-all') && (err as GitRunError).exitCode === 5)) stamped = false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
this.stampedCredentials = stamped ? this.credentialFingerprint() : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Bring the credential helper into line with the deployment's credential when
|
|
350
|
+
* it changed since it was last stamped. As the administrator, as every write
|
|
351
|
+
* to the shared config is. A no-op for a repository not yet made.
|
|
352
|
+
*/
|
|
353
|
+
async refreshCredentials(): Promise<void> {
|
|
354
|
+
if (this.stampedCredentials === this.credentialFingerprint() && this.configStamped) return;
|
|
355
|
+
await this.exclusive(async () => {
|
|
356
|
+
if (await this.exists()) await this.stampConfigIfNeeded();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run `git config` arguments against the repository's shared configuration
|
|
362
|
+
* (from a worktree or the repository, it is one file), one writer at a time:
|
|
363
|
+
* git refuses a second writer while `config.lock` is held rather than
|
|
364
|
+
* waiting for it.
|
|
365
|
+
*/
|
|
366
|
+
async writeConfig(cwd: string, argLists: readonly string[][]): Promise<void> {
|
|
367
|
+
await this.exclusive(async () => {
|
|
368
|
+
for (const args of argLists) await this.gitRunner.run(cwd, args);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** See the contract. `gc --auto` decides for itself whether there is enough loose to pack. */
|
|
373
|
+
async maintain(): Promise<void> {
|
|
374
|
+
try {
|
|
375
|
+
if (!(await this.exists())) return;
|
|
376
|
+
await this.git(['-c', 'gc.auto=6700', 'gc', '--auto', '--quiet'], Math.max(this.gitRunner.defaultTimeoutMs, FIRST_FETCH_TIMEOUT_MS));
|
|
377
|
+
} catch (err) {
|
|
378
|
+
log.warn('garbage collection of the knowledge-base repository failed; it is tried again at the next start:', {
|
|
379
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* `at` for a success is when its fetch STARTED: the refs it wrote are at
|
|
386
|
+
* least that fresh, and no fresher — a deletion on the remote while it ran
|
|
387
|
+
* may be missing from them.
|
|
388
|
+
*/
|
|
389
|
+
private noteFetched(ok: boolean, at = Date.now()): void {
|
|
390
|
+
this.lastOk = ok;
|
|
391
|
+
if (ok) this.lastSuccessAt = at;
|
|
392
|
+
else this.lastFailureAt = at;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The last time the repository reached the remote and whether it worked; null before any attempt. */
|
|
396
|
+
lastContact(): { at: number; ok: boolean } | null {
|
|
397
|
+
if (this.lastOk === null) return null;
|
|
398
|
+
return { at: Math.max(this.lastSuccessAt, this.lastFailureAt), ok: this.lastOk };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Why the last fetch failed, redacted; null when it did not. For a fail-closed caller's message. */
|
|
402
|
+
lastFetchFailure(): string | null {
|
|
403
|
+
return this.lastOk === false ? this.lastFailure : null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private lastFailure: string | null = null;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* `git fetch --prune origin` in the repository — ONCE for every workspace.
|
|
410
|
+
* Throttled by `maxAgeMs` and joined while in flight (ten callers in one
|
|
411
|
+
* tick share one fetch). Not serialized against the worktrees' own git:
|
|
412
|
+
* a fetch writes remote-tracking refs and packs, which git locks itself.
|
|
413
|
+
* Answers whether the remote-tracking refs are provably fresh: this call's
|
|
414
|
+
* fetch (or the one it joined) succeeded, or one succeeded within the
|
|
415
|
+
* window. Never throws; a caller that must not act on stale refs reads the
|
|
416
|
+
* answer.
|
|
417
|
+
*/
|
|
418
|
+
async fetch(opts: FetchOptions): Promise<boolean> {
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
if (
|
|
421
|
+
!opts.force &&
|
|
422
|
+
now - this.lastSuccessAt < opts.maxAgeMs &&
|
|
423
|
+
(opts.fetchedSince === undefined || this.lastSuccessAt > opts.fetchedSince)
|
|
424
|
+
) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
if (this.inFlightFetch) {
|
|
428
|
+
if (opts.fetchedSince === undefined || this.inFlightStartedAt > opts.fetchedSince) return this.inFlightFetch;
|
|
429
|
+
// Started before the caller's instant: its refs may be older than what
|
|
430
|
+
// the caller must see (a branch deleted since). Waited for, then a
|
|
431
|
+
// fetch after the instant is joined or run: forced, so neither the
|
|
432
|
+
// waited-for one's success nor its failure (the backoff) stands in for
|
|
433
|
+
// it. Any fetch in flight now started after the instant, so this ends.
|
|
434
|
+
await this.inFlightFetch;
|
|
435
|
+
return this.fetch({ ...opts, force: true });
|
|
436
|
+
}
|
|
437
|
+
if (!opts.force && opts.failureBackoffMs && now - this.lastFailureAt < opts.failureBackoffMs) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const generation = this.generation;
|
|
441
|
+
let startedAt = fetchInstant();
|
|
442
|
+
const promise = (async () => {
|
|
443
|
+
startedAt = fetchInstant();
|
|
444
|
+
// A repository not made yet is made here, which fetches it once: that
|
|
445
|
+
// first fetch is this one.
|
|
446
|
+
const existed = await this.exists();
|
|
447
|
+
await this.ensure();
|
|
448
|
+
if (!existed) return;
|
|
449
|
+
await this.git([
|
|
450
|
+
// A transfer that stalls below 1 KB/s for ten seconds is abandoned,
|
|
451
|
+
// so a hung host cannot park every workspace behind it.
|
|
452
|
+
'-c', 'http.lowSpeedLimit=1000',
|
|
453
|
+
'-c', 'http.lowSpeedTime=10',
|
|
454
|
+
...SAFE_IMPLICIT_FETCH_ARGS,
|
|
455
|
+
]);
|
|
456
|
+
})()
|
|
457
|
+
.then(
|
|
458
|
+
() => {
|
|
459
|
+
// When this fetch made the repository, ensureHeld() already stamped its
|
|
460
|
+
// own (later) first-fetch start: keep that one.
|
|
461
|
+
if (generation === this.generation) this.noteFetched(true, Math.max(startedAt, this.lastSuccessAt));
|
|
462
|
+
return true;
|
|
463
|
+
},
|
|
464
|
+
(err: unknown) => {
|
|
465
|
+
if (generation === this.generation) {
|
|
466
|
+
this.noteFetched(false);
|
|
467
|
+
this.lastFailure = this.redact(err instanceof Error ? err.message : String(err));
|
|
468
|
+
}
|
|
469
|
+
log.warn('git fetch origin failed:', {
|
|
470
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
471
|
+
});
|
|
472
|
+
return false;
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
.finally(() => {
|
|
476
|
+
if (this.inFlightFetch === promise) this.inFlightFetch = null;
|
|
477
|
+
});
|
|
478
|
+
this.inFlightFetch = promise;
|
|
479
|
+
this.inFlightStartedAt = startedAt;
|
|
480
|
+
return promise;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Forget what this process knows about the repository — it was set aside,
|
|
485
|
+
* and the next use makes a fresh one.
|
|
486
|
+
*/
|
|
487
|
+
forget(): void {
|
|
488
|
+
this.generation++;
|
|
489
|
+
this.lastSuccessAt = 0;
|
|
490
|
+
this.lastFailureAt = 0;
|
|
491
|
+
this.lastOk = null;
|
|
492
|
+
this.inFlightFetch = null;
|
|
493
|
+
this.configStamped = false;
|
|
494
|
+
this.stampedCredentials = null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async revParse(ref: string): Promise<string | null> {
|
|
498
|
+
try {
|
|
499
|
+
return (await this.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])).trim() || null;
|
|
500
|
+
} catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private async isAncestor(ancestor: string, of: string): Promise<boolean> {
|
|
506
|
+
try {
|
|
507
|
+
await this.git(['merge-base', '--is-ancestor', ancestor, of]);
|
|
508
|
+
return true;
|
|
509
|
+
} catch {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Add the worktree of `branch` at `checkoutDir`. Copies no history: the
|
|
516
|
+
* repository already has it, and is fetched first when the remote's branch
|
|
517
|
+
* is not known to it yet.
|
|
518
|
+
*
|
|
519
|
+
* The branch's local ref is created from `origin/<branch>`, tracking it. A
|
|
520
|
+
* local ref already there (a converted clone's tip, a worktree that was lost
|
|
521
|
+
* with commits the remote lacks) is KEPT when it holds commits the remote
|
|
522
|
+
* does not have, and fast-forwarded to the remote when it is behind.
|
|
523
|
+
*
|
|
524
|
+
* A branch the remote does not have fails with git's own words for a clone
|
|
525
|
+
* of it ("Remote branch … not found in upstream origin"), so the callers
|
|
526
|
+
* that tell a deleted branch from one that never existed read it as they
|
|
527
|
+
* read a clone's failure — unless `allowLocalOnly` (a converted clone whose
|
|
528
|
+
* branch was never pushed) and the local ref exists.
|
|
529
|
+
*
|
|
530
|
+
* `keepLocalTip` (a conversion) leaves a local ref that is behind the
|
|
531
|
+
* remote where it is: the worktree comes up at the clone's own tip, and the
|
|
532
|
+
* phase's own update moves it on, as it moved the clone.
|
|
533
|
+
*
|
|
534
|
+
* `fetchedSince` (the startup phase, opening every branch) skips asking
|
|
535
|
+
* the remote when the repository's own fetch succeeded, starting after it,
|
|
536
|
+
* and already has `origin/<branch>`: that fetch brought every branch, so
|
|
537
|
+
* opening a hundred branches costs one fetch, not a hundred. A branch it
|
|
538
|
+
* does not know yet is asked for as usual.
|
|
539
|
+
*/
|
|
540
|
+
async addWorktree(
|
|
541
|
+
branch: string,
|
|
542
|
+
checkoutDir: string,
|
|
543
|
+
opts: AddWorktreeOptions = {},
|
|
544
|
+
): Promise<void> {
|
|
545
|
+
await this.ensure();
|
|
546
|
+
{
|
|
547
|
+
// The remote is ASKED for the branch, as a clone of it asked: the
|
|
548
|
+
// repository's tracking ref may be from before the branch moved, or was
|
|
549
|
+
// deleted, on the host. One branch, not the whole remote. Not as the
|
|
550
|
+
// administrator: a fetch may take a while, and git locks what it writes.
|
|
551
|
+
let missing = false;
|
|
552
|
+
const fresh =
|
|
553
|
+
opts.fetchedSince !== undefined &&
|
|
554
|
+
this.lastSuccessAt > 0 &&
|
|
555
|
+
this.lastSuccessAt > opts.fetchedSince &&
|
|
556
|
+
(await this.revParse(`refs/remotes/origin/${branch}`)) !== null;
|
|
557
|
+
if (!fresh) {
|
|
558
|
+
try {
|
|
559
|
+
await this.git(['fetch', '--no-write-fetch-head', '--quiet', 'origin',
|
|
560
|
+
`+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
563
|
+
if (!/couldn't find remote ref/i.test(message)) throw err;
|
|
564
|
+
missing = true;
|
|
565
|
+
// What the repository still says about it is stale.
|
|
566
|
+
await this.git(['update-ref', '-d', `refs/remotes/origin/${branch}`]).catch(() => undefined);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// As the administrator, from reading the local ref to the config
|
|
570
|
+
// writes: the worktree list is the one thing git does not lock (a prune
|
|
571
|
+
// beside another branch's `worktree add` can take the entry being
|
|
572
|
+
// made), `worktree add --track` writes the shared config, which git
|
|
573
|
+
// refuses a second writer of, and a `removeWorktree` queued before this
|
|
574
|
+
// section deletes the branch's local ref — read here, it is the ref
|
|
575
|
+
// the add uses, not one read before that removal and gone by then.
|
|
576
|
+
await this.exclusive(async () => {
|
|
577
|
+
const local = await this.revParse(`refs/heads/${branch}`);
|
|
578
|
+
if (missing && !(opts.allowLocalOnly && local !== null)) {
|
|
579
|
+
throw new Error(`Remote branch ${branch} not found in upstream origin`);
|
|
580
|
+
}
|
|
581
|
+
await fs.mkdir(path.dirname(checkoutDir), { recursive: true });
|
|
582
|
+
// A registration left behind by a folder deleted without git (a crash,
|
|
583
|
+
// an operator) would make git refuse the path, or the branch, as taken.
|
|
584
|
+
await this.git(['worktree', 'prune']);
|
|
585
|
+
if (local === null) {
|
|
586
|
+
await this.git(['worktree', 'add', '--quiet', '--track', '-b', branch, checkoutDir, `refs/remotes/origin/${branch}`]);
|
|
587
|
+
} else {
|
|
588
|
+
const remote = missing ? null : await this.revParse(`refs/remotes/origin/${branch}`);
|
|
589
|
+
if (!opts.keepLocalTip && remote !== null && local !== remote && (await this.isAncestor(local, remote))) {
|
|
590
|
+
await this.git(['update-ref', `refs/heads/${branch}`, remote, local]);
|
|
591
|
+
}
|
|
592
|
+
await this.git(['worktree', 'add', '--quiet', checkoutDir, branch]);
|
|
593
|
+
}
|
|
594
|
+
// The branch tracks `origin/<branch>`, exactly one fetch refspec and
|
|
595
|
+
// one upstream ref, as a fresh clone of it would.
|
|
596
|
+
for (const args of cloneTrackingConfigArgs(branch)) await this.git(args);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Remove the worktree at `checkoutDir` and prune it from the repository,
|
|
603
|
+
* with its branch's local ref: deleting a workspace takes what a deleted
|
|
604
|
+
* clone took. The repository's objects and every other worktree are left
|
|
605
|
+
* exactly as they were. A folder git will not remove (it is not a worktree
|
|
606
|
+
* any more, or is locked) is deleted by hand, then pruned.
|
|
607
|
+
*/
|
|
608
|
+
async removeWorktree(checkoutDir: string, branch: string): Promise<void> {
|
|
609
|
+
if (!(await this.exists())) {
|
|
610
|
+
await fs.rm(checkoutDir, { recursive: true, force: true });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// As the administrator, the branch delete included: the worktree list
|
|
614
|
+
// is the repository's to serialize (see `addWorktree`), the delete
|
|
615
|
+
// removes the branch's section from the shared config, and an
|
|
616
|
+
// `addWorktree` of the same branch must find the ref either there or
|
|
617
|
+
// gone, not read it and then lose it.
|
|
618
|
+
await this.exclusive(async () => {
|
|
619
|
+
await this.git(['worktree', 'remove', '--force', '--force', checkoutDir]).catch(async () => {
|
|
620
|
+
await fs.rm(checkoutDir, { recursive: true, force: true });
|
|
621
|
+
});
|
|
622
|
+
await this.git(['worktree', 'prune']);
|
|
623
|
+
await this.git(['branch', '-D', '--', branch]).catch(() => undefined);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* After worktree folders were deleted by hand (the orphan sweep): prune them
|
|
629
|
+
* from the repository and drop their branches' local refs. A branch still
|
|
630
|
+
* checked out in a worktree that exists is refused by git and kept.
|
|
631
|
+
*/
|
|
632
|
+
async forgetWorktrees(branches: readonly string[]): Promise<void> {
|
|
633
|
+
if (!(await this.exists())) return;
|
|
634
|
+
// As the administrator, prune and deletes together: the worktree list is
|
|
635
|
+
// the repository's to serialize, and a branch delete writes the shared
|
|
636
|
+
// config (see `removeWorktree`).
|
|
637
|
+
await this.exclusive(async () => {
|
|
638
|
+
await this.git(['worktree', 'prune']);
|
|
639
|
+
for (const branch of branches) {
|
|
640
|
+
if ((await this.revParse(`refs/heads/${branch}`)) === null) continue;
|
|
641
|
+
await this.git(['branch', '-D', '--', branch]).catch(() => undefined);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* `git worktree prune`: forget worktrees whose folders are gone. As the
|
|
648
|
+
* administrator: a prune beside a `worktree add` can take the entry being
|
|
649
|
+
* made, before git has marked it as in the making.
|
|
650
|
+
*/
|
|
651
|
+
async prune(): Promise<void> {
|
|
652
|
+
if (!(await this.exists())) return;
|
|
653
|
+
await this.exclusive(() => this.git(['worktree', 'prune']));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The folders of the repository's worktrees, as git lists them. */
|
|
657
|
+
async listWorktrees(): Promise<string[]> {
|
|
658
|
+
if (!(await this.exists())) return [];
|
|
659
|
+
const out = await this.git(['worktree', 'list', '--porcelain']);
|
|
660
|
+
const dirs: string[] = [];
|
|
661
|
+
for (const line of out.split('\n')) {
|
|
662
|
+
if (!line.startsWith('worktree ')) continue;
|
|
663
|
+
const dir = path.resolve(line.slice('worktree '.length).trim());
|
|
664
|
+
if (dir !== this.dir) dirs.push(dir);
|
|
665
|
+
}
|
|
666
|
+
return dirs;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Bring the tip of `branch` from the full clone at `cloneDir` into the
|
|
671
|
+
* repository as `refs/heads/<branch>` — its commits the remote lacks
|
|
672
|
+
* included — without touching the remote. Answers the tip, after checking
|
|
673
|
+
* the repository now has it.
|
|
674
|
+
*/
|
|
675
|
+
async importBranch(cloneDir: string, branch: string): Promise<string> {
|
|
676
|
+
await this.ensure();
|
|
677
|
+
const tip = (
|
|
678
|
+
await this.gitRunner.run(cloneDir, ['rev-parse', '--verify', `refs/heads/${branch}^{commit}`])
|
|
679
|
+
).stdout.trim();
|
|
680
|
+
await this.git(['fetch', '--no-tags', '--no-write-fetch-head', '--quiet', cloneDir,
|
|
681
|
+
`+refs/heads/${branch}:refs/heads/${branch}`]);
|
|
682
|
+
const imported = await this.revParse(`refs/heads/${branch}`);
|
|
683
|
+
if (imported !== tip) {
|
|
684
|
+
throw new Error(`the repository holds ${imported ?? 'nothing'} for "${branch}" after the import, not ${tip}`);
|
|
685
|
+
}
|
|
686
|
+
return tip;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Bring what else the full clone at `cloneDir` holds of its own into the
|
|
691
|
+
* repository, under `refs/kb-converted/<workspaceId>/`, before the clone
|
|
692
|
+
* goes: every entry of its stash (`stash/0` the latest), and every local
|
|
693
|
+
* ref but its own branch's and the remote-tracking ones — other local
|
|
694
|
+
* branches, tags — that the repository does not already hold with the same
|
|
695
|
+
* value. Nothing in the clone changes: each object is pushed by its id.
|
|
696
|
+
* Answers the refs written, after checking the repository holds each at the
|
|
697
|
+
* clone's value; throws, the clone untouched, when one cannot be written.
|
|
698
|
+
*
|
|
699
|
+
* The refs are no workspace's: deleting the branch's workspace leaves them,
|
|
700
|
+
* and the repository's collection keeps what they reach.
|
|
701
|
+
*/
|
|
702
|
+
async importLocalRefs(cloneDir: string, branch: string, workspaceId: string): Promise<string[]> {
|
|
703
|
+
await this.ensure();
|
|
704
|
+
const inClone = async (args: string[]) => (await this.gitRunner.run(cloneDir, args)).stdout;
|
|
705
|
+
const namespace = `${CONVERTED_REFS}/${workspaceId}`;
|
|
706
|
+
const wanted: { sha: string; dest: string }[] = [];
|
|
707
|
+
let stashTop: string | null = null;
|
|
708
|
+
for (const line of (await inClone(['for-each-ref', '--format=%(objectname) %(refname)'])).split('\n')) {
|
|
709
|
+
const [sha, name] = line.trim().split(' ');
|
|
710
|
+
if (!sha || !name) continue;
|
|
711
|
+
if (name === 'refs/stash') { stashTop = sha; continue; }
|
|
712
|
+
if (name.startsWith('refs/remotes/') || name === `refs/heads/${branch}`) continue;
|
|
713
|
+
if ((await this.refValue(name)) === sha) continue;
|
|
714
|
+
if (name.startsWith('refs/heads/') && (await this.refValue(`refs/remotes/origin/${name.slice('refs/heads/'.length)}`)) === sha) continue;
|
|
715
|
+
wanted.push({ sha, dest: `${namespace}/${name.slice('refs/'.length)}` });
|
|
716
|
+
}
|
|
717
|
+
if (stashTop !== null) {
|
|
718
|
+
// Every entry, not only the latest: the older ones are the stash's log.
|
|
719
|
+
// Git reads a log it cannot open (EACCES, EIO) as an empty one and
|
|
720
|
+
// exits 0, so the log is read here first: only an absent one means the
|
|
721
|
+
// stash is its tip alone, and any other failure throws, the clone kept.
|
|
722
|
+
const logFile = path.resolve(cloneDir, (await inClone(['rev-parse', '--git-path', 'logs/refs/stash'])).trim());
|
|
723
|
+
let logged: number;
|
|
724
|
+
try {
|
|
725
|
+
logged = (await fs.readFile(logFile, 'utf8')).split('\n').filter((l) => l.trim() !== '').length;
|
|
726
|
+
} catch (err) {
|
|
727
|
+
if (!isAbsence(err)) throw err;
|
|
728
|
+
logged = 0;
|
|
729
|
+
}
|
|
730
|
+
const entries = (await inClone(['reflog', 'show', '--format=%H', 'refs/stash']))
|
|
731
|
+
.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
732
|
+
if (entries.length !== logged) {
|
|
733
|
+
throw new Error(`the stash log of ${cloneDir} holds ${logged} entries, git read ${entries.length}`);
|
|
734
|
+
}
|
|
735
|
+
if (logged > 0 && entries[0] !== stashTop) {
|
|
736
|
+
throw new Error(`the stash log of ${cloneDir} starts at ${entries[0]}, not at the stash tip ${stashTop}`);
|
|
737
|
+
}
|
|
738
|
+
(logged > 0 ? entries : [stashTop]).forEach((sha, i) => wanted.push({ sha, dest: `${namespace}/stash/${i}` }));
|
|
739
|
+
}
|
|
740
|
+
if (wanted.length === 0) return [];
|
|
741
|
+
await this.gitRunner.run(cloneDir, ['push', '--quiet', '--no-verify', this.dir,
|
|
742
|
+
...wanted.map(({ sha, dest }) => `+${sha}:${dest}`)]);
|
|
743
|
+
for (const { sha, dest } of wanted) {
|
|
744
|
+
const held = await this.refValue(dest);
|
|
745
|
+
if (held !== sha) throw new Error(`the repository holds ${held ?? 'nothing'} at ${dest} after the import, not ${sha}`);
|
|
746
|
+
}
|
|
747
|
+
return wanted.map(({ dest }) => dest);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** The object a ref names, as it is (a tag is not peeled), or null. */
|
|
751
|
+
private async refValue(ref: string): Promise<string | null> {
|
|
752
|
+
try {
|
|
753
|
+
return (await this.git(['rev-parse', '--verify', '--quiet', ref])).trim() || null;
|
|
754
|
+
} catch {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The address the repository fetches from, or null when it cannot be read
|
|
761
|
+
* (no repository, no `origin`).
|
|
762
|
+
*/
|
|
763
|
+
async originUrl(): Promise<string | null> {
|
|
764
|
+
if (!(await this.exists())) return null;
|
|
765
|
+
try {
|
|
766
|
+
return (await this.git(['config', '--get', 'remote.origin.url'])).trim() || null;
|
|
767
|
+
} catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Re-link a repository and its worktrees that were moved together (set
|
|
774
|
+
* aside), so `git log` in a moved worktree reads the moved repository.
|
|
775
|
+
*
|
|
776
|
+
* Both links are written here rather than left to `git worktree repair`,
|
|
777
|
+
* which cannot follow a link whose two ends BOTH moved: the worktree's
|
|
778
|
+
* `.git` file names `<repository>/worktrees/<name>`, and that folder's
|
|
779
|
+
* `gitdir` names the worktree's `.git`. The name is the last part of the
|
|
780
|
+
* path the worktree's `.git` file still holds. Answers the moved worktrees
|
|
781
|
+
* that could not be re-linked (git had already lost track of them, or a
|
|
782
|
+
* write failed) — their files are all there, only `git` in them is not.
|
|
783
|
+
* A worktree whose second link cannot be written gets its first one back,
|
|
784
|
+
* so neither link names a place the other does not.
|
|
785
|
+
*/
|
|
786
|
+
async relinkMovedCopy(movedRepository: string, movedWorktrees: readonly string[]): Promise<string[]> {
|
|
787
|
+
const gitPath = (p: string): string => (process.platform === 'win32' ? p.replace(/\\/g, '/') : p);
|
|
788
|
+
const unlinked: string[] = [];
|
|
789
|
+
for (const worktree of movedWorktrees) {
|
|
790
|
+
try {
|
|
791
|
+
const gitfile = path.join(worktree, '.git');
|
|
792
|
+
const old = await readGitfile(gitfile);
|
|
793
|
+
if (old === null) throw new Error('its .git file names no folder');
|
|
794
|
+
const admin = path.join(movedRepository, 'worktrees', path.basename(old));
|
|
795
|
+
if (!(await fs.stat(admin).then((st) => st.isDirectory(), () => false))) {
|
|
796
|
+
throw new Error(`the repository has no ${admin}`);
|
|
797
|
+
}
|
|
798
|
+
// Both links or neither: a `.git` re-pointed while the record behind
|
|
799
|
+
// it still names the old checkout is a worktree git calls prunable,
|
|
800
|
+
// and a `worktree prune` in the set-aside repository would drop it.
|
|
801
|
+
const before = await fs.readFile(gitfile, 'utf8');
|
|
802
|
+
// Written as the file system names them, as git itself writes them:
|
|
803
|
+
// on Windows a path held by its 8.3 short name (`RUNNER~1`) would
|
|
804
|
+
// otherwise be what `git worktree list` shows.
|
|
805
|
+
const realAdmin = await fs.realpath(admin);
|
|
806
|
+
const realGitfile = path.join(await fs.realpath(worktree), '.git');
|
|
807
|
+
await rewriteFile(gitfile, `gitdir: ${gitPath(realAdmin)}\n`);
|
|
808
|
+
try {
|
|
809
|
+
await rewriteFile(path.join(admin, 'gitdir'), `${gitPath(realGitfile)}\n`);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
await rewriteFile(gitfile, before).catch((backErr: unknown) => {
|
|
812
|
+
log.error(`the .git file of the set-aside worktree ${worktree} could not be restored:`, {
|
|
813
|
+
detail: this.redact(backErr instanceof Error ? backErr.message : String(backErr)),
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
throw err;
|
|
817
|
+
}
|
|
818
|
+
} catch (err) {
|
|
819
|
+
unlinked.push(worktree);
|
|
820
|
+
log.warn(`the set-aside worktree ${worktree} could not be re-linked to the set-aside repository:`, {
|
|
821
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return unlinked;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Point the repository at `url` — the same repository at a new address.
|
|
830
|
+
* What this process knew of the old address's fetches is forgotten with it,
|
|
831
|
+
* so the next fetch reaches the new one rather than reading a success from
|
|
832
|
+
* the old one as fresh.
|
|
833
|
+
*/
|
|
834
|
+
async setOriginUrl(url: string): Promise<void> {
|
|
835
|
+
await this.exclusive(async () => {
|
|
836
|
+
await this.git(['remote', 'set-url', 'origin', url]);
|
|
837
|
+
this.generation++;
|
|
838
|
+
this.lastSuccessAt = 0;
|
|
839
|
+
this.lastFailureAt = 0;
|
|
840
|
+
this.lastOk = null;
|
|
841
|
+
this.lastFailure = null;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|