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:
Brennan Benson
2026-10-02 12:36:13 -07:00
committed by GitHub
parent 1e600a8f65
commit 0f9154015e
10 changed files with 486 additions and 50 deletions
@@ -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)
})
})
+39 -34
View File
@@ -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
+6 -4
View File
@@ -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()
})
})