diff --git a/src/main/git/nested-repo-warning.test.ts b/src/main/git/nested-repo-warning.test.ts new file mode 100644 index 0000000000..dde1149d2a --- /dev/null +++ b/src/main/git/nested-repo-warning.test.ts @@ -0,0 +1,331 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NestedRepoScanResult } from '../../shared/types' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +const { parseWslPathMock, toWindowsWslPathMock } = vi.hoisted(() => ({ + parseWslPathMock: vi.fn(() => null as { distro: string } | null), + toWindowsWslPathMock: vi.fn( + (linuxPath: string, distro: string) => `\\\\wsl$\\${distro}${linuxPath}` + ) +})) + +vi.mock('../wsl', () => ({ + parseWslPath: parseWslPathMock, + toWindowsWslPath: toWindowsWslPathMock +})) + +import { detectUntrackedNestedRepos, NESTED_REPO_DISPLAY_CAP } from './nested-repo-warning' +import { + createLocalNestedRepoScanFilesystem, + scanNestedRepos +} from '../project-groups/nested-repo-discovery' + +const TOPLEVEL = '/repo' + +function scanResult( + absolutePaths: string[], + extra?: Partial +): NestedRepoScanResult { + return { + selectedPath: TOPLEVEL, + selectedPathKind: 'non_git_folder', + repos: absolutePaths.map((path, index) => ({ + path, + displayName: path.split('/').pop() ?? path, + depth: 1 + index * 0 + })), + truncated: false, + timedOut: false, + stopped: false, + durationMs: 1, + maxDepth: 3, + maxRepos: 100, + timeoutMs: 5000, + ...extra + } +} + +function lsFilesZOutput(entries: { mode: string; path: string }[]): string { + return entries + .map((entry) => `${entry.mode} 0123456789abcdef0123456789abcdef01234567 0\t${entry.path}\0`) + .join('') +} + +/** rev-parse resolves the toplevel; ls-files / config get their own queued answers. */ +function queueGit(args: { + toplevel?: string | Error + lsFiles?: string | Error + gitmodules?: string | Error +}): void { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + if (args.toplevel instanceof Error) { + throw args.toplevel + } + return { stdout: `${args.toplevel ?? TOPLEVEL}\n`, stderr: '' } + } + if (argv[0] === 'ls-files') { + if (args.lsFiles instanceof Error) { + throw args.lsFiles + } + return { stdout: args.lsFiles ?? '', stderr: '' } + } + if (argv[0] === 'config') { + if (args.gitmodules instanceof Error) { + throw args.gitmodules + } + if (args.gitmodules === undefined) { + throw new Error('exit 1: no .gitmodules') + } + return { stdout: args.gitmodules, stderr: '' } + } + throw new Error(`unexpected git argv: ${argv.join(' ')}`) + }) +} + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() + parseWslPathMock.mockReset() + parseWslPathMock.mockReturnValue(null) + toWindowsWslPathMock.mockClear() +}) + +describe('detectUntrackedNestedRepos', () => { + it('returns sorted nested repos with trailing slashes when none are submodules', async () => { + queueGit({}) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/frontend`, `${TOPLEVEL}/backend`])) + + const result = await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + expect(result).toEqual({ paths: ['backend/', 'frontend/'], truncated: false, moreCount: 0 }) + }) + + it('excludes staged gitlinks and returns null when only gitlinks remain', async () => { + queueGit({ lsFiles: lsFilesZOutput([{ mode: '160000', path: 'sub' }]) }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/sub`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('keeps untracked nested repos while excluding gitlinks', async () => { + queueGit({ lsFiles: lsFilesZOutput([{ mode: '160000', path: 'sub' }]) }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/sub`, `${TOPLEVEL}/frontend`])) + + const result = await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + expect(result).toEqual({ paths: ['frontend/'], truncated: false, moreCount: 0 }) + }) + + it('passes canonical relative pathspecs to ls-files, never absolute paths', async () => { + queueGit({}) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/frontend`, `${TOPLEVEL}/backend`])) + + await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + const lsFilesCall = gitExecFileAsyncMock.mock.calls.find((call) => call[0][0] === 'ls-files') + expect(lsFilesCall?.[0]).toEqual(['ls-files', '-z', '--stage', '--', 'backend', 'frontend']) + }) + + it('issues exactly one ls-files and one gitmodules config call regardless of candidate count', async () => { + queueGit({ gitmodules: '' }) + const scan = vi.fn(async () => + scanResult( + Array.from({ length: 12 }, (_, i) => `${TOPLEVEL}/pkg-${String(i).padStart(2, '0')}`) + ) + ) + + await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + const argv0 = gitExecFileAsyncMock.mock.calls.map((call) => call[0][0]) + expect(argv0.filter((name) => name === 'ls-files')).toHaveLength(1) + expect(argv0.filter((name) => name === 'config')).toHaveLength(1) + expect(argv0.filter((name) => name === 'rev-parse')).toHaveLength(1) + }) + + it('excludes submodules declared only in .gitmodules', async () => { + queueGit({ gitmodules: 'submodule.sub2.path sub2\n' }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/sub2`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('normalizes .gitmodules path forms before comparing', async () => { + queueGit({ gitmodules: 'submodule.a.path sub2/\nsubmodule.b.path nested\\win\n' }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/sub2`, `${TOPLEVEL}/nested/win`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('keeps candidates when .gitmodules is absent (config exits non-zero)', async () => { + queueGit({ gitmodules: new Error('exit code 1') }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/frontend`])) + + const result = await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + expect(result).toEqual({ paths: ['frontend/'], truncated: false, moreCount: 0 }) + }) + + it('returns null when the scan finds nothing', async () => { + queueGit({}) + const scan = vi.fn(async () => scanResult([])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('caps the displayed paths and reports the real remainder', async () => { + queueGit({}) + const scan = vi.fn(async () => + scanResult( + Array.from({ length: 12 }, (_, i) => `${TOPLEVEL}/pkg-${String(i).padStart(2, '0')}`) + ) + ) + + const result = await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + expect(result?.paths).toHaveLength(NESTED_REPO_DISPLAY_CAP) + expect(result?.truncated).toBe(true) + expect(result?.moreCount).toBe(2) + }) + + it('reports a floor remainder when the scanner itself truncated', async () => { + queueGit({}) + const scan = vi.fn(async () => + scanResult( + Array.from({ length: 100 }, (_, i) => `${TOPLEVEL}/pkg-${String(i).padStart(3, '0')}`), + { truncated: true } + ) + ) + + const result = await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + expect(result?.paths).toHaveLength(NESTED_REPO_DISPLAY_CAP) + expect(result?.truncated).toBe(true) + expect(result?.moreCount).toBe(90) + }) + + it('returns null when rev-parse fails', async () => { + queueGit({ toplevel: new Error('not a git repo') }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/frontend`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('returns null when ls-files fails', async () => { + queueGit({ lsFiles: new Error('boom') }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/frontend`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('returns null when the scan rejects', async () => { + queueGit({}) + const scan = vi.fn(async () => { + throw new Error('scan exploded') + }) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('returns null when the scan timed out, even with partial results', async () => { + queueGit({}) + const scan = vi.fn(async () => + scanResult([`${TOPLEVEL}/frontend`, `${TOPLEVEL}/backend`], { timedOut: true }) + ) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('canonicalizes Windows-style separators for pathspecs and display', async () => { + const winToplevel = 'C:\\repo' + queueGit({ toplevel: winToplevel }) + const scan = vi.fn(async () => scanResult(['C:\\repo\\packages\\api'])) + + const result = await detectUntrackedNestedRepos(winToplevel, { scan }) + + const lsFilesCall = gitExecFileAsyncMock.mock.calls.find((call) => call[0][0] === 'ls-files') + expect(lsFilesCall?.[0]).toEqual(['ls-files', '-z', '--stage', '--', 'packages/api']) + expect(result).toEqual({ paths: ['packages/api/'], truncated: false, moreCount: 0 }) + }) + + it('translates WSL toplevels to UNC before scanning', async () => { + parseWslPathMock.mockReturnValue({ distro: 'Ubuntu' }) + queueGit({ toplevel: '/home/u/repo' }) + const scan = vi.fn(async (_args: Parameters[0]) => scanResult([])) + + await detectUntrackedNestedRepos('\\\\wsl$\\Ubuntu\\home\\u\\repo', { scan }) + + expect(toWindowsWslPathMock).toHaveBeenCalledWith('/home/u/repo', 'Ubuntu') + expect(scan.mock.calls[0]?.[0]?.path).toBe('\\\\wsl$\\Ubuntu/home/u/repo') + }) + + it('passes bounded options and a neutralized filesystem to the scanner', async () => { + queueGit({}) + const scan = vi.fn(async (_args: Parameters[0]) => scanResult([])) + + await detectUntrackedNestedRepos(TOPLEVEL, { scan }) + + const args = scan.mock.calls[0]?.[0] + expect(args?.options).toEqual({ maxDepth: 3, maxRepos: 100, timeoutMs: 5000 }) + expect(await args?.filesystem?.isSelectedPathGitRepo(TOPLEVEL)).toBe(false) + expect(await args?.filesystem?.readTextFile?.('/repo/.gitignore')).toBe('') + }) + + it('drops candidates resolving outside the toplevel', async () => { + queueGit({}) + const scan = vi.fn(async () => scanResult([TOPLEVEL, '/elsewhere/x'])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) + + it('parses -z records with spaces and tabs in paths', async () => { + queueGit({ + lsFiles: lsFilesZOutput([{ mode: '160000', path: 'odd dir\twith tab' }]) + }) + const scan = vi.fn(async () => scanResult([`${TOPLEVEL}/odd dir\twith tab`])) + + expect(await detectUntrackedNestedRepos(TOPLEVEL, { scan })).toBeNull() + }) +}) + +describe('detectUntrackedNestedRepos (real scanner integration)', () => { + let tempRoot: string + + beforeEach(async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'orca-nested-repo-warning-')) + }) + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }) + }) + + it('finds a gitignored nested repo because the override defeats gitignore pruning', async () => { + await writeFile(join(tempRoot, '.gitignore'), 'frontend/\n') + await mkdir(join(tempRoot, 'frontend', '.git'), { recursive: true }) + queueGit({ toplevel: tempRoot }) + + const result = await detectUntrackedNestedRepos(tempRoot) + + expect(result).toEqual({ paths: ['frontend/'], truncated: false, moreCount: 0 }) + }) + + it('local filesystem factory reads a real directory with a .git marker', async () => { + await mkdir(join(tempRoot, 'sub', '.git'), { recursive: true }) + + const scan = await scanNestedRepos({ + path: tempRoot, + filesystem: createLocalNestedRepoScanFilesystem() + }) + + expect(scan.repos.map((repo) => repo.path)).toEqual([join(tempRoot, 'sub')]) + }) +}) diff --git a/src/main/git/nested-repo-warning.ts b/src/main/git/nested-repo-warning.ts new file mode 100644 index 0000000000..f9c4c12c16 --- /dev/null +++ b/src/main/git/nested-repo-warning.ts @@ -0,0 +1,152 @@ +import type { NestedRepoWarning } from '../../shared/types' +import { + createLocalNestedRepoScanFilesystem, + scanNestedRepos +} from '../project-groups/nested-repo-discovery' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { gitExecFileAsync } from './runner' + +export const NESTED_REPO_DISPLAY_CAP = 10 + +export type NestedRepoDetectionDeps = { + /** Test seam: replaces the nested-repo scanner. */ + scan?: typeof scanNestedRepos +} + +// Why: pure string relativization instead of path.relative — candidates may carry +// either separator (Windows scans) and path.relative on POSIX does not treat '\' +// as a separator, which would break both the git pathspecs and the display. +function toSlashes(value: string): string { + return value.replace(/\\/g, '/') +} + +function relativizeToToplevel(toplevel: string, candidatePath: string): string | null { + const root = toSlashes(toplevel).replace(/\/+$/, '') + const candidate = toSlashes(candidatePath) + if (!candidate.startsWith(`${root}/`)) { + return null + } + const relative = candidate.slice(root.length + 1).replace(/\/+$/, '') + return relative.length > 0 ? relative : null +} + +function parseGitlinkPaths(lsFilesZOutput: string): Set { + const gitlinks = new Set() + for (const record of lsFilesZOutput.split('\0')) { + if (!record) { + continue + } + const tabIndex = record.indexOf('\t') + if (tabIndex === -1) { + continue + } + const [mode] = record.slice(0, tabIndex).split(' ') + if (mode === '160000') { + gitlinks.add(record.slice(tabIndex + 1)) + } + } + return gitlinks +} + +function parseGitmodulesPaths(configOutput: string): Set { + const declared = new Set() + for (const line of configOutput.split(/\r?\n/)) { + const spaceIndex = line.indexOf(' ') + if (spaceIndex === -1) { + continue + } + const value = toSlashes(line.slice(spaceIndex + 1).trim()).replace(/\/+$/, '') + if (value) { + declared.add(value) + } + } + return declared +} + +/** + * Detect independent nested git repos inside `repoPath` that a worktree of the + * parent repo would NOT materialize. Tracked submodules (staged gitlinks or + * `.gitmodules` declarations) are intentional layouts and never warn. + * + * Never throws and never rejects; resolves null when there is nothing to warn + * about (including any scan/git failure or timeout — a warning is best-effort). + */ +export async function detectUntrackedNestedRepos( + repoPath: string, + deps: NestedRepoDetectionDeps = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['rev-parse', '--show-toplevel'], { cwd: repoPath }) + const gitToplevel = stdout.trim() + let toplevel = gitToplevel + // Why: the WSL-aware runner returns Linux paths from git stdout; the fs scan + // below runs in the Windows process and needs the UNC form. + const wslInfo = parseWslPath(repoPath) + if (wslInfo) { + toplevel = toWindowsWslPath(gitToplevel, wslInfo.distro) + } + // Why: pathspecs and .gitmodules are toplevel-relative, so git commands must + // run from the toplevel — except on WSL, where the git toplevel is a Linux + // path the Windows process cannot use as cwd (the registered UNC repoPath + // routes through the WSL-aware runner instead). + const gitCwd = wslInfo ? repoPath : gitToplevel + + const scan = deps.scan ?? scanNestedRepos + const result = await scan({ + path: toplevel, + options: { maxDepth: 3, maxRepos: 100, timeoutMs: 5000 }, + filesystem: { + ...createLocalNestedRepoScanFilesystem(), + // Why: the scanner early-returns on git repos (it was built for non-git + // project folders) and prunes gitignored dirs — but gitignored nested + // repos are exactly the meta-repo case this warning exists for. + isSelectedPathGitRepo: () => false, + readTextFile: async () => '' + } + }) + if (result.timedOut) { + return null + } + + const candidates = result.repos + .map((repo) => relativizeToToplevel(toplevel, repo.path)) + .filter((relative): relative is string => relative !== null) + .sort() + if (candidates.length === 0) { + return null + } + + const { stdout: lsFilesOut } = await gitExecFileAsync( + ['ls-files', '-z', '--stage', '--', ...candidates], + { cwd: gitCwd } + ) + const gitlinks = parseGitlinkPaths(lsFilesOut) + + let declaredSubmodules = new Set() + try { + const { stdout: configOut } = await gitExecFileAsync( + ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], + { cwd: gitCwd } + ) + declaredSubmodules = parseGitmodulesPaths(configOut) + } catch { + // Why: git config exits non-zero when .gitmodules is absent — not an error. + } + + const untracked = candidates.filter( + (candidate) => !gitlinks.has(candidate) && !declaredSubmodules.has(candidate) + ) + if (untracked.length === 0) { + return null + } + + const shown = untracked.slice(0, NESTED_REPO_DISPLAY_CAP) + return { + paths: shown.map((candidate) => `${candidate}/`), + truncated: untracked.length > shown.length, + moreCount: untracked.length - shown.length + } + } catch { + return null + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 0211096db4..847f777dfa 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -29,6 +29,7 @@ import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git import { validateGitPushTarget } from '../git/push-target-validation' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from '../git/runner' +import { detectUntrackedNestedRepos } from '../git/nested-repo-warning' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' @@ -1587,6 +1588,10 @@ export async function createLocalWorktree( runtime?: OrcaRuntimeService ): Promise { const timing = createWorktreeCreateTimingRecorder() + // Why: launched before the first await so the scan overlaps the create; the + // .catch lives at the launch site because intermediate throws would leave the + // promise un-awaited and a rejection would otherwise surface as unhandled. + const nestedReposPromise = detectUntrackedNestedRepos(repo.path).catch(() => null) const settings = store.getSettings() const worktreePathSettings = getWorktreePathSettings(repo, settings) @@ -2042,8 +2047,10 @@ export async function createLocalWorktree( ) notifyWorktreesChanged(mainWindow, repo.id) + const nestedRepos = await nestedReposPromise return { worktree, + ...(nestedRepos ? { nestedRepos } : {}), ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index fa2ecde8a0..61a8c34195 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -111,6 +111,14 @@ vi.mock('../git/runner', () => ({ gitExecFileSync: vi.fn() })) +const { detectUntrackedNestedReposMock } = vi.hoisted(() => ({ + detectUntrackedNestedReposMock: vi.fn() +})) + +vi.mock('../git/nested-repo-warning', () => ({ + detectUntrackedNestedRepos: detectUntrackedNestedReposMock +})) + vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock, getDefaultBaseRef: getDefaultBaseRefMock, @@ -280,6 +288,7 @@ describe('registerWorktreeHandlers', () => { computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock, + detectUntrackedNestedReposMock, getSshGitProviderMock, getSshFilesystemProviderMock, getActiveMultiplexerMock, @@ -349,6 +358,7 @@ describe('registerWorktreeHandlers', () => { // narrow unit harnesses. Return a resolved promise so catch/then chains // don't trip on undefined. gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + detectUntrackedNestedReposMock.mockResolvedValue(null) getEffectiveHooksMock.mockReturnValue(null) getEffectiveHooksFromConfigMock.mockImplementation(() => getEffectiveHooksMock()) getDefaultTabsLaunchMock.mockReturnValue(undefined) @@ -566,6 +576,116 @@ describe('registerWorktreeHandlers', () => { }) }) + it('attaches the nested-repo warning to the local create result', async () => { + const warning = { paths: ['frontend/'], truncated: false, moreCount: 0 } + detectUntrackedNestedReposMock.mockResolvedValue(warning) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt', + head: 'abc123', + branch: 'feature-wt', + isBare: false, + isMainWorktree: false + } + ]) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature-wt' + })) as { nestedRepos?: unknown } + + expect(detectUntrackedNestedReposMock).toHaveBeenCalledWith('/workspace/repo') + expect(result.nestedRepos).toEqual(warning) + }) + + it('omits the nested-repo field when the detector resolves null', async () => { + detectUntrackedNestedReposMock.mockResolvedValue(null) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt', + head: 'abc123', + branch: 'feature-wt', + isBare: false, + isMainWorktree: false + } + ]) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature-wt' + })) as Record + + expect('nestedRepos' in result).toBe(false) + }) + + it('launches nested-repo detection before the worktree add', async () => { + detectUntrackedNestedReposMock.mockResolvedValue(null) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt', + head: 'abc123', + branch: 'feature-wt', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature-wt' + }) + + const detectorOrder = detectUntrackedNestedReposMock.mock.invocationCallOrder[0] + const addOrder = addWorktreeMock.mock.invocationCallOrder[0] + expect(detectorOrder).toBeLessThan(addOrder) + }) + + it('creates normally when the detector rejects even if the create itself throws early', async () => { + // Why: the detection promise is launched before the first await; if create + // throws before reaching its await, a rejecting detector must not surface + // as an unhandled rejection (the .catch must live at the launch site). + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', onUnhandled) + try { + detectUntrackedNestedReposMock.mockRejectedValue(new Error('detector exploded')) + addWorktreeMock.mockRejectedValue(new Error('add failed')) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'feature-wt' }) + ).rejects.toThrow('add failed') + + await new Promise((resolve) => setImmediate(resolve)) + expect(unhandled).toEqual([]) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + addWorktreeMock.mockReset() + addWorktreeMock.mockResolvedValue(undefined) + } + }) + + it('creates normally without the field when the detector rejects', async () => { + detectUntrackedNestedReposMock.mockRejectedValue(new Error('detector exploded')) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt', + head: 'abc123', + branch: 'feature-wt', + isBare: false, + isMainWorktree: false + } + ]) + + const result = (await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'feature-wt' + })) as Record + + expect('nestedRepos' in result).toBe(false) + }) + it('uses a repo-specific worktree base path when creating local worktrees', async () => { store.getRepo.mockReturnValue({ id: 'repo-1', @@ -1583,6 +1703,43 @@ describe('registerWorktreeHandlers', () => { }) }) + it('does not run nested-repo detection for remote creates', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/feature-wt', + head: 'abc123', + branch: 'refs/heads/feature-wt', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + + await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'feature-wt' }) + + expect(detectUntrackedNestedReposMock).not.toHaveBeenCalled() + }) + it('returns SSH local base refresh skip status when the owning worktree is dirty', async () => { const repo = { id: 'repo-ssh', diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index acc193b15d..fa6339b80c 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -15,7 +15,7 @@ type NestedRepoDirectoryEntry = { isSymlink?: boolean } -type NestedRepoScanFilesystem = { +export type NestedRepoScanFilesystem = { readDirectory: (dirPath: string) => Promise readTextFile?: (filePath: string) => Promise joinPath: (parentPath: string, childName: string) => string @@ -206,6 +206,20 @@ async function readLocalDirectory(dirPath: string): Promise readFile(path, 'utf8'), + joinPath: join, + basename, + hasGitMarker, + isSelectedPathGitRepo: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) + } +} + export async function scanNestedRepos(args: { path: string options?: unknown @@ -219,14 +233,7 @@ export async function scanNestedRepos(args: { let truncated = false let timedOut = false let stopped = false - const filesystem = args.filesystem ?? { - readDirectory: readLocalDirectory, - readTextFile: (path: string) => readFile(path, 'utf8'), - joinPath: join, - basename, - hasGitMarker, - isSelectedPathGitRepo: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) - } + const filesystem = args.filesystem ?? createLocalNestedRepoScanFilesystem() const buildResult = (selectedPathKind: NestedRepoScanResult['selectedPathKind']) => ({ selectedPath: args.path, selectedPathKind, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 39e3ec1e4e..613dcd27ed 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1316,6 +1316,75 @@ describe('createWorktree base status merge', () => { expect(toast.warning).not.toHaveBeenCalled() }) + it('warns about untracked nested repos after a local create', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + nestedRepos: { paths: ['backend/', 'frontend/'], truncated: false, moreCount: 0 } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).toHaveBeenCalledWith('Workspace created without nested repos', { + description: expect.stringContaining('backend/, frontend/') + }) + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('only contains files tracked by the parent repo') + }) + + it('appends the remainder count when the nested repo list is truncated', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + nestedRepos: { + paths: Array.from({ length: 10 }, (_, i) => `pkg-${i}/`), + truncated: true, + moreCount: 2 + } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('and 2 more') + }) + + it('does not warn about nested repos when the field is absent', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + mockApi.worktrees.create.mockResolvedValue({ worktree: wt }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).not.toHaveBeenCalledWith( + 'Workspace created without nested repos', + expect.anything() + ) + }) + + it('shows both the base-ref and nested-repo warnings when present together', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + localBaseRefRefresh: { + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/repo' + }, + nestedRepos: { paths: ['frontend/'], truncated: false, moreCount: 0 } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + const titles = vi.mocked(toast.warning).mock.calls.map((call) => call[0]) + expect(titles).toContain('Local main was not refreshed') + expect(titles).toContain('Workspace created without nested repos') + }) + it('stamps manualOrder on create while Manual sort is active', async () => { const store = createTestStore() store.setState({ sortBy: 'manual' } as Partial) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index e91d34fc68..b4e72f6371 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -6,6 +6,7 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, LocalBaseRefRefreshResult, + NestedRepoWarning, ForceDeleteWorktreeBranchResult, GitHubPrStartPoint, Worktree, @@ -97,6 +98,19 @@ function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefi }) } +// Why: a worktree of a meta-repo materializes none of the nested repos' files; +// without this warning the user lands in a silently incomplete tree. The field +// is only attached by local creates, so remote/folder flows are no-ops here. +function showNestedReposToast(warning: NestedRepoWarning | undefined): void { + if (!warning) { + return + } + const more = warning.truncated && warning.moreCount > 0 ? ` and ${warning.moreCount} more` : '' + toast.warning('Workspace created without nested repos', { + description: `This repo contains nested git repos not tracked by it: ${warning.paths.join(', ')}${more}. The new worktree only contains files tracked by the parent repo.` + }) +} + function showPreservedBranchToast( result: RemoveWorktreeResult | undefined, worktree: Pick | undefined, @@ -1134,6 +1148,7 @@ export const createWorktreeSlice: StateCreator } }) showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + showNestedReposToast(result.nestedRepos) return result } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/src/shared/types.ts b/src/shared/types.ts index c61a7cb486..c8d6fefce7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -400,6 +400,18 @@ export type WorktreeLineageWarning = { details?: Record } +// Why: a worktree of a meta-repo (parent repo containing independent nested git +// repos) materializes none of the nested repos' files; this warns the user +// post-create instead of leaving them in a silently incomplete tree. +export type NestedRepoWarning = { + /** Repo-relative paths, '/'-normalized, trailing '/'; capped at 10 shown. */ + paths: string[] + /** True when more nested repos exist beyond the displayed cap. */ + truncated: boolean + /** Count of additional repos beyond `paths` (0 when not truncated). */ + moreCount: number +} + // ─── Diff line comments ────────────────────────────────────────────── // Why: users leave review notes on specific lines of the modified side of // a diff so they can be handed back to an AI agent (pasted into a terminal @@ -1638,6 +1650,7 @@ export type CreateWorktreeResult = { warning?: string initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult + nestedRepos?: NestedRepoWarning startupTerminal?: { spawned: boolean surface?: 'visible' | 'background'