@bevel-software/platform-core-backend 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +32 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +138 -16
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +2 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts +29 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +177 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +7 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +6 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +36 -3
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +142 -14
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +184 -22
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +12 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- package/src/test-setup.ts +5 -0
|
@@ -5,13 +5,16 @@ import { logger } from '../../../shared/logging.js';
|
|
|
5
5
|
// callback of its own, and a module logger of the same name would be
|
|
6
6
|
// shadowed exactly where it is meant to be the default.
|
|
7
7
|
const startupLog = logger('kb-startup');
|
|
8
|
-
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
8
|
+
import { branchForWorkspaceId, workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
9
|
import { git, lsRemoteHeads, stampIdentity, withTempDir } from './kb-git.js';
|
|
10
10
|
import { GitRunError } from '../../../shared/git.contract.js';
|
|
11
11
|
import { ClassifiedFailure, classifyGitFailure, failureOf, gitFailure, } from '../../../shared/git-failure.js';
|
|
12
12
|
import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
|
|
13
13
|
import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
|
|
14
|
-
import { setAsideClone, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
|
|
14
|
+
import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
|
|
15
|
+
import { workingCopyShape } from '../kb-repository.js';
|
|
16
|
+
import { fetchInstant } from '../fetch-instant.js';
|
|
17
|
+
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
15
18
|
const UNFINISHED_CLONE_QUIET_MS = 60_000;
|
|
16
19
|
/** The floor `kb-git.ts` gives a startup git command, a clone included. */
|
|
17
20
|
const LONGEST_CLONE_MS = 600_000;
|
|
@@ -21,9 +24,15 @@ const LONGEST_CLONE_MS = 600_000;
|
|
|
21
24
|
* on every one of these is being worked on by something that will not stop.
|
|
22
25
|
*/
|
|
23
26
|
const PASSES_OVER_ONE_WORKING_COPY = 4;
|
|
24
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* How recent a fetch of the repository the phase takes as current. Every
|
|
29
|
+
* branch it visits is a worktree of the one repository, so one fetch serves
|
|
30
|
+
* them all; a boot that visits 142 branches fetches once, not 142 times.
|
|
31
|
+
*/
|
|
32
|
+
const STARTUP_FETCH_WINDOW_MS = 30_000;
|
|
33
|
+
/** Whether `git clone` or `git worktree add` failed because its destination already held something, having written nothing. */
|
|
25
34
|
function refusedAnOccupiedFolder(err) {
|
|
26
|
-
return err instanceof Error && /already exists and is not an empty directory
|
|
35
|
+
return err instanceof Error && /already exists and is not an empty directory|' already exists$/m.test(err.message);
|
|
27
36
|
}
|
|
28
37
|
/**
|
|
29
38
|
* When anything under `dir` was last written, as a time in milliseconds. Now,
|
|
@@ -104,8 +113,23 @@ export function bootMaySurvive(err) {
|
|
|
104
113
|
}
|
|
105
114
|
export class KbStartupRunner {
|
|
106
115
|
opts;
|
|
116
|
+
repository;
|
|
117
|
+
/**
|
|
118
|
+
* Branches deleted on the remote after the phase listed them: the
|
|
119
|
+
* repository's fetch (`--prune`) took their `origin/<branch>` away. Their
|
|
120
|
+
* worktrees are left as they are, and the phase commits and pushes nothing
|
|
121
|
+
* to them — a push would bring a deleted branch back.
|
|
122
|
+
*/
|
|
123
|
+
goneFromRemote = new Set();
|
|
124
|
+
/**
|
|
125
|
+
* When this run listed the remote's branches. A fetch that started before
|
|
126
|
+
* it may lack a deletion the listing already saw, so only a fetch since
|
|
127
|
+
* counts as current for the branches the run opens (`fetchedSince`).
|
|
128
|
+
*/
|
|
129
|
+
listedAt = 0;
|
|
107
130
|
constructor(opts) {
|
|
108
131
|
this.opts = opts;
|
|
132
|
+
this.repository = opts.repository;
|
|
109
133
|
}
|
|
110
134
|
/** The run in progress, so two invokers share one rather than racing clones. */
|
|
111
135
|
inFlight = null;
|
|
@@ -283,11 +307,19 @@ export class KbStartupRunner {
|
|
|
283
307
|
'process listings. Remove them from the URL and configure the token via the setup ' +
|
|
284
308
|
'screen or GITHUB_TOKEN instead.');
|
|
285
309
|
}
|
|
310
|
+
// What a previous run found deleted may have been pushed again since:
|
|
311
|
+
// each run asks afresh.
|
|
312
|
+
this.goneFromRemote.clear();
|
|
313
|
+
this.listedAt = fetchInstant();
|
|
286
314
|
const heads = await this.ensureRemote();
|
|
287
315
|
// AFTER the remote answered: the configured address is known to be a
|
|
288
316
|
// repository we can reach, so a clone of a different one is stale, not
|
|
289
317
|
// a casualty of a typo in the address.
|
|
290
318
|
await this.reconcileClonesWithConfiguredRepository();
|
|
319
|
+
// Full clones from a release before worktrees become worktrees of the
|
|
320
|
+
// one repository — after the address check, so a clone of another
|
|
321
|
+
// repository is set aside rather than imported.
|
|
322
|
+
await this.convertLegacyClones();
|
|
291
323
|
const ctx = this.buildContext(heads, handles);
|
|
292
324
|
for (const step of this.opts.steps) {
|
|
293
325
|
const started = Date.now();
|
|
@@ -338,6 +370,11 @@ export class KbStartupRunner {
|
|
|
338
370
|
for (const h of handles.values()) {
|
|
339
371
|
await this.finalize(h);
|
|
340
372
|
}
|
|
373
|
+
// The repository's garbage collection, once per boot, with every
|
|
374
|
+
// branch's work landed and nothing served yet: automatic collection is
|
|
375
|
+
// off on the repository, where a commit in one worktree would collect
|
|
376
|
+
// beside every other worktree's work.
|
|
377
|
+
await this.repository.maintain();
|
|
341
378
|
}
|
|
342
379
|
catch (err) {
|
|
343
380
|
// Every exit carries a scrubbed message: the port scrubs only what the
|
|
@@ -517,21 +554,35 @@ export class KbStartupRunner {
|
|
|
517
554
|
*/
|
|
518
555
|
async reconcileClonesWithConfiguredRepository() {
|
|
519
556
|
const configured = this.opts.kbRepoUrl();
|
|
557
|
+
// One folder per run, so what was set aside together stays together.
|
|
558
|
+
const stamp = setAsideStamp();
|
|
559
|
+
const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
|
|
560
|
+
// The repository first: its address is every worktree's. Even with no
|
|
561
|
+
// workspaces root — the repository lives outside it, and a worktree added
|
|
562
|
+
// later would fetch from whatever address it holds.
|
|
563
|
+
//
|
|
564
|
+
// The root is read BEFORE anything moves, and only an absent root counts
|
|
565
|
+
// as "no workspaces": one that cannot be read (a permission or I/O error)
|
|
566
|
+
// stops the phase, rather than letting the repository move without the
|
|
567
|
+
// worktrees it could not see.
|
|
520
568
|
let entries;
|
|
521
569
|
try {
|
|
522
570
|
entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
|
|
523
571
|
}
|
|
524
|
-
catch {
|
|
525
|
-
|
|
572
|
+
catch (err) {
|
|
573
|
+
const code = err.code;
|
|
574
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
575
|
+
throw err;
|
|
576
|
+
entries = []; // no workspaces yet
|
|
526
577
|
}
|
|
527
|
-
|
|
528
|
-
const stamp = setAsideStamp();
|
|
529
|
-
const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
|
|
578
|
+
await this.reconcileRepositoryWithConfigured(configured, path.join(setAsideRoot, stamp));
|
|
530
579
|
for (const entry of entries) {
|
|
531
580
|
if (!entry.isDirectory())
|
|
532
581
|
continue;
|
|
533
582
|
const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
|
|
534
|
-
|
|
583
|
+
// A full clone only: a worktree reads the repository's address, and went
|
|
584
|
+
// (or stayed) with it above.
|
|
585
|
+
const hasGit = await fs.stat(path.join(repoDir, '.git')).then((s) => s.isDirectory(), () => false);
|
|
535
586
|
if (!hasGit)
|
|
536
587
|
continue;
|
|
537
588
|
let origin;
|
|
@@ -565,6 +616,264 @@ export class KbStartupRunner {
|
|
|
565
616
|
await this.announceDiscarded(entry.name);
|
|
566
617
|
}
|
|
567
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* The repository's half of {@link reconcileClonesWithConfiguredRepository}.
|
|
621
|
+
* The address is the repository's, shared by every worktree, so it is
|
|
622
|
+
* decided once: the same repository at a new address is re-pointed and
|
|
623
|
+
* kept; another repository is set aside TOGETHER WITH EVERY WORKTREE OF IT,
|
|
624
|
+
* into `dest` — a worktree's commits live in the repository, so neither is
|
|
625
|
+
* of use without the other. Nothing is deleted.
|
|
626
|
+
*/
|
|
627
|
+
async reconcileRepositoryWithConfigured(configured, dest) {
|
|
628
|
+
// Read and probed as itself (reads, and a probe fetch into a throwaway
|
|
629
|
+
// ref, which git locks); the one write, the re-pointing, is made by
|
|
630
|
+
// `setOriginUrl` as the repository's administrator. The phase runs before
|
|
631
|
+
// anything is served, so nothing opens a branch between the two.
|
|
632
|
+
const verdict = await (async () => {
|
|
633
|
+
const origin = await this.repository.originUrl();
|
|
634
|
+
if (origin === null || sameRepository(origin, configured))
|
|
635
|
+
return 'keep';
|
|
636
|
+
const was = this.redact(normalizeRepositoryAddress(origin));
|
|
637
|
+
// Any commit of ours will do: a common ancestor of one is a common
|
|
638
|
+
// ancestor of the repository's history.
|
|
639
|
+
const ours = (await git(this.opts.gitRunner, this.repository.dir, [
|
|
640
|
+
'for-each-ref', '--count=1', '--format=%(objectname)', 'refs/heads', 'refs/remotes/origin',
|
|
641
|
+
]).catch(() => '')).trim();
|
|
642
|
+
if (ours !== '' && (await this.sharesHistoryWithConfigured(this.repository.dir, configured, ours))) {
|
|
643
|
+
await this.repository.setOriginUrl(configured);
|
|
644
|
+
startupLog.warn(`the knowledge-base repository was fetched from ${was}, whose history the configured repository ` +
|
|
645
|
+
'holds: the same repository at a new address. Pointed at the configured address and kept, with ' +
|
|
646
|
+
'every worktree and their unpushed work.');
|
|
647
|
+
return 'repointed';
|
|
648
|
+
}
|
|
649
|
+
return { was };
|
|
650
|
+
})();
|
|
651
|
+
if (typeof verdict === 'string')
|
|
652
|
+
return;
|
|
653
|
+
const { was } = verdict;
|
|
654
|
+
// Every worktree's work leaves first, and may stop the phase: see
|
|
655
|
+
// `beforeCloneSetAside`.
|
|
656
|
+
const worktreeIds = [];
|
|
657
|
+
for (const entry of await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true }).catch(() => [])) {
|
|
658
|
+
if (!entry.isDirectory())
|
|
659
|
+
continue;
|
|
660
|
+
const dir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
|
|
661
|
+
if (await fs.stat(path.join(dir, '.git')).then((s) => s.isFile(), () => false))
|
|
662
|
+
worktreeIds.push(entry.name);
|
|
663
|
+
}
|
|
664
|
+
for (const id of worktreeIds)
|
|
665
|
+
await this.opts.beforeCloneSetAside?.(id);
|
|
666
|
+
const moved = await setAsideRepository(this.repository, this.opts.workspacesRoot, this.opts.kbDirName, dest);
|
|
667
|
+
startupLog.warn(`the knowledge-base repository is of another repository (${was}). It and its ${moved.length} worktree(s) were ` +
|
|
668
|
+
`set aside together at ${dest}; nothing was deleted, and a fresh repository is made from the configured ` +
|
|
669
|
+
'one. Work that was never pushed is in that folder: `git log` in a worktree there shows it.');
|
|
670
|
+
for (const id of moved)
|
|
671
|
+
await this.announceDiscarded(id);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Convert every full clone under the workspaces root — what a release before
|
|
675
|
+
* worktrees left there — into a worktree of the one repository, ONE AT A
|
|
676
|
+
* TIME, without losing a commit:
|
|
677
|
+
*
|
|
678
|
+
* 1. the clone's branch tip is imported into the repository as the branch's
|
|
679
|
+
* local ref (`git fetch <clone> +refs/heads/<b>:refs/heads/<b>`), so
|
|
680
|
+
* commits the remote lacks are in the repository before anything moves,
|
|
681
|
+
* and with it every stash entry and other local ref the clone holds,
|
|
682
|
+
* under `refs/kb-converted/<workspace id>/`;
|
|
683
|
+
* 2. the clone is moved aside, beside where it was (one rename);
|
|
684
|
+
* 3. the worktree is added at the imported tip, and checked to be there;
|
|
685
|
+
* 4. the files git ignores in the clone, in no commit, are copied into it;
|
|
686
|
+
* 5. the clone is renamed `.<kb>.converted`: the one step that says the
|
|
687
|
+
* copy is complete, taken before the worktree is used, so a clone left
|
|
688
|
+
* behind after it is never copied from again (it would overwrite edits
|
|
689
|
+
* made since); a rename that fails undoes the conversion;
|
|
690
|
+
* 6. only then is the clone removed. A removal that fails leaves the
|
|
691
|
+
* `.converted` folder, which the next boot removes and nothing reads.
|
|
692
|
+
*
|
|
693
|
+
* A clone that cannot be converted — unreadable objects, uncommitted changes
|
|
694
|
+
* that are not anybody's to discard, a branch it is not on — is put back
|
|
695
|
+
* where it was and keeps working as a clone; the branch is logged, and the
|
|
696
|
+
* next boot tries again. One clone never stops the phase.
|
|
697
|
+
*
|
|
698
|
+
* A conversion a crash cut short is finished or undone first: the moved
|
|
699
|
+
* clone's ignored files are copied and it is removed when its worktree is
|
|
700
|
+
* in place (its tip was imported before it moved, and the worktree was not
|
|
701
|
+
* used yet: the rename of step 5 comes first), and put back when it is not.
|
|
702
|
+
*/
|
|
703
|
+
async convertLegacyClones() {
|
|
704
|
+
let entries;
|
|
705
|
+
try {
|
|
706
|
+
entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
let converted = 0;
|
|
712
|
+
const kept = [];
|
|
713
|
+
for (const entry of entries) {
|
|
714
|
+
if (!entry.isDirectory())
|
|
715
|
+
continue;
|
|
716
|
+
const workspaceDir = path.join(this.opts.workspacesRoot, entry.name);
|
|
717
|
+
const repoDir = path.join(workspaceDir, this.opts.kbDirName);
|
|
718
|
+
const aside = path.join(workspaceDir, `.${this.opts.kbDirName}.converting`);
|
|
719
|
+
const convertedClone = path.join(workspaceDir, `.${this.opts.kbDirName}.converted`);
|
|
720
|
+
await this.removeConvertedClone(branchForWorkspaceId(entry.name), convertedClone);
|
|
721
|
+
try {
|
|
722
|
+
await this.finishInterruptedConversion(repoDir, aside, convertedClone);
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
// Both folders stay as they are; the next start looks again.
|
|
726
|
+
kept.push(branchForWorkspaceId(entry.name));
|
|
727
|
+
startupLog.warn(`an interrupted conversion of the "${branchForWorkspaceId(entry.name)}" working copy could not be finished (` +
|
|
728
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). The moved clone is kept as it is, and the next start tries again.`);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const branch = branchForWorkspaceId(entry.name);
|
|
732
|
+
let shape;
|
|
733
|
+
try {
|
|
734
|
+
shape = await workingCopyShape(repoDir);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
// A `.git` that could not be read has shown nothing: left as it is,
|
|
738
|
+
// named, and looked at again by the next start.
|
|
739
|
+
kept.push(branch);
|
|
740
|
+
startupLog.warn(`the "${branch}" working copy could not be inspected for conversion (` +
|
|
741
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). It is left as it is, and the next start tries again.`);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (shape !== 'clone')
|
|
745
|
+
continue;
|
|
746
|
+
try {
|
|
747
|
+
if (workspaceIdForBranch(branch) !== entry.name)
|
|
748
|
+
continue;
|
|
749
|
+
assertValidBranchName(branch);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
// A clone with no commit on its branch is an unfinished one, which the
|
|
755
|
+
// branch's own pass sets aside: there is no tip to import.
|
|
756
|
+
const tip = await git(this.opts.gitRunner, repoDir, [
|
|
757
|
+
'for-each-ref', '--format=%(objectname)', `refs/heads/${branch}`,
|
|
758
|
+
]).catch(() => 'unreadable');
|
|
759
|
+
if (tip.trim() === '')
|
|
760
|
+
continue;
|
|
761
|
+
try {
|
|
762
|
+
await this.convertOneClone(branch, repoDir, aside, convertedClone);
|
|
763
|
+
converted++;
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
kept.push(branch);
|
|
767
|
+
startupLog.warn(`the "${branch}" working copy is still a full clone: it could not be converted to a worktree (` +
|
|
768
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). It keeps working as a clone, ` +
|
|
769
|
+
'nothing in it was changed, and the next start tries again.');
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (converted > 0 || kept.length > 0) {
|
|
773
|
+
startupLog.info(`converted ${converted} full clone(s) to worktrees of the knowledge-base repository` +
|
|
774
|
+
(kept.length > 0 ? `; ${kept.length} left as they are, to be tried again at the next start (${kept.join(', ')})` : '') +
|
|
775
|
+
'.');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async convertOneClone(branch, repoDir, aside, converted) {
|
|
779
|
+
const onBranch = (await git(this.opts.gitRunner, repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
780
|
+
if (onBranch !== branch)
|
|
781
|
+
throw new Error(`it is on "${onBranch}", not on its own branch`);
|
|
782
|
+
// Uncommitted work is in the working tree, not in a commit the import can
|
|
783
|
+
// carry; the clone keeps it, and is converted once it is committed.
|
|
784
|
+
const dirty = (await git(this.opts.gitRunner, repoDir, ['status', '--porcelain=v1'])).trim();
|
|
785
|
+
if (dirty !== '')
|
|
786
|
+
throw new Error('it has uncommitted changes');
|
|
787
|
+
const tip = await this.repository.importBranch(repoDir, branch);
|
|
788
|
+
await this.importLocalRefs(repoDir, branch);
|
|
789
|
+
await fs.rename(repoDir, aside);
|
|
790
|
+
try {
|
|
791
|
+
await this.repository.addWorktree(branch, repoDir, { allowLocalOnly: true, keepLocalTip: true });
|
|
792
|
+
const head = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
|
|
793
|
+
if (head !== tip)
|
|
794
|
+
throw new Error(`the worktree came up at ${head}, not at the clone's tip ${tip}`);
|
|
795
|
+
await this.copyIgnoredFiles(aside, repoDir);
|
|
796
|
+
await fs.rename(aside, converted);
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
await this.undoConversion(repoDir, aside);
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
await this.removeConvertedClone(branch, converted);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The clone's stash and its other local refs, into the repository before
|
|
806
|
+
* the clone goes (see `KbRepository.importLocalRefs`), and said where.
|
|
807
|
+
*/
|
|
808
|
+
async importLocalRefs(cloneDir, branch) {
|
|
809
|
+
const kept = await this.repository.importLocalRefs(cloneDir, branch, workspaceIdForBranch(branch));
|
|
810
|
+
if (kept.length === 0)
|
|
811
|
+
return;
|
|
812
|
+
startupLog.info(`the "${branch}" clone's stash and other local refs are kept in the knowledge-base repository (${kept.length}): ` +
|
|
813
|
+
`${kept.join(', ')}. \`git log <ref>\` in its worktree shows each.`);
|
|
814
|
+
}
|
|
815
|
+
/** Undone: the worktree goes, the clone comes back exactly as it was. */
|
|
816
|
+
async undoConversion(repoDir, aside) {
|
|
817
|
+
await fs.rm(repoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
818
|
+
await this.repository.prune().catch(() => undefined);
|
|
819
|
+
await fs.rename(aside, repoDir);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Remove a converted clone: its commits and ignored files are in the
|
|
823
|
+
* worktree already. One that cannot be removed is said and left; the next
|
|
824
|
+
* boot tries again, and nothing ever copies from it.
|
|
825
|
+
*/
|
|
826
|
+
async removeConvertedClone(branch, converted) {
|
|
827
|
+
await fs.rm(converted, { recursive: true, force: true }).catch((err) => {
|
|
828
|
+
startupLog.warn(`the converted clone of "${branch}" could not be removed (` +
|
|
829
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). Nothing reads it; the next start tries again.`);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Copy the files git ignores in the clone at `from` into the worktree at
|
|
834
|
+
* `to`: they are in no commit the import carried, yet they are the
|
|
835
|
+
* clone's. Copied, not moved, so an undo puts the clone back whole; and
|
|
836
|
+
* over whatever is there, so a copy a crash cut short (a partial file) is
|
|
837
|
+
* made whole again before the clone goes.
|
|
838
|
+
*/
|
|
839
|
+
async copyIgnoredFiles(from, to) {
|
|
840
|
+
const ignored = (await git(this.opts.gitRunner, from, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']))
|
|
841
|
+
.split('\0')
|
|
842
|
+
.map((rel) => rel.replace(/\/$/, ''))
|
|
843
|
+
.filter(Boolean);
|
|
844
|
+
for (const rel of ignored) {
|
|
845
|
+
await fs.mkdir(path.dirname(path.join(to, rel)), { recursive: true });
|
|
846
|
+
await fs.cp(path.join(from, rel), path.join(to, rel), { recursive: true, force: true, verbatimSymlinks: true });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/** See {@link convertLegacyClones}: a conversion a crash cut short. */
|
|
850
|
+
async finishInterruptedConversion(repoDir, aside, converted) {
|
|
851
|
+
if (!(await fs.access(aside).then(() => true, () => false)))
|
|
852
|
+
return;
|
|
853
|
+
// Only a checkout shown to be no worktree is removed: a link that could
|
|
854
|
+
// not be read has shown nothing, and the error leaves both folders alone.
|
|
855
|
+
if ((await workingCopyShape(repoDir)) === 'worktree') {
|
|
856
|
+
// Not used since it was added: the clone is still `.converting`, so
|
|
857
|
+
// the copy is finished (over a partial one) as the conversion would
|
|
858
|
+
// have, and undone when it cannot be.
|
|
859
|
+
try {
|
|
860
|
+
// Imported before the clone moved; again, in case that was by a
|
|
861
|
+
// release that imported its branch only.
|
|
862
|
+
await this.importLocalRefs(aside, branchForWorkspaceId(path.basename(path.dirname(repoDir))));
|
|
863
|
+
await this.copyIgnoredFiles(aside, repoDir);
|
|
864
|
+
await fs.rename(aside, converted);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
await this.undoConversion(repoDir, aside);
|
|
868
|
+
throw err;
|
|
869
|
+
}
|
|
870
|
+
await this.removeConvertedClone(branchForWorkspaceId(path.basename(path.dirname(repoDir))), converted);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
874
|
+
await this.repository.prune().catch(() => undefined);
|
|
875
|
+
await fs.rename(aside, repoDir);
|
|
876
|
+
}
|
|
568
877
|
/**
|
|
569
878
|
* Whether the configured repository holds history this clone has too —
|
|
570
879
|
* asked of git, not guessed from the two addresses. The configured
|
|
@@ -576,7 +885,7 @@ export class KbStartupRunner {
|
|
|
576
885
|
* a wrong true would push a stranger's commits into the configured
|
|
577
886
|
* repository.
|
|
578
887
|
*/
|
|
579
|
-
async sharesHistoryWithConfigured(repoDir, configured) {
|
|
888
|
+
async sharesHistoryWithConfigured(repoDir, configured, ours = 'HEAD') {
|
|
580
889
|
const probe = 'refs/hexis-probe';
|
|
581
890
|
let theirs = [];
|
|
582
891
|
try {
|
|
@@ -590,7 +899,7 @@ export class KbStartupRunner {
|
|
|
590
899
|
// One question for all of them: a common ancestor of HEAD and ANY of
|
|
591
900
|
// their branches is a common ancestor of HEAD and their merge. Capped,
|
|
592
901
|
// so a remote with thousands of branches cannot outgrow a command line.
|
|
593
|
-
await git(this.opts.gitRunner, repoDir, ['merge-base',
|
|
902
|
+
await git(this.opts.gitRunner, repoDir, ['merge-base', ours, ...theirs.slice(0, 200)]);
|
|
594
903
|
return true;
|
|
595
904
|
}
|
|
596
905
|
catch {
|
|
@@ -644,9 +953,13 @@ export class KbStartupRunner {
|
|
|
644
953
|
const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
|
|
645
954
|
const repoDir = path.join(workspaceDir, this.opts.kbDirName);
|
|
646
955
|
for (let pass = 1;; pass++) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
956
|
+
// A worktree whose link to the repository is broken has a `.git` and is
|
|
957
|
+
// no working copy: it is set aside as an unfinished clone is, below.
|
|
958
|
+
const shape = await workingCopyShape(repoDir);
|
|
959
|
+
const hasGit = shape !== 'none';
|
|
960
|
+
if ((shape === 'worktree' || shape === 'clone') && (await this.hasCommit(repoDir))) {
|
|
961
|
+
return this.updateClone(branch, repoDir, shape === 'worktree');
|
|
962
|
+
}
|
|
650
963
|
if (pass > PASSES_OVER_ONE_WORKING_COPY) {
|
|
651
964
|
throw new Error('it kept changing while this start was preparing it; something else is working on it');
|
|
652
965
|
}
|
|
@@ -709,8 +1022,10 @@ export class KbStartupRunner {
|
|
|
709
1022
|
await this.announceDiscarded(id);
|
|
710
1023
|
}
|
|
711
1024
|
async isUnfinishedClone(repoDir) {
|
|
712
|
-
const
|
|
713
|
-
|
|
1025
|
+
const shape = await workingCopyShape(repoDir);
|
|
1026
|
+
if (shape === 'broken')
|
|
1027
|
+
return true;
|
|
1028
|
+
return shape !== 'none' && !(await this.hasCommit(repoDir));
|
|
714
1029
|
}
|
|
715
1030
|
/**
|
|
716
1031
|
* Wait until nothing under the unfinished clone's `.git` has been written
|
|
@@ -729,7 +1044,11 @@ export class KbStartupRunner {
|
|
|
729
1044
|
for (;;) {
|
|
730
1045
|
if (!(await this.isUnfinishedClone(repoDir)))
|
|
731
1046
|
return false;
|
|
732
|
-
|
|
1047
|
+
// A clone writes under its `.git` folder; a worktree being added
|
|
1048
|
+
// writes its checkout, its `.git` being a file.
|
|
1049
|
+
const dotGit = path.join(repoDir, '.git');
|
|
1050
|
+
const watched = (await fs.stat(dotGit).then((s) => s.isDirectory(), () => false)) ? dotGit : repoDir;
|
|
1051
|
+
const lastWrite = await newestChangeUnder(watched);
|
|
733
1052
|
const quietFor = Date.now() - lastWrite;
|
|
734
1053
|
if (quietFor >= quietMs)
|
|
735
1054
|
return true;
|
|
@@ -763,16 +1082,21 @@ export class KbStartupRunner {
|
|
|
763
1082
|
}
|
|
764
1083
|
async cloneFresh(branch, workspaceDir, repoDir) {
|
|
765
1084
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
766
|
-
// Only what cannot be a
|
|
767
|
-
// gained a `.git` since the caller looked is somebody's
|
|
1085
|
+
// Only what cannot be a working copy is cleared out of the way: a folder
|
|
1086
|
+
// that gained a `.git` since the caller looked is somebody's, and git
|
|
768
1087
|
// refuses it below rather than this removing it.
|
|
769
1088
|
if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
|
|
770
1089
|
await fs.rm(repoDir, { recursive: true, force: true });
|
|
771
1090
|
}
|
|
772
|
-
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
1091
|
+
// A worktree of the one repository, which is made (and fetched) by the
|
|
1092
|
+
// first branch that needs it: no history is copied per branch. The
|
|
1093
|
+
// repository carries the identity, the long-paths setting and the
|
|
1094
|
+
// credential helper for every worktree.
|
|
1095
|
+
// The phase opens every branch: the repository's one fetch in the window
|
|
1096
|
+
// brought them all, so each add reads it instead of asking the remote
|
|
1097
|
+
// for its branch again. A fetch that failed leaves the add to ask.
|
|
1098
|
+
await this.repository.fetch(this.fetchWindow());
|
|
1099
|
+
await this.repository.addWorktree(branch, repoDir, { fetchedSince: this.fetchedSince() });
|
|
776
1100
|
try {
|
|
777
1101
|
this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
|
|
778
1102
|
}
|
|
@@ -781,12 +1105,42 @@ export class KbStartupRunner {
|
|
|
781
1105
|
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
782
1106
|
});
|
|
783
1107
|
}
|
|
784
|
-
await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
|
|
785
|
-
await stampIdentity(this.opts.gitRunner, repoDir);
|
|
786
1108
|
return repoDir;
|
|
787
1109
|
}
|
|
788
|
-
|
|
789
|
-
|
|
1110
|
+
/**
|
|
1111
|
+
* The earliest a fetch of the repository may have started and still count
|
|
1112
|
+
* for this run: never before its listing, never older than the window. An
|
|
1113
|
+
* instant, so the repository compares it when it gets the reservation and
|
|
1114
|
+
* time spent waiting for it does not age the fetch out.
|
|
1115
|
+
*/
|
|
1116
|
+
fetchedSince() {
|
|
1117
|
+
return Math.max(this.listedAt, Date.now() - STARTUP_FETCH_WINDOW_MS);
|
|
1118
|
+
}
|
|
1119
|
+
fetchWindow() {
|
|
1120
|
+
return { maxAgeMs: STARTUP_FETCH_WINDOW_MS, fetchedSince: this.fetchedSince() };
|
|
1121
|
+
}
|
|
1122
|
+
async updateClone(branch, repoDir, worktree = false) {
|
|
1123
|
+
if (worktree) {
|
|
1124
|
+
// ONE fetch for every worktree the phase visits, within the window: the
|
|
1125
|
+
// repository's. The phase is fail-closed, so a failed fetch stops it
|
|
1126
|
+
// with git's words, as the clone's own fetch did.
|
|
1127
|
+
if (!(await this.repository.fetch(this.fetchWindow()))) {
|
|
1128
|
+
const raw = `git fetch origin failed: ${String(this.repository.lastFetchFailure() ?? 'unknown failure')}`;
|
|
1129
|
+
throw new ClassifiedFailure(this.redact(raw), classifyGitFailure(raw));
|
|
1130
|
+
}
|
|
1131
|
+
// The branch was listed before the fetch and deleted on the remote
|
|
1132
|
+
// since: nothing to move the worktree to. Its commits stay where they
|
|
1133
|
+
// are, reachable from its branch, as any other branch's do.
|
|
1134
|
+
if (!(await this.hasRemoteBranch(repoDir, branch))) {
|
|
1135
|
+
this.goneFromRemote.add(branch);
|
|
1136
|
+
startupLog.warn(`branch "${branch}" was deleted on the remote during this start: its working copy is left as it is`);
|
|
1137
|
+
return repoDir;
|
|
1138
|
+
}
|
|
1139
|
+
this.goneFromRemote.delete(branch);
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
|
|
1143
|
+
}
|
|
790
1144
|
const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
|
|
791
1145
|
const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
|
|
792
1146
|
if (local !== remote) {
|
|
@@ -798,6 +1152,23 @@ export class KbStartupRunner {
|
|
|
798
1152
|
}
|
|
799
1153
|
return repoDir;
|
|
800
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Whether the repository's remote-tracking ref for `branch` exists. False
|
|
1157
|
+
* only when git answered that it does not (`--verify --quiet`, exit 1);
|
|
1158
|
+
* any other failure is thrown.
|
|
1159
|
+
*/
|
|
1160
|
+
async hasRemoteBranch(repoDir, branch) {
|
|
1161
|
+
try {
|
|
1162
|
+
await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
catch (err) {
|
|
1166
|
+
const ran = err instanceof Error ? err.cause : undefined;
|
|
1167
|
+
if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1)
|
|
1168
|
+
return false;
|
|
1169
|
+
throw err;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
801
1172
|
/**
|
|
802
1173
|
* Whether the repository at `repoDir` has a commit checked out. False for a
|
|
803
1174
|
* clone that was cut short.
|
|
@@ -825,8 +1196,15 @@ export class KbStartupRunner {
|
|
|
825
1196
|
async finalize(h) {
|
|
826
1197
|
if (!h.dirty)
|
|
827
1198
|
return;
|
|
1199
|
+
if (this.goneFromRemote.has(h.name)) {
|
|
1200
|
+
startupLog.warn(`${h.name}: deleted on the remote during this start; the phase's changes are not committed or pushed`);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
828
1203
|
const repoDir = await h.repoDir();
|
|
829
|
-
|
|
1204
|
+
// A worktree's identity is the repository's, stamped when it was made; a
|
|
1205
|
+
// config write from here would contend with every other worktree's.
|
|
1206
|
+
if ((await workingCopyShape(repoDir)) !== 'worktree')
|
|
1207
|
+
await stampIdentity(this.opts.gitRunner, repoDir);
|
|
830
1208
|
// Drop any PRE-EXISTING index state first (a crashed tool may have left
|
|
831
1209
|
// edits staged): `git commit` publishes the whole index, and the phase
|
|
832
1210
|
// must commit exactly its own staged set. The edits stay in the working
|