@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
2
3
|
import { logger } from '../../shared/logging.js';
|
|
3
4
|
|
|
4
5
|
const log = logger('workspace');
|
|
@@ -44,9 +45,10 @@ import {
|
|
|
44
45
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
45
46
|
import { normalizeRepositoryAddress, sameRepository } from '../kb-fs/remote-url.js';
|
|
46
47
|
import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
|
|
47
|
-
import { setAsideClone, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
|
|
48
|
+
import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
|
|
49
|
+
import { workingCopyShape } from './kb-repository.js';
|
|
50
|
+
import type { IKbRepository } from './kb-repository.contract.js';
|
|
48
51
|
import {
|
|
49
|
-
cloneCredentialArgs,
|
|
50
52
|
cloneCredentialConfigArgs,
|
|
51
53
|
credentialHelperValue,
|
|
52
54
|
cloneTrackingConfigArgs,
|
|
@@ -72,12 +74,18 @@ interface HeldTurn {
|
|
|
72
74
|
* Directory layout on disk:
|
|
73
75
|
* <workspacesRoot>/
|
|
74
76
|
* <encodeURIComponent(branch)>/
|
|
75
|
-
* knowledge-base/ #
|
|
77
|
+
* knowledge-base/ # a worktree of the KB repository, on <branch>
|
|
78
|
+
* <repository>/knowledge-base.git # the one repository (see `kb-repository.ts`)
|
|
76
79
|
*
|
|
77
|
-
* Bootstrap is lazy: the first call to `getOrCreateForBranch(branch)`
|
|
78
|
-
* the
|
|
79
|
-
* a different one — share the same directory and the same
|
|
80
|
-
* serialised through the workspace mutex elsewhere in the
|
|
80
|
+
* Bootstrap is lazy: the first call to `getOrCreateForBranch(branch)` adds
|
|
81
|
+
* the branch's worktree, which copies no history. Subsequent callers — whether
|
|
82
|
+
* the same user or a different one — share the same directory and the same
|
|
83
|
+
* git operations are serialised through the workspace mutex elsewhere in the
|
|
84
|
+
* codebase.
|
|
85
|
+
*
|
|
86
|
+
* A checkout folder that is still a full CLONE (from a release before
|
|
87
|
+
* worktrees, which the KB startup phase converts at boot) is served as it is
|
|
88
|
+
* until it is converted: every method here accepts both shapes.
|
|
81
89
|
*/
|
|
82
90
|
|
|
83
91
|
export interface UnzipResult {
|
|
@@ -150,23 +158,6 @@ export class EntryExistsError extends WorkflowDomainError {
|
|
|
150
158
|
}
|
|
151
159
|
}
|
|
152
160
|
|
|
153
|
-
/**
|
|
154
|
-
* A floor under the git deadline for a clone: a first clone of a large
|
|
155
|
-
* knowledge base over a slow link legitimately takes minutes, and the port's
|
|
156
|
-
* default is sized for a running deployment's operations. A configured
|
|
157
|
-
* ceiling above this applies as configured.
|
|
158
|
-
*/
|
|
159
|
-
const CLONE_TIMEOUT_MS = 600_000;
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Identity stamped on the per-branch clone's git config. Every workflow
|
|
163
|
-
* commit overrides this with `--author=…` so the human shows up in
|
|
164
|
-
* `git log`; this is just the fallback committer identity so plain
|
|
165
|
-
* git operations have something to attribute to.
|
|
166
|
-
*/
|
|
167
|
-
const BOT_NAME = 'Bevel Workflow';
|
|
168
|
-
const BOT_EMAIL = 'bevel-workflow@bevel.software';
|
|
169
|
-
|
|
170
161
|
/** Redact the token the runner authenticates with from an error message. */
|
|
171
162
|
function redactError(err: unknown, token: string | null): string {
|
|
172
163
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -333,7 +324,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
333
324
|
*
|
|
334
325
|
* Why, in one place: `setAsideRootFor` in `set-aside-clone.ts`.
|
|
335
326
|
*/
|
|
336
|
-
private readonly setAsideRoot
|
|
327
|
+
private readonly setAsideRoot: string | undefined,
|
|
337
328
|
/**
|
|
338
329
|
* What surrounds the setting aside of ONE working copy when a branch is
|
|
339
330
|
* opened and its clone turns out to be of another repository: given the
|
|
@@ -348,10 +339,59 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
348
339
|
_workspaceId,
|
|
349
340
|
move,
|
|
350
341
|
) => move(),
|
|
342
|
+
/**
|
|
343
|
+
* The one repository every branch workspace is a worktree of — THE SAME
|
|
344
|
+
* INSTANCE THE KB STARTUP PHASE AND THE GIT LAYER USE, so that one fetch
|
|
345
|
+
* serves them all and its reservation is one. Built by the composition
|
|
346
|
+
* root, never here.
|
|
347
|
+
*/
|
|
348
|
+
readonly repository: IKbRepository,
|
|
351
349
|
) {
|
|
352
350
|
this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
|
|
353
351
|
}
|
|
354
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Whether the checkout folder `repoDir` is a worktree (its `.git` a file)
|
|
355
|
+
* rather than a full clone. A stat, no git.
|
|
356
|
+
*/
|
|
357
|
+
private async isWorktree(repoDir: string): Promise<boolean> {
|
|
358
|
+
// Synchronous on purpose: it is one stat of a local file, and it sits in
|
|
359
|
+
// front of the fetch joins (`ensureRemotesFetched`), where an await
|
|
360
|
+
// between two concurrent callers' in-flight checks is what lets both
|
|
361
|
+
// start a fetch.
|
|
362
|
+
try {
|
|
363
|
+
return statSync(path.join(repoDir, '.git')).isFile();
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Whether `repoDir` holds a working copy that can be served: a worktree of
|
|
371
|
+
* the repository or a full clone. A worktree whose link to the repository is
|
|
372
|
+
* broken is NOT one — it is an unfinished checkout, recreated on the next
|
|
373
|
+
* open. Errors other than absence propagate.
|
|
374
|
+
*/
|
|
375
|
+
private async hasWorkingCopy(repoDir: string): Promise<boolean> {
|
|
376
|
+
const shape = await workingCopyShape(repoDir);
|
|
377
|
+
return shape === 'worktree' || shape === 'clone';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The repository behind a workspace when it is a worktree of it; null for a
|
|
382
|
+
* full clone (which fetches on its own) or a workspace with no checkout.
|
|
383
|
+
* For the git layer, which keys its fetches on this.
|
|
384
|
+
*/
|
|
385
|
+
async sharedRepositoryOf(workspaceId: string): Promise<IKbRepository | null> {
|
|
386
|
+
const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
|
|
387
|
+
return this.repositoryOfCheckout(path.join(workspaceDir, this.kbDirName));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** {@link sharedRepositoryOf}, for a caller that already has the checkout folder. */
|
|
391
|
+
async repositoryOfCheckout(repoDir: string): Promise<IKbRepository | null> {
|
|
392
|
+
return (await this.isWorktree(repoDir)) ? this.repository : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
355
395
|
/**
|
|
356
396
|
* The set-asides in flight, one per branch. Two callers opening the same
|
|
357
397
|
* branch both find the clone of another repository; the second waits for
|
|
@@ -488,63 +528,6 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
488
528
|
}
|
|
489
529
|
}
|
|
490
530
|
|
|
491
|
-
/**
|
|
492
|
-
* Build git clone args. Credentials go in as `clone --config`, which both
|
|
493
|
-
* authenticates this clone and leaves the helper in the new repo's config for
|
|
494
|
-
* every later push — and keeps the token itself out of the repo URL, out of
|
|
495
|
-
* argv, and out of `.git/config` (the helper reads it from the environment).
|
|
496
|
-
*/
|
|
497
|
-
private gitCloneArgs(targetDir: string, branch: string, referenceRepo?: string): string[] {
|
|
498
|
-
// core.longpaths=true: KB has paths >260 chars; without this the clone silently drops those files on Windows.
|
|
499
|
-
const args = ['clone', '-c', 'core.longpaths=true', '-b', branch];
|
|
500
|
-
// Borrow the object database of an existing sibling clone. Every commit
|
|
501
|
-
// in the KB is already on disk locally, so object transfer drops to
|
|
502
|
-
// ~zero and a cold branch switch pays only the ref-negotiation
|
|
503
|
-
// round-trip instead of a full download. `--dissociate` copies the
|
|
504
|
-
// borrowed objects into the new repo and drops the alternates link, so
|
|
505
|
-
// the new clone stays independent even if the sibling is later swept
|
|
506
|
-
// or gc'd. Still a real clone of the real remote — all `refs/remotes/
|
|
507
|
-
// origin/*` are populated as usual.
|
|
508
|
-
if (referenceRepo) {
|
|
509
|
-
args.push('--reference', referenceRepo, '--dissociate');
|
|
510
|
-
}
|
|
511
|
-
// Persisted into the new repo's config (`clone --config`), not just applied
|
|
512
|
-
// to this invocation — every later push from this clone depends on finding
|
|
513
|
-
// the helper there. See `cloneCredentialArgs`.
|
|
514
|
-
args.push(...cloneCredentialArgs(this.gitRunner.credentials));
|
|
515
|
-
args.push(this.kbRepoUrl(), targetDir);
|
|
516
|
-
return args;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Find an existing workspace clone on disk to use as a `--reference` for
|
|
521
|
-
* a new clone. Any clone of the KB works — they all share the same object
|
|
522
|
-
* history. Returns the absolute path to the inner repo dir, or null when
|
|
523
|
-
* no sibling exists (the first branch after a cold start). Scans disk
|
|
524
|
-
* rather than the in-memory `branchDirs` map so clones that survived a
|
|
525
|
-
* process restart are still found.
|
|
526
|
-
*/
|
|
527
|
-
private async findSiblingRepo(excludeBranch: string): Promise<string | null> {
|
|
528
|
-
const excludeId = workspaceIdForBranch(excludeBranch);
|
|
529
|
-
let entries: Array<{ name: string; isDirectory: () => boolean }>;
|
|
530
|
-
try {
|
|
531
|
-
entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
|
|
532
|
-
} catch {
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
for (const entry of entries) {
|
|
536
|
-
if (!entry.isDirectory() || entry.name === excludeId) continue;
|
|
537
|
-
const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
|
|
538
|
-
try {
|
|
539
|
-
await fs.access(path.join(repoDir, '.git'));
|
|
540
|
-
return repoDir;
|
|
541
|
-
} catch {
|
|
542
|
-
// No `.git` — half-built or unrelated dir; keep looking.
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
531
|
/**
|
|
549
532
|
* Return the id of any workspace already cloned on disk, or null if none
|
|
550
533
|
* exists. Every clone tracks the same origin, so for a *repo-global* operation
|
|
@@ -578,12 +561,9 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
578
561
|
for (const entry of entries) {
|
|
579
562
|
if (!entry.isDirectory()) continue;
|
|
580
563
|
const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// No `.git` — half-built or unrelated dir; keep looking.
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
564
|
+
// No working copy — half-built, a worktree whose repository is gone, or
|
|
565
|
+
// an unrelated dir; keep looking.
|
|
566
|
+
if (!(await this.hasWorkingCopy(repoDir).catch(() => false))) continue;
|
|
587
567
|
await this.refreshCredentialHelperIfStale(branchForWorkspaceId(entry.name), repoDir);
|
|
588
568
|
return entry.name;
|
|
589
569
|
}
|
|
@@ -635,7 +615,9 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
635
615
|
continue;
|
|
636
616
|
}
|
|
637
617
|
try {
|
|
638
|
-
|
|
618
|
+
// A worktree whose repository is gone is not a working copy to pull:
|
|
619
|
+
// it is recreated when the branch is next opened.
|
|
620
|
+
if (!(await this.hasWorkingCopy(path.join(this.workspacesRoot, entry.name, this.kbDirName)))) continue;
|
|
639
621
|
} catch (err) {
|
|
640
622
|
if (isAbsence(err)) continue;
|
|
641
623
|
cloned.push({
|
|
@@ -655,36 +637,6 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
655
637
|
return cloned;
|
|
656
638
|
}
|
|
657
639
|
|
|
658
|
-
/**
|
|
659
|
-
* Run the clone. When a `referenceRepo` is supplied and the referenced
|
|
660
|
-
* clone fails (e.g. the sibling's object store is corrupt or mid-write),
|
|
661
|
-
* fall back once to a plain network clone so a bad sibling can never
|
|
662
|
-
* block a new branch from bootstrapping.
|
|
663
|
-
*/
|
|
664
|
-
private async runClone(
|
|
665
|
-
targetDir: string,
|
|
666
|
-
branch: string,
|
|
667
|
-
referenceRepo: string | null,
|
|
668
|
-
): Promise<void> {
|
|
669
|
-
// Long enough for a first clone, whatever the configured ceiling says.
|
|
670
|
-
const timeoutMs = Math.max(this.gitRunner.defaultTimeoutMs, CLONE_TIMEOUT_MS);
|
|
671
|
-
if (referenceRepo) {
|
|
672
|
-
try {
|
|
673
|
-
await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch, referenceRepo), {
|
|
674
|
-
timeoutMs,
|
|
675
|
-
});
|
|
676
|
-
return;
|
|
677
|
-
} catch (err) {
|
|
678
|
-
log.warn(`referenced clone for "${branch}" failed, retrying without reference:`, {
|
|
679
|
-
detail: redactError(err, this.gitRunner.credentials.token()),
|
|
680
|
-
});
|
|
681
|
-
// Clear any partial output so the retry clones into a clean dir.
|
|
682
|
-
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch), { timeoutMs });
|
|
686
|
-
}
|
|
687
|
-
|
|
688
640
|
/**
|
|
689
641
|
* Get-or-create the per-branch workspace. Idempotent — repeat calls for
|
|
690
642
|
* the same branch reuse the existing clone. Concurrent first-callers
|
|
@@ -723,9 +675,11 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
723
675
|
return this.buildWorkspaceInfo(branch, workspaceDir);
|
|
724
676
|
}
|
|
725
677
|
|
|
726
|
-
// Check disk — the clone
|
|
727
|
-
// process and survived a restart.
|
|
728
|
-
|
|
678
|
+
// Check disk — the worktree (or a clone from an earlier release) may have
|
|
679
|
+
// been made by a previous process and survived a restart. A worktree whose
|
|
680
|
+
// repository is gone is not on disk in any useful sense: bootstrap below
|
|
681
|
+
// recreates it.
|
|
682
|
+
const onDisk = await this.hasWorkingCopy(repoDir).catch(() => false);
|
|
729
683
|
// A clone fetches and pushes through the address stored in its OWN
|
|
730
684
|
// `remote.origin.url`, which nothing rewrites when the configured
|
|
731
685
|
// repository changes. Adopting one of the repository that was replaced is
|
|
@@ -757,7 +711,14 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
757
711
|
// Someone else is moving it. Wait, then clone below; a move that failed
|
|
758
712
|
// refuses this caller with the same reason.
|
|
759
713
|
await setAsideInFlight;
|
|
760
|
-
} else if (
|
|
714
|
+
} else if (
|
|
715
|
+
onDisk
|
|
716
|
+
? await this.isCloneOfAnotherRepository(repoDir)
|
|
717
|
+
: // Nothing of this branch on disk yet, but the worktree it is about to
|
|
718
|
+
// get would be of the one repository — whose address may be the
|
|
719
|
+
// replaced one, until the startup phase has been through.
|
|
720
|
+
await this.isRepositoryOfAnother()
|
|
721
|
+
) {
|
|
761
722
|
// Asked again: another caller may have started the move while the
|
|
762
723
|
// address was being read.
|
|
763
724
|
const startedMeanwhile = this.inFlightSetAsides.get(branch);
|
|
@@ -772,13 +733,51 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
772
733
|
// at this path NOW, which is the fresh clone the first caller is in
|
|
773
734
|
// the middle of making. Nothing is left to move; the bootstrap below
|
|
774
735
|
// waits for that clone.
|
|
736
|
+
} else if (!onDisk || (await this.isWorktree(repoDir))) {
|
|
737
|
+
// A worktree of a repository of another address: the address is the
|
|
738
|
+
// REPOSITORY's, and its commits are the repository's, so the repository
|
|
739
|
+
// and every worktree of it are set aside together, into one folder.
|
|
740
|
+
const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp());
|
|
741
|
+
const ids = await this.worktreeIdsOnDisk();
|
|
742
|
+
if (onDisk && !ids.includes(id)) ids.push(id);
|
|
743
|
+
let movedIds: string[] | null = null;
|
|
744
|
+
const move = async (): Promise<void> => {
|
|
745
|
+
if (this.discardsOf(id) !== discardsAtAdoption) return;
|
|
746
|
+
// Asked again under the reservation: two branches opened at once
|
|
747
|
+
// both read the old address, and the first one's set-aside and fresh
|
|
748
|
+
// repository may be done by the time this one holds it. Moving then
|
|
749
|
+
// would set aside the replacement and the first branch's worktree.
|
|
750
|
+
let superseded = false;
|
|
751
|
+
const ids = await setAsideRepository(this.repository, this.workspacesRoot, this.kbDirName, kept, async () => {
|
|
752
|
+
superseded = !(await this.isRepositoryOfAnother());
|
|
753
|
+
return !superseded;
|
|
754
|
+
});
|
|
755
|
+
if (superseded) return;
|
|
756
|
+
movedIds = ids;
|
|
757
|
+
for (const movedId of movedIds) this.forgetClone(movedId);
|
|
758
|
+
};
|
|
759
|
+
// What surrounds a set-aside is asked for every workspace that goes,
|
|
760
|
+
// nested, so the move runs once with all of them held.
|
|
761
|
+
const wrapped = ids.reduce<() => Promise<void>>((inner, wid) => () => this.aroundSetAside(wid, inner), move);
|
|
762
|
+
const moving = this.settleSetAside(wrapped(), () => movedIds !== null, branch).finally(() => {
|
|
763
|
+
this.inFlightSetAsides.delete(branch);
|
|
764
|
+
});
|
|
765
|
+
this.inFlightSetAsides.set(branch, moving);
|
|
766
|
+
await moving;
|
|
767
|
+
if (movedIds) {
|
|
768
|
+
log.warn(
|
|
769
|
+
`the knowledge-base repository and its ${(movedIds as string[]).length} worktree(s) were of another ` +
|
|
770
|
+
`repository. Set aside together at ${kept}; nothing was deleted, and "${branch}" is being opened ` +
|
|
771
|
+
'fresh from the configured one. Work that was never pushed is in that folder.',
|
|
772
|
+
);
|
|
773
|
+
}
|
|
775
774
|
} else {
|
|
776
775
|
const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp(), id);
|
|
777
776
|
// What surrounds the move may wait, and the startup phase may set
|
|
778
777
|
// this very copy aside meanwhile. Then there is nothing left to move:
|
|
779
778
|
// the phase did it, and said so.
|
|
780
779
|
let moved = false;
|
|
781
|
-
const
|
|
780
|
+
const move = async (): Promise<void> => {
|
|
782
781
|
if (this.discardsOf(id) !== discardsAtAdoption) return;
|
|
783
782
|
await setAsideClone(repoDir, kept);
|
|
784
783
|
moved = true;
|
|
@@ -787,7 +786,8 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
787
786
|
// that looked at the old copy before this line knows its answer is
|
|
788
787
|
// stale, however late it comes back.
|
|
789
788
|
this.forgetClone(id);
|
|
790
|
-
}
|
|
789
|
+
};
|
|
790
|
+
const moving = this.settleSetAside(this.aroundSetAside(id, move), () => moved, branch).finally(() => {
|
|
791
791
|
this.inFlightSetAsides.delete(branch);
|
|
792
792
|
});
|
|
793
793
|
this.inFlightSetAsides.set(branch, moving);
|
|
@@ -963,7 +963,39 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
963
963
|
* process that does not know where the repository is has no business
|
|
964
964
|
* declaring a clone stale.
|
|
965
965
|
*/
|
|
966
|
-
|
|
966
|
+
/**
|
|
967
|
+
* Wait for a set-aside. Its failure refuses the branch only while nothing
|
|
968
|
+
* has moved: once the move happened, what failed is what runs AFTER it
|
|
969
|
+
* around the move (`aroundSetAside` looking for the copy's work again,
|
|
970
|
+
* which already retries itself), and refusing then would report a move that
|
|
971
|
+
* did happen as one that did not. That failure is logged, and the branch is
|
|
972
|
+
* opened fresh.
|
|
973
|
+
*/
|
|
974
|
+
private async settleSetAside(moving: Promise<void>, didMove: () => boolean, branch: string): Promise<void> {
|
|
975
|
+
try {
|
|
976
|
+
await moving;
|
|
977
|
+
} catch (err) {
|
|
978
|
+
if (!didMove()) throw err;
|
|
979
|
+
log.error(
|
|
980
|
+
`the working copy of "${branch}" was set aside, but what surrounds the move failed after it; the branch ` +
|
|
981
|
+
'is opened fresh:',
|
|
982
|
+
{ detail: redactError(err, this.gitRunner.credentials.token()) },
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Whether the one repository is on disk, finished, and of an address that
|
|
989
|
+
* is not the configured one. For a branch opened with nothing on disk: its
|
|
990
|
+
* worktree would be added to that repository and fetch from the old
|
|
991
|
+
* address.
|
|
992
|
+
*/
|
|
993
|
+
private async isRepositoryOfAnother(): Promise<boolean> {
|
|
994
|
+
if (!(await this.repository.exists())) return false;
|
|
995
|
+
return this.isCloneOfAnotherRepository(this.repository.dir, 'knowledge-base repository');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private async isCloneOfAnotherRepository(repoDir: string, label?: string): Promise<boolean> {
|
|
967
999
|
const configured = this.kbRepoUrl().trim();
|
|
968
1000
|
if (configured === '') return false;
|
|
969
1001
|
let origin: string;
|
|
@@ -979,7 +1011,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
979
1011
|
// signed URL — see `urlQuerySecrets`). So the address is scrubbed on the
|
|
980
1012
|
// way into the log, exactly as the startup phase scrubs its own.
|
|
981
1013
|
log.warn(
|
|
982
|
-
`the "${path.basename(path.dirname(repoDir))}" working copy is a clone of another repository ` +
|
|
1014
|
+
`the ${label ?? `"${path.basename(path.dirname(repoDir))}" working copy`} is a clone of another repository ` +
|
|
983
1015
|
`(${redactSecret(normalizeRepositoryAddress(origin), urlQuerySecrets(origin))}).`,
|
|
984
1016
|
);
|
|
985
1017
|
return true;
|
|
@@ -1064,6 +1096,20 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1064
1096
|
* tracking keys before it pulls.
|
|
1065
1097
|
*/
|
|
1066
1098
|
private async normalizeCloneConfig(repoDir: string, branch: string): Promise<void> {
|
|
1099
|
+
if (await this.isWorktree(repoDir)) {
|
|
1100
|
+
// A worktree reads the repository's one config: the branch's keys are
|
|
1101
|
+
// written there, one writer at a time, and the credential helper is the
|
|
1102
|
+
// repository's, stamped once for every worktree.
|
|
1103
|
+
try {
|
|
1104
|
+
await this.repository.writeConfig(repoDir, cloneTrackingConfigArgs(branch));
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
log.warn(`could not normalize the config of the "${branch}" worktree:`, {
|
|
1107
|
+
detail: redactError(err, this.gitRunner.credentials.token()),
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
await this.repository.refreshCredentials();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1067
1113
|
try {
|
|
1068
1114
|
for (const args of cloneTrackingConfigArgs(branch)) {
|
|
1069
1115
|
await this.gitRunner.run(repoDir, args);
|
|
@@ -1156,9 +1202,31 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1156
1202
|
*/
|
|
1157
1203
|
private async refreshCredentialHelperIfStale(branch: string, repoDir: string): Promise<void> {
|
|
1158
1204
|
if (this.stampedCredentialFingerprint.get(branch) === this.credentialFingerprint()) return;
|
|
1205
|
+
if (await this.isWorktree(repoDir)) {
|
|
1206
|
+
// The repository's helper serves every worktree; it keeps its own
|
|
1207
|
+
// fingerprint, and a no-op costs no git.
|
|
1208
|
+
await this.repository.refreshCredentials();
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1159
1211
|
await this.stampCredentialHelper(repoDir, branch);
|
|
1160
1212
|
}
|
|
1161
1213
|
|
|
1214
|
+
/** The workspace ids whose checkout folder is a worktree (sound or broken) — a disk scan. */
|
|
1215
|
+
private async worktreeIdsOnDisk(): Promise<string[]> {
|
|
1216
|
+
let entries: Array<{ name: string; isDirectory: () => boolean }>;
|
|
1217
|
+
try {
|
|
1218
|
+
entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
|
|
1219
|
+
} catch {
|
|
1220
|
+
return [];
|
|
1221
|
+
}
|
|
1222
|
+
const ids: string[] = [];
|
|
1223
|
+
for (const entry of entries) {
|
|
1224
|
+
if (!entry.isDirectory()) continue;
|
|
1225
|
+
if (await this.isWorktree(path.join(this.workspacesRoot, entry.name, this.kbDirName))) ids.push(entry.name);
|
|
1226
|
+
}
|
|
1227
|
+
return ids;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1162
1230
|
private async cloneProcessMapForBranch(workspaceDir: string, branch: string): Promise<void> {
|
|
1163
1231
|
const targetDir = path.join(workspaceDir, this.kbDirName);
|
|
1164
1232
|
|
|
@@ -1187,16 +1255,14 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1187
1255
|
|
|
1188
1256
|
if (target !== null) {
|
|
1189
1257
|
// The directory exists, but a previous bootstrap may have crashed
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1258
|
+
// midway — leaving a directory shell without `.git` — or it is a
|
|
1259
|
+
// worktree whose link to the repository is broken (the repository was
|
|
1260
|
+
// lost, or the worktree pruned from under it). Either is an unfinished
|
|
1261
|
+
// checkout: wipe it and add the worktree again. A working worktree, or a
|
|
1262
|
+
// full clone from an earlier release, is adopted as it is.
|
|
1263
|
+
let shape;
|
|
1195
1264
|
try {
|
|
1196
|
-
|
|
1197
|
-
if (dotGit.isDirectory() || dotGit.isFile()) {
|
|
1198
|
-
alreadyCloned = true;
|
|
1199
|
-
}
|
|
1265
|
+
shape = await workingCopyShape(targetDir);
|
|
1200
1266
|
} catch (err) {
|
|
1201
1267
|
// Only treat "does-not-exist" as "half-built dir, wipe and
|
|
1202
1268
|
// re-clone". A transient EACCES / EIO / EBUSY must NOT delete
|
|
@@ -1205,8 +1271,9 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1205
1271
|
// (`targetDir` is known to be a directory here, so ENOTDIR can only
|
|
1206
1272
|
// mean `.git` itself vanished under us — an absence either way.)
|
|
1207
1273
|
if (!isAbsence(err)) throw err;
|
|
1274
|
+
shape = 'none' as const;
|
|
1208
1275
|
}
|
|
1209
|
-
if (
|
|
1276
|
+
if (shape === 'worktree' || shape === 'clone') {
|
|
1210
1277
|
// We don't auto-pull because that could clobber another user's
|
|
1211
1278
|
// in-progress lock-held edits. The CONFIG is re-stamped though — it
|
|
1212
1279
|
// never touches the working tree, and a clone with drifted tracking
|
|
@@ -1214,31 +1281,25 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1214
1281
|
await this.normalizeCloneConfig(targetDir, branch);
|
|
1215
1282
|
return;
|
|
1216
1283
|
}
|
|
1217
|
-
|
|
1284
|
+
if (shape === 'broken') {
|
|
1285
|
+
log.warn(`the "${branch}" worktree had lost its link to the repository; it is added again.`);
|
|
1286
|
+
}
|
|
1287
|
+
// Half-built dir — wipe and add the worktree again.
|
|
1218
1288
|
await fs.rm(targetDir, { recursive: true, force: true });
|
|
1219
1289
|
}
|
|
1220
1290
|
|
|
1221
|
-
// Borrow an existing sibling clone's objects to skip the bulk download.
|
|
1222
|
-
// Resolved before the clone so a failure mid-clone still rolls back cleanly.
|
|
1223
|
-
const reference = await this.findSiblingRepo(branch);
|
|
1224
1291
|
try {
|
|
1225
|
-
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
// this
|
|
1236
|
-
// explicitly means the shape is asserted rather than assumed, and the
|
|
1237
|
-
// same call is what repairs an existing clone that drifted.
|
|
1238
|
-
await this.normalizeCloneConfig(targetDir, branch);
|
|
1239
|
-
log.info(`Cloned ${this.kbDirName} for branch "${branch}"` + (reference ? ' (referenced a sibling clone)' : ''));
|
|
1240
|
-
// A fresh clone has already downloaded every ref — tell the git layer
|
|
1241
|
-
// so the first `listBranches` skips the redundant implicit `git fetch`.
|
|
1292
|
+
// A worktree of the one repository: no history is copied. The
|
|
1293
|
+
// repository is created and fetched first if this is the first branch
|
|
1294
|
+
// opened on the deployment, and the identity, the long-paths setting,
|
|
1295
|
+
// the credential helper and the branch's tracking config are the
|
|
1296
|
+
// repository's (see `KbRepository`). The add asks the remote for the
|
|
1297
|
+
// branch, so the worktree starts at its tip as a fresh clone would.
|
|
1298
|
+
await this.repository.addWorktree(branch, targetDir);
|
|
1299
|
+
log.info(`Added the ${this.kbDirName} worktree for branch "${branch}"`);
|
|
1300
|
+
// A working copy was just made — tell the git layer, so a fetch record
|
|
1301
|
+
// it kept for this workspace id (a clone it replaced) is not read as
|
|
1302
|
+
// this one's, and the first `listBranches` skips a redundant fetch.
|
|
1242
1303
|
// Isolated from the clone-rollback `catch` below: a misbehaving listener
|
|
1243
1304
|
// must not cause a successful clone to be wiped.
|
|
1244
1305
|
try {
|
|
@@ -1252,6 +1313,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1252
1313
|
const redacted = redactError(err, this.gitRunner.credentials.token());
|
|
1253
1314
|
// Roll back partial state so the next bootstrap retries cleanly.
|
|
1254
1315
|
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
|
1316
|
+
await this.repository.prune().catch(() => {});
|
|
1255
1317
|
// A branch origin does not have is a fact about the branch, not a
|
|
1256
1318
|
// failure of ours — but WHICH fact depends on whether the platform ever
|
|
1257
1319
|
// knew the name. A branch it cloned or listed and origin no longer has
|
|
@@ -1299,8 +1361,13 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1299
1361
|
*/
|
|
1300
1362
|
async deleteWorkspace(workspaceId: string): Promise<void> {
|
|
1301
1363
|
const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
|
|
1302
|
-
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
1303
1364
|
const branch = branchForWorkspaceId(workspaceId);
|
|
1365
|
+
const repoDir = path.join(workspaceDir, this.kbDirName);
|
|
1366
|
+
// A worktree is removed through git and pruned from the repository, with
|
|
1367
|
+
// its branch's local ref; the repository's objects and every other
|
|
1368
|
+
// worktree are untouched.
|
|
1369
|
+
if (await this.isWorktree(repoDir)) await this.repository.removeWorktree(repoDir, branch);
|
|
1370
|
+
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
1304
1371
|
this.branchDirs.delete(branch);
|
|
1305
1372
|
}
|
|
1306
1373
|
|
|
@@ -1657,6 +1724,15 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1657
1724
|
): Promise<void> {
|
|
1658
1725
|
const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
|
|
1659
1726
|
const repoDir = path.join(workspaceDir, this.kbDirName);
|
|
1727
|
+
if (await this.isWorktree(repoDir)) {
|
|
1728
|
+
// ONE fetch for every workspace: the repository's, throttled and joined
|
|
1729
|
+
// there.
|
|
1730
|
+
const ok = await this.repository.fetch({ maxAgeMs: FETCH_CACHE_TTL_MS, force: opts.force });
|
|
1731
|
+
if (opts.strict && !ok) throw new Error('git fetch origin failed — remote refs could not be refreshed');
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
// A full clone from an earlier release fetches on its own until the boot
|
|
1735
|
+
// phase converts it.
|
|
1660
1736
|
const now = Date.now();
|
|
1661
1737
|
// The TTL is stamped only by a SUCCESSFUL fetch, so a fresh window is
|
|
1662
1738
|
// itself the proof a strict caller wants.
|
|
@@ -2356,12 +2432,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
2356
2432
|
} catch {
|
|
2357
2433
|
return false;
|
|
2358
2434
|
}
|
|
2359
|
-
|
|
2360
|
-
await fs.access(path.join(workspaceDir, this.kbDirName, '.git'));
|
|
2361
|
-
return true;
|
|
2362
|
-
} catch {
|
|
2363
|
-
return false;
|
|
2364
|
-
}
|
|
2435
|
+
return this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false);
|
|
2365
2436
|
}
|
|
2366
2437
|
|
|
2367
2438
|
/**
|
|
@@ -2489,6 +2560,14 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
2489
2560
|
log.warn(`sweep failed for ${entry.name}:`, { err });
|
|
2490
2561
|
}
|
|
2491
2562
|
}
|
|
2563
|
+
// The repository is not under the root, so the sweep never touched it.
|
|
2564
|
+
// What it forgets is the worktrees whose folders went, and their
|
|
2565
|
+
// branches' local refs, as removing a clone took its branch with it.
|
|
2566
|
+
if (removed.length > 0) {
|
|
2567
|
+
await this.repository
|
|
2568
|
+
.forgetWorktrees(removed.map((id) => branchForWorkspaceId(id)))
|
|
2569
|
+
.catch((err: unknown) => log.warn('could not prune the swept worktrees from the repository:', { err }));
|
|
2570
|
+
}
|
|
2492
2571
|
return { removed };
|
|
2493
2572
|
}
|
|
2494
2573
|
|
|
@@ -2550,12 +2629,11 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
2550
2629
|
return this.branchDirs.get(branch)!;
|
|
2551
2630
|
}
|
|
2552
2631
|
const workspaceDir = this.workspaceDirWithinRoot(workspaceId);
|
|
2553
|
-
|
|
2554
|
-
|
|
2632
|
+
// A worktree whose link to the repository is broken falls through too:
|
|
2633
|
+
// the bootstrap recreates it.
|
|
2634
|
+
if (await this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false)) {
|
|
2555
2635
|
this.registerBranchDir(branch, workspaceDir);
|
|
2556
2636
|
return workspaceDir;
|
|
2557
|
-
} catch {
|
|
2558
|
-
// Fall through to lazy bootstrap.
|
|
2559
2637
|
}
|
|
2560
2638
|
try {
|
|
2561
2639
|
await this.getOrCreateForBranch(branch);
|