diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts new file mode 100644 index 00000000000..f0fad64322f --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts @@ -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() + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 2f9e5e92c09..eaa3f245dd9 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -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 diff --git a/src/main/ipc/registered-worktree-root-owner.ts b/src/main/ipc/registered-worktree-root-owner.ts index 37b1ed25e38..d1b45caf2f6 100644 --- a/src/main/ipc/registered-worktree-root-owner.ts +++ b/src/main/ipc/registered-worktree-root-owner.ts @@ -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 | null + /** The WSL distro whose Git produced `listed`; undefined is host Git. */ + listedWslDistro: string | undefined + recovered: Set + aliases: Set + 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)]) } diff --git a/src/main/ipc/registered-worktree-root-probes.ts b/src/main/ipc/registered-worktree-root-probes.ts index ca295f02dd1..a1ba05441b9 100644 --- a/src/main/ipc/registered-worktree-root-probes.ts +++ b/src/main/ipc/registered-worktree-root-probes.ts @@ -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; listingFailed: boolean } +/** `wslDistro` names the Git that listed the roots; undefined is host Git. */ +type ListedRoots = { roots: Set; 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 { + 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 } } } ) diff --git a/src/main/ipc/registered-worktree-root-relist-policy.ts b/src/main/ipc/registered-worktree-root-relist-policy.ts index 7707bcce983..b7348a2f5f1 100644 --- a/src/main/ipc/registered-worktree-root-relist-policy.ts +++ b/src/main/ipc/registered-worktree-root-relist-policy.ts @@ -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, + owners: ReadonlyMap +): 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] + : [] + }) +} diff --git a/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts new file mode 100644 index 00000000000..6d516a3574a --- /dev/null +++ b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index ba538356b9a..ecdabd46abb 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -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 | null - recovered: Set - aliases: Set - revision: number - dirty: boolean -} - const registeredWorktreeRoots = new Set() const registeredOwners = new Map() const registeredWorktreeRootsRevisionByRepo = new Map() @@ -45,6 +41,16 @@ function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner return key === undefined ? undefined : registeredOwners.get(key) } +function relistOwners(owners: Iterable): 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 { 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 diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 11b897b81db..36e25a018b6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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. diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index dad44c64a69..4df3d379d4d 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -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 + ) } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts new file mode 100644 index 00000000000..952ae1c68d2 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts @@ -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 { + 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() + }) +})