mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
fix(filesystem): deleting a workspace no longer fails as 'outside allowed directories' in WSL-runtime C:\ projects (#24242)
* fix(filesystem): list a WSL-runtime repo's worktrees through its distro when authorizing paths * fix(filesystem): record the Git that listed each repo's roots and keep WSL roots under repair - Registration now takes the distro the caller listed through (create and catalog scan pass theirs) instead of re-resolving the runtime, so a stale-routed listing is relisted on the next miss. - A runtime awaiting repair no longer counts as a routing change, so its last WSL listing stays authorized instead of being replaced by a host-Git listing. - The routing check runs after each awaited refresh, so a runtime switch during an in-flight rebuild is corrected in the same request. - Reuse the shared distro helper for listing; move the drift check into the relist policy and refresh the root set once per batch. * test(worktrees): fail the host-Git scan registration test when nothing registers
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../../persistence'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ graph: vi.fn(), gitOptions: vi.fn() }))
|
||||
vi.mock('../../repo-worktrees', () => ({ listRepoWorktreeGraph: mocks.graph }))
|
||||
vi.mock('../../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: mocks.gitOptions
|
||||
}))
|
||||
vi.mock('../registered-worktree-roots-cache', () => ({
|
||||
resolveRegisteredWorktreePath: async (path: string) => path
|
||||
}))
|
||||
vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() }))
|
||||
vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() }))
|
||||
import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'wsl-drive-repo',
|
||||
path: resolve('/wsl-drive/repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const worktree = resolve('/wsl-drive/workspaces/feature-extra')
|
||||
|
||||
function fixture(): Store {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership check reads only these store methods.
|
||||
return { getRepo: () => repo, getAllWorktreeMeta: () => ({}) } as unknown as Store
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' })
|
||||
// Only WSL Git names the checkout the way the renderer does; host Git reads its `/mnt/c` metadata.
|
||||
mocks.graph.mockImplementation(async (_repo: Repo, options?: { wslDistro?: string }) => [
|
||||
{ path: options?.wslDistro ? worktree : resolve('/mnt/c/wsl-drive/workspaces/feature-extra') }
|
||||
])
|
||||
})
|
||||
|
||||
describe('source control AI repo ownership for a detected worktree', () => {
|
||||
it('lists through the project runtime, so a WSL-runtime drive worktree keeps its repo', async () => {
|
||||
await expect(
|
||||
getRepoForSourceControlAi(fixture(), { repoId: repo.id, worktreePath: worktree })
|
||||
).resolves.toBe(repo)
|
||||
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: 'Ubuntu-24.04' })
|
||||
})
|
||||
|
||||
it('does not claim a worktree the repo listing does not name', async () => {
|
||||
await expect(
|
||||
getRepoForSourceControlAi(fixture(), {
|
||||
repoId: repo.id,
|
||||
worktreePath: resolve('/wsl-drive/elsewhere')
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Store } from '../../persistence'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import {
|
||||
getLocalProjectWorktreeGitOptions,
|
||||
type LocalProjectWorktreeGitOptions
|
||||
} from '../../project-runtime-git-options'
|
||||
import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment'
|
||||
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -77,7 +80,11 @@ async function localRepoOwnsWorktree(
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const worktrees = await listRepoWorktreeGraph(repo)
|
||||
// Why the project runtime: WSL Git names a drive worktree differently than host Git does.
|
||||
const worktrees = await listRepoWorktreeGraph(
|
||||
repo,
|
||||
getLocalProjectWorktreeGitOptions(store, repo)
|
||||
)
|
||||
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -3,6 +3,29 @@ import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { hasRemoteFilesystemOwner } from './remote-filesystem-owner'
|
||||
|
||||
export type RegisteredOwner = {
|
||||
repoId: string
|
||||
listed: Set<string> | null
|
||||
/** The WSL distro whose Git produced `listed`; undefined is host Git. */
|
||||
listedWslDistro: string | undefined
|
||||
recovered: Set<string>
|
||||
aliases: Set<string>
|
||||
revision: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export function createRegisteredOwner(repoId: string, revision: number): RegisteredOwner {
|
||||
return {
|
||||
repoId,
|
||||
listed: null,
|
||||
listedWslDistro: undefined,
|
||||
recovered: new Set(),
|
||||
aliases: new Set(),
|
||||
revision,
|
||||
dirty: true
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeRootOwnerKey(repo: Repo): string {
|
||||
return JSON.stringify([repo.id, resolve(repo.path), getRepoExecutionHostId(repo)])
|
||||
}
|
||||
|
||||
@@ -3,16 +3,27 @@ import { resolve } from 'node:path'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getErrorCode } from '../git/worktree-operation-options'
|
||||
import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution'
|
||||
import type { Store } from '../persistence'
|
||||
import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options'
|
||||
import { listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
|
||||
const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000
|
||||
const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
|
||||
|
||||
type ListedRoots = { roots: Set<string>; listingFailed: boolean }
|
||||
/** `wslDistro` names the Git that listed the roots; undefined is host Git. */
|
||||
type ListedRoots = { roots: Set<string>; listingFailed: boolean; wslDistro: string | undefined }
|
||||
|
||||
/**
|
||||
* Why the project runtime's distro: the catalog and removal list through it, and WSL Git records a
|
||||
* `C:\` worktree as `/mnt/c/...`, which host Git reading that metadata names as another path. A
|
||||
* runtime awaiting repair has no distro, so it keeps the host Git this listing always used.
|
||||
*/
|
||||
export async function listWorktreeRootsWithConcurrency(
|
||||
store: Store,
|
||||
repos: readonly Repo[]
|
||||
): Promise<ListedRoots[]> {
|
||||
const runtimes = resolveLocalProjectRuntimesForRepos(store, repos)
|
||||
const results: ListedRoots[] = []
|
||||
let nextIndex = 0
|
||||
await Promise.all(
|
||||
@@ -22,10 +33,14 @@ export async function listWorktreeRootsWithConcurrency(
|
||||
while (nextIndex < repos.length) {
|
||||
const index = nextIndex++
|
||||
const repo = repos[index]
|
||||
const wslDistro = getWorktreeMirrorDistroForRuntime(runtimes.get(repo.id))
|
||||
const roots = new Set([resolve(repo.path)])
|
||||
let listingFailed = false
|
||||
try {
|
||||
for (const worktree of await listRepoWorktreeGraph(repo)) {
|
||||
for (const worktree of await listRepoWorktreeGraph(
|
||||
repo,
|
||||
wslDistro ? { wslDistro } : {}
|
||||
)) {
|
||||
roots.add(resolve(worktree.path))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,7 +50,7 @@ export async function listWorktreeRootsWithConcurrency(
|
||||
)
|
||||
listingFailed = true
|
||||
}
|
||||
results[index] = { roots, listingFailed }
|
||||
results[index] = { roots, listingFailed, wslDistro }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
* Split out of registered-worktree-roots-cache so the policy is testable on its own
|
||||
* and the cache module stays within its line budget.
|
||||
*/
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution'
|
||||
import type { Store } from '../persistence'
|
||||
import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options'
|
||||
import type { RegisteredOwner } from './registered-worktree-root-owner'
|
||||
|
||||
/** The owner fields the policy reads; `undefined` means no owner record yet. */
|
||||
export type RelistCandidate =
|
||||
| { dirty: boolean; listed: unknown; recovered: { size: number } }
|
||||
@@ -24,3 +30,26 @@ export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): b
|
||||
}
|
||||
return owner.dirty || owner.listed == null || owner.recovered.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Owners whose roots came from a Git other than the one their project runtime now selects.
|
||||
*
|
||||
* A runtime awaiting repair is skipped: it names no Git, and re-listing through host Git would
|
||||
* revoke worktrees only the last real listing can name. Its owners keep that listing.
|
||||
*/
|
||||
export function findOwnersListedThroughAnotherGit(
|
||||
store: Store,
|
||||
repos: ReadonlyMap<string, Repo>,
|
||||
owners: ReadonlyMap<string, RegisteredOwner>
|
||||
): RegisteredOwner[] {
|
||||
const runtimes = resolveLocalProjectRuntimesForRepos(store, [...repos.values()])
|
||||
return [...repos].flatMap(([key, repo]) => {
|
||||
const owner = owners.get(key)
|
||||
const runtime = runtimes.get(repo.id)
|
||||
return owner?.listed &&
|
||||
runtime?.status !== 'repair-required' &&
|
||||
owner.listedWslDistro !== getWorktreeMirrorDistroForRuntime(runtime)
|
||||
? [owner]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { join, resolve } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ graph: vi.fn(), runtimes: vi.fn(), realpath: vi.fn() }))
|
||||
vi.mock('node:fs', () => ({ realpathSync: (path: string) => path }))
|
||||
vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({})), realpath: mocks.realpath }))
|
||||
vi.mock('../repo-worktrees', () => ({
|
||||
listRepoWorktreeGraph: mocks.graph,
|
||||
isRepoRoot: vi.fn(() => false)
|
||||
}))
|
||||
vi.mock('../local-project-runtime-resolution', () => ({
|
||||
resolveLocalProjectRuntimesForRepos: mocks.runtimes
|
||||
}))
|
||||
import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth'
|
||||
import {
|
||||
__resetCreatedWorktreeRootsForTests,
|
||||
invalidateAuthorizedRootsCache,
|
||||
registerWorktreeRootsForRepo,
|
||||
resolveRegisteredWorktreePath
|
||||
} from './registered-worktree-roots-cache'
|
||||
|
||||
const DISTRO = 'Ubuntu-24.04'
|
||||
const repo: Repo = {
|
||||
id: 'wsl-drive-repo',
|
||||
path: resolve('/wsl-drive/repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
// The same checkout as each Git names it: WSL Git's `/mnt/c/...` translated to the drive spelling
|
||||
// every other consumer sends, and host Git reading that `/mnt/c` metadata as a different path.
|
||||
const worktree = resolve('/wsl-drive/workspaces/feature-extra')
|
||||
const hostGitSpelling = resolve('/mnt/c/wsl-drive/workspaces/feature-extra')
|
||||
const hostRepo: Repo = { ...repo, id: 'host-repo', path: resolve('/host-drive/repo') }
|
||||
const hostRepoWorktree = resolve('/host-drive/workspaces/feature')
|
||||
|
||||
const wslRuntime: ProjectExecutionRuntimeResolution = {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
distro: DISTRO,
|
||||
projectId: 'project',
|
||||
reason: 'project-override',
|
||||
cacheKey: `project:wsl:${DISTRO}`
|
||||
}
|
||||
}
|
||||
const hostRuntime: ProjectExecutionRuntimeResolution = {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'windows-host',
|
||||
hostPlatform: 'win32',
|
||||
projectId: 'project',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'project:windows-host'
|
||||
}
|
||||
}
|
||||
const repairRuntime: ProjectExecutionRuntimeResolution = {
|
||||
status: 'repair-required',
|
||||
repair: {
|
||||
projectId: 'project',
|
||||
preferredRuntime: { kind: 'wsl', distro: DISTRO },
|
||||
reason: 'wsl-distro-missing',
|
||||
source: 'project-override',
|
||||
cacheKey: `project:repair:wsl-distro-missing:${DISTRO}`
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(repos: Repo[] = [repo]): Store {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these store methods; listing and runtime resolution are mocked.
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getProjectGroups: () => [],
|
||||
getFolderWorkspaces: () => [],
|
||||
getSettings: () => ({})
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
function useRuntime(runtime: ProjectExecutionRuntimeResolution): void {
|
||||
mocks.runtimes.mockImplementation(
|
||||
(_store: Store, repos: readonly Repo[]) =>
|
||||
new Map(repos.map((entry) => [entry.id, entry.id === hostRepo.id ? hostRuntime : runtime]))
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
__resetCreatedWorktreeRootsForTests()
|
||||
vi.resetAllMocks()
|
||||
mocks.realpath.mockImplementation(async (path: string) => path)
|
||||
mocks.graph.mockImplementation(async (listed: Repo, options?: { wslDistro?: string }) => [
|
||||
{
|
||||
path:
|
||||
listed.id === hostRepo.id
|
||||
? hostRepoWorktree
|
||||
: options?.wslDistro === DISTRO
|
||||
? worktree
|
||||
: hostGitSpelling
|
||||
}
|
||||
])
|
||||
useRuntime(wslRuntime)
|
||||
})
|
||||
|
||||
describe('authorized worktree roots for a drive repo whose project runtime is WSL', () => {
|
||||
it('lists through the project distro, so the path delete and git status send is authorized', async () => {
|
||||
await expect(resolveAuthorizedPath(worktree, fixture())).resolves.toBe(worktree)
|
||||
await expect(resolveRegisteredWorktreePath(worktree, fixture())).resolves.toBe(worktree)
|
||||
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
|
||||
})
|
||||
|
||||
it('still refuses a path outside every root, including the spelling only host Git lists', async () => {
|
||||
const store = fixture()
|
||||
await expect(
|
||||
resolveAuthorizedPath(resolve('/wsl-drive/elsewhere/secret'), store)
|
||||
).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
|
||||
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow(
|
||||
PATH_ACCESS_DENIED_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('re-lists through the new Git when the project runtime changes after a listing', async () => {
|
||||
useRuntime(hostRuntime)
|
||||
const store = fixture()
|
||||
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).resolves.toBe(
|
||||
join(hostGitSpelling, 'file')
|
||||
)
|
||||
|
||||
useRuntime(wslRuntime)
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
expect(mocks.graph).toHaveBeenLastCalledWith(repo, { wslDistro: DISTRO })
|
||||
// The host-Git listing is retired with its owner, not kept beside the new one.
|
||||
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow(
|
||||
PATH_ACCESS_DENIED_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a registration WSL Git produced without re-listing', async () => {
|
||||
const store = fixture()
|
||||
registerWorktreeRootsForRepo(store, repo.id, [repo.path, worktree], { wslDistro: DISTRO })
|
||||
await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow(
|
||||
PATH_ACCESS_DENIED_MESSAGE
|
||||
)
|
||||
expect(mocks.graph).not.toHaveBeenCalled()
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
})
|
||||
|
||||
it('re-lists a registration host Git produced for a WSL project on the next miss', async () => {
|
||||
const store = fixture()
|
||||
registerWorktreeRootsForRepo(store, repo.id, [repo.path, hostGitSpelling])
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
|
||||
})
|
||||
|
||||
it('lists with host Git while the runtime awaits repair, as before routing', async () => {
|
||||
useRuntime(repairRuntime)
|
||||
await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), fixture())).resolves.toBe(
|
||||
join(hostGitSpelling, 'file')
|
||||
)
|
||||
expect(mocks.graph).toHaveBeenCalledWith(repo, {})
|
||||
})
|
||||
|
||||
it('keeps the WSL listing when a resolved runtime starts awaiting repair', async () => {
|
||||
const store = fixture()
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
|
||||
useRuntime(repairRuntime)
|
||||
await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow(
|
||||
PATH_ACCESS_DENIED_MESSAGE
|
||||
)
|
||||
expect(mocks.graph).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
})
|
||||
|
||||
it('re-lists in the same request when the runtime changes during an in-flight rebuild', async () => {
|
||||
useRuntime(hostRuntime)
|
||||
const store = fixture()
|
||||
let releaseHostListing = (): void => {}
|
||||
mocks.graph.mockImplementationOnce(
|
||||
(_repo: Repo) =>
|
||||
new Promise((resolveListing) => {
|
||||
releaseHostListing = () => resolveListing([{ path: hostGitSpelling }])
|
||||
})
|
||||
)
|
||||
const earlier = resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)
|
||||
await vi.waitFor(() => expect(mocks.graph).toHaveBeenCalledTimes(1))
|
||||
|
||||
useRuntime(wslRuntime)
|
||||
const afterSwitch = resolveAuthorizedPath(join(worktree, 'file'), store)
|
||||
releaseHostListing()
|
||||
await expect(earlier).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
|
||||
await expect(afterSwitch).resolves.toBe(join(worktree, 'file'))
|
||||
})
|
||||
|
||||
it('keeps a host-runtime repo in the same cache on host Git', async () => {
|
||||
const store = fixture([repo, hostRepo])
|
||||
await expect(resolveAuthorizedPath(join(hostRepoWorktree, 'file'), store)).resolves.toBe(
|
||||
join(hostRepoWorktree, 'file')
|
||||
)
|
||||
await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe(
|
||||
join(worktree, 'file')
|
||||
)
|
||||
expect(mocks.graph).toHaveBeenCalledWith(hostRepo, {})
|
||||
expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO })
|
||||
expect(mocks.graph).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -6,21 +6,17 @@ import {
|
||||
pruneCreatedWorktreeRoots
|
||||
} from './registered-worktree-root-probes'
|
||||
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
|
||||
import { shouldRelistOwner } from './registered-worktree-root-relist-policy'
|
||||
import {
|
||||
findOwnersListedThroughAnotherGit,
|
||||
shouldRelistOwner
|
||||
} from './registered-worktree-root-relist-policy'
|
||||
import {
|
||||
createRegisteredOwner,
|
||||
getLocalWorktreeRootOwners,
|
||||
resolveWorktreeRootOwner
|
||||
resolveWorktreeRootOwner,
|
||||
type RegisteredOwner
|
||||
} from './registered-worktree-root-owner'
|
||||
|
||||
type RegisteredOwner = {
|
||||
repoId: string
|
||||
listed: Set<string> | null
|
||||
recovered: Set<string>
|
||||
aliases: Set<string>
|
||||
revision: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
const registeredWorktreeRoots = new Set<string>()
|
||||
const registeredOwners = new Map<string, RegisteredOwner>()
|
||||
const registeredWorktreeRootsRevisionByRepo = new Map<string, number>()
|
||||
@@ -45,6 +41,16 @@ function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner
|
||||
return key === undefined ? undefined : registeredOwners.get(key)
|
||||
}
|
||||
|
||||
function relistOwners(owners: Iterable<RegisteredOwner>): void {
|
||||
for (const owner of owners) {
|
||||
owner.listed = null
|
||||
owner.dirty = true
|
||||
advanceOwner(owner)
|
||||
}
|
||||
refreshRegisteredWorktreeRoots()
|
||||
registeredWorktreeRootsDirty = true
|
||||
}
|
||||
|
||||
function synchronizeOwners(store: Store): Repo[] {
|
||||
const repos = store.getRepos()
|
||||
const owners = getLocalWorktreeRootOwners(repos)
|
||||
@@ -63,14 +69,7 @@ function synchronizeOwners(store: Store): Repo[] {
|
||||
}
|
||||
for (const [key, repo] of owners) {
|
||||
if (!registeredOwners.has(key)) {
|
||||
registeredOwners.set(key, {
|
||||
repoId: repo.id,
|
||||
listed: null,
|
||||
recovered: new Set(),
|
||||
aliases: new Set(),
|
||||
revision: ++revisionSequence,
|
||||
dirty: true
|
||||
})
|
||||
registeredOwners.set(key, createRegisteredOwner(repo.id, ++revisionSequence))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
@@ -84,13 +83,7 @@ function synchronizeOwners(store: Store): Repo[] {
|
||||
|
||||
export function invalidateAuthorizedRootsCache(): void {
|
||||
invalidationGeneration++
|
||||
for (const owner of registeredOwners.values()) {
|
||||
owner.listed = null
|
||||
owner.dirty = true
|
||||
advanceOwner(owner)
|
||||
}
|
||||
refreshRegisteredWorktreeRoots()
|
||||
registeredWorktreeRootsDirty = true
|
||||
relistOwners(registeredOwners.values())
|
||||
baseRevision = ++revisionSequence
|
||||
registeredWorktreeRootsRevisionByRepo.clear()
|
||||
}
|
||||
@@ -111,7 +104,10 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals
|
||||
// every registered repo. An owner with no listing yet (`listed === null`) is
|
||||
// always included, so a first rebuild is unchanged.
|
||||
.filter((entry) => shouldRelistOwner(entry.owner, onlyDirty))
|
||||
const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo))
|
||||
const listings = await listWorktreeRootsWithConcurrency(
|
||||
store,
|
||||
pending.map((entry) => entry.repo)
|
||||
)
|
||||
const results = pending.map((entry, index) => ({ ...entry, ...listings[index] }))
|
||||
const isCurrent = (entry: (typeof pending)[number]): boolean =>
|
||||
generation === invalidationGeneration &&
|
||||
@@ -134,6 +130,7 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals
|
||||
continue
|
||||
}
|
||||
entry.owner.listed = entry.roots
|
||||
entry.owner.listedWslDistro = entry.wslDistro
|
||||
entry.owner.dirty = false
|
||||
entry.owner.recovered = entry.recovered
|
||||
advanceOwner(entry.owner)
|
||||
@@ -155,24 +152,23 @@ export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string)
|
||||
if (!owner) {
|
||||
return false
|
||||
}
|
||||
owner.listed = null
|
||||
owner.dirty = true
|
||||
advanceOwner(owner)
|
||||
refreshRegisteredWorktreeRoots()
|
||||
registeredWorktreeRootsDirty = true
|
||||
relistOwners([owner])
|
||||
return true
|
||||
}
|
||||
|
||||
/** `listing.wslDistro` is the distro whose Git produced `worktreeRoots`; omitted is host Git. */
|
||||
export function registerWorktreeRootsForRepo(
|
||||
store: Store,
|
||||
repo: Repo | string,
|
||||
worktreeRoots: string[]
|
||||
worktreeRoots: string[],
|
||||
listing: { wslDistro?: string } = {}
|
||||
): void {
|
||||
const owner = resolveOwnerForRepo(store, repo)
|
||||
if (!owner) {
|
||||
return
|
||||
}
|
||||
owner.listed = new Set(worktreeRoots.map((root) => resolve(root)))
|
||||
owner.listedWslDistro = listing.wslDistro
|
||||
owner.dirty = false
|
||||
advanceOwner(owner)
|
||||
refreshRegisteredWorktreeRoots()
|
||||
@@ -218,7 +214,16 @@ export function getRegisteredWorktreeRootsRevision(repoId: string): number {
|
||||
export async function ensureAuthorizedRootsCache(store: Store): Promise<void> {
|
||||
synchronizeOwners(store)
|
||||
// Follow one superseded refresh; continuous catalog churn must not pin authorization forever.
|
||||
for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
// Why re-derived each attempt: nothing invalidates this cache when a project's runtime changes,
|
||||
// and a refresh that began before the change commits a listing from the old Git.
|
||||
const drifted = findOwnersListedThroughAnotherGit(store, currentOwners, registeredOwners)
|
||||
if (drifted.length > 0) {
|
||||
relistOwners(drifted)
|
||||
}
|
||||
if (!registeredWorktreeRootsDirty) {
|
||||
break
|
||||
}
|
||||
if (!registeredWorktreeRootsRefresh) {
|
||||
registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => {
|
||||
registeredWorktreeRootsRefresh = null
|
||||
|
||||
@@ -2828,10 +2828,12 @@ async function performLocalWorktreeCreate(
|
||||
// Why gated: registration replaces the repo's root set, so registering a create recovered without
|
||||
// a listing would revoke filesystem access to every worktree that listing would have named.
|
||||
if (listingComplete) {
|
||||
registerWorktreeRootsForRepo(store, repo, [
|
||||
repo.path,
|
||||
...gitWorktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
registerWorktreeRootsForRepo(
|
||||
store,
|
||||
repo,
|
||||
[repo.path, ...gitWorktrees.map((worktree) => worktree.path)],
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
} else {
|
||||
// Recovered without a listing: authorize just the new root, or the create the user just made
|
||||
// is rejected by filesystem/git-status IPC until a full scan repopulates the cache.
|
||||
|
||||
@@ -47,6 +47,8 @@ export type DetectedWorktreeScan = {
|
||||
export type DetectedWorktreeSideEffectToken = Readonly<{
|
||||
generation: number
|
||||
authorizedRootsRevision: number
|
||||
/** The distro whose Git listed the scan; undefined is host Git. */
|
||||
wslDistro?: string
|
||||
}>
|
||||
|
||||
export type DetectedWorktreeMetadataPrune = Readonly<{
|
||||
@@ -173,7 +175,7 @@ export async function listDetectedGitWorktrees(
|
||||
const scan: DetectedWorktreeScan = {
|
||||
invalidated: false,
|
||||
promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
|
||||
sideEffectToken: { generation, authorizedRootsRevision },
|
||||
sideEffectToken: { generation, authorizedRootsRevision, ...localWorktreeGitOptions },
|
||||
hygieneDue,
|
||||
...(metadataPruneExpectation
|
||||
? {
|
||||
@@ -269,7 +271,7 @@ export async function applyFreshDetectedWorktreeScanSideEffects(
|
||||
) {
|
||||
return false
|
||||
}
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees, sideEffectToken)
|
||||
// Why: lineage retention is decided against the metadata rows the prune preserved, so running it
|
||||
// without that pass would drop lineage for rows the pass would have kept. Both halves share the
|
||||
// hygiene cadence instead.
|
||||
@@ -294,14 +296,17 @@ export function getDetectedWorktreeScanCacheKey(
|
||||
export function rememberLocalWorktreeRoots(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
gitWorktrees: GitWorktreeInfo[],
|
||||
listing: { wslDistro?: string } = {}
|
||||
): void {
|
||||
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
|
||||
return
|
||||
}
|
||||
// Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts.
|
||||
registerWorktreeRootsForRepo(store, repo, [
|
||||
repo.path,
|
||||
...gitWorktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
registerWorktreeRootsForRepo(
|
||||
store,
|
||||
repo,
|
||||
[repo.path, ...gitWorktrees.map((worktree) => worktree.path)],
|
||||
listing
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
gitOptions: vi.fn(),
|
||||
registerRoots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../repo-worktrees', () => ({ listRepoWorktreesForDetectedScan: mocks.list }))
|
||||
vi.mock('../../../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: mocks.gitOptions
|
||||
}))
|
||||
vi.mock('../../registered-worktree-roots-cache', () => ({
|
||||
getRegisteredWorktreeRootsRevision: () => 1,
|
||||
registerWorktreeRootsForRepo: mocks.registerRoots
|
||||
}))
|
||||
vi.mock('../../../worktree-lineage-pruning', () => ({
|
||||
pruneLineageForMissingRepoWorktrees: vi.fn()
|
||||
}))
|
||||
|
||||
const {
|
||||
__resetDetectedWorktreeScanCacheForTests,
|
||||
applyFreshDetectedWorktreeScanSideEffects,
|
||||
listDetectedGitWorktrees
|
||||
} = await import('./detected-worktree-scan-cache')
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repos/one',
|
||||
displayName: 'one',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const worktree = { path: '/repos/one-feature', head: 'abc', branch: 'feature', isBare: false }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a first local scan reads only this store method; routing, listing and registration are mocked.
|
||||
const store = { captureNativeLocalWorktreeMetadataScanExpectation: vi.fn() } as unknown as Store
|
||||
|
||||
async function scanAndRegister(): Promise<void> {
|
||||
const scan = await listDetectedGitWorktrees(store, repo)
|
||||
await applyFreshDetectedWorktreeScanSideEffects(store, repo, scan.gitWorktrees, undefined, {
|
||||
sideEffectToken: scan.sideEffectToken,
|
||||
hygieneDue: false
|
||||
})
|
||||
}
|
||||
|
||||
describe('detected worktree scan root registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.list.mockResolvedValue([{ ...worktree, isMainWorktree: false }])
|
||||
__resetDetectedWorktreeScanCacheForTests()
|
||||
})
|
||||
|
||||
it('records the distro whose Git listed the scan', async () => {
|
||||
mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' })
|
||||
await scanAndRegister()
|
||||
|
||||
expect(mocks.registerRoots).toHaveBeenCalledWith(
|
||||
store,
|
||||
repo,
|
||||
[repo.path, worktree.path],
|
||||
expect.objectContaining({ wslDistro: 'Ubuntu-24.04' })
|
||||
)
|
||||
})
|
||||
|
||||
it('records host Git for a scan listed without a distro', async () => {
|
||||
mocks.gitOptions.mockReturnValue({})
|
||||
await scanAndRegister()
|
||||
|
||||
expect(mocks.registerRoots).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.registerRoots.mock.calls[0]?.[3]?.wslDistro).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user