From c8d97f3b98dc00520ee0a704c13e632e9303bf81 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 13 May 2026 14:29:30 -0700 Subject: [PATCH] fix: include gitignored files in quick open (#1762) --- docs/quick-open-gitignored-files.md | 76 ++++++++++ src/main/ipc/filesystem-list-files.test.ts | 34 +++-- src/main/ipc/filesystem-list-files.ts | 13 +- src/relay/fs-handler-git-fallback.ts | 8 +- .../fs-handler-list-files-ignored.test.ts | 131 ++++++++++++++++++ src/relay/fs-handler-list-files.ts | 6 +- src/shared/quick-open-filter.test.ts | 27 ++-- src/shared/quick-open-filter.ts | 44 ++---- 8 files changed, 273 insertions(+), 66 deletions(-) create mode 100644 docs/quick-open-gitignored-files.md create mode 100644 src/relay/fs-handler-list-files-ignored.test.ts diff --git a/docs/quick-open-gitignored-files.md b/docs/quick-open-gitignored-files.md new file mode 100644 index 000000000..09c2119be --- /dev/null +++ b/docs/quick-open-gitignored-files.md @@ -0,0 +1,76 @@ +# Quick Open Gitignored Files + +## Problem + +Cmd/Ctrl+P Quick Open does not list arbitrary gitignored files in the active workspace. + +- `src/renderer/src/components/QuickOpen.tsx:257` calls `window.api.fs.listFiles(...)`; filtering in the renderer only fuzzy-matches the returned list, so missing files are already absent from the backend result. +- `src/main/ipc/filesystem-list-files.ts:46` uses `buildRgArgsForQuickOpen(...)` for local worktrees, then merges the primary rg pass with `envPass`. +- `src/relay/fs-handler-list-files.ts:37` uses the same shared rg args for SSH worktrees. +- `src/shared/quick-open-filter.ts:252` builds the primary rg pass without `--no-ignore-vcs`, so gitignored files are hidden. +- `src/shared/quick-open-filter.ts:265` builds a second `--no-ignore-vcs` pass, but it is restricted to `.env*` and `**/.env*`. +- `src/shared/quick-open-filter.ts:357` builds the git fallback primary pass with `--exclude-standard`; `src/shared/quick-open-filter.ts:365` only adds `.env*` pathspecs. + +## Root Cause + +Quick Open intentionally mirrors `rg --files --hidden` with gitignore respect, then adds a special-case pass for gitignored `.env*` files. That policy lives in shared code used by local main-process listing, SSH relay listing, and git fallback listing, so non-env ignored files are excluded consistently in every workspace type. + +## Non-goals + +- Do not change right-sidebar text search behavior. +- Do not add a new UI preference or mode switch. +- Do not follow symlinks or broaden traversal outside the authorized workspace root. +- Do not surface heavy generated directories already excluded by Quick Open policy, including `node_modules`, `.git`, `.cache`, `.next`, and other `HIDDEN_DIR_BLOCKLIST` entries. +- Do not change fuzzy ranking, selection, file-opening behavior, or shortcut handling. + +## Design + +1. Replace the `.env*` rg second pass with a general ignored-files pass. + - Keep `--files --hidden --no-ignore-vcs`, hidden-dir blocklist globs, nested-worktree exclude globs, `searchRoot='.'`, and path-separator handling. + - Remove positive `.env*` globs so the second pass is no longer a whitelist. + - Keep the primary pass unchanged. + +2. Rename pass naming from `envPass` to `ignoredPass` in shared/main/relay code. + - This is a behavior change, not cosmetic: the second pass now returns all gitignored candidates. + +3. Replace git fallback `.env*` pass with ignored-files pass. + - Keep primary as `['--cached', '--others', '--exclude-standard', ...]`. + - Use ignored pass as `['--others', '--ignored', '--exclude-standard', ...]`. + - Keep nested-worktree pathspec exclusion semantics (`--`, `.`, excludes when exclude prefixes exist). + - Do not assume both runtimes have identical error semantics today: + - local main fallback currently resolves on most git failures; + - relay fallback rejects on spawn/signal failures. + Preserve each runtime’s current behavior unless explicitly changing it in a separate doc. + +4. Keep post-filters unchanged. + - `shouldIncludeQuickOpenPath` remains final blocklist enforcement. + - `shouldExcludeQuickOpenRelPath` remains nested-worktree correctness backstop. + - Set-based merge still dedupes cross-pass overlap. + +5. Do not change renderer request-lifecycle logic in this doc. + - Current `QuickOpen.tsx` effect cleanup cancels the prior request before the next effect body runs. + - This change is backend listing policy only. + +6. Update tests. + - `quick-open-filter.test.ts`: assert rg ignored pass has `--no-ignore-vcs` and no `.env*` globs; assert git ignored pass has `--others --ignored --exclude-standard` and no `.env*` pathspec whitelist. + - `filesystem-list-files.test.ts`: update pass-detection helpers that currently key off `'**/.env*'`; cover ignored non-env files; keep local fallback’s resolve-on-failure behavior unchanged. + - Add relay coverage in `src/relay/fs-handler.test.ts` (or new focused relay tests) for ignored-pass args and current reject-on-signal behavior. + +## Edge Cases + +- Gitignored files inside `node_modules`, `.git`, `.cache`, `.next`, `.npm`, `.npm-global`, `.gvfs`, and other blocklisted dirs must remain hidden. +- Nested linked worktree paths passed as `excludePaths` must remain excluded from both rg and git passes. +- Local/SSH candidate sets should match when both run the same backend path (rg or git) and complete successfully; timeout/error behavior remains intentionally different today. +- Windows and WSL path normalization must remain unchanged; output still passes through `normalizeQuickOpenRgLine`. +- If rg is unavailable, git fallback should include ignored files only when Git can enumerate them; non-git roots keep existing fallback limits. +- Timeout/signal behavior must not regress into partial false-empty results. +- Keep existing timeout asymmetry unless intentionally changed: local rg/git fallback uses 10s timeouts, relay rg uses 25s. +- `--no-ignore-vcs` also includes files ignored by parent/global excludes; blocklists are the guardrail against accidental heavy trees. + +## Rollout + +1. Update `src/shared/quick-open-filter.ts` types, rg args, git args, and comments. +2. Update local main-process and SSH relay callers/tests for `ignoredPass`. +3. Run focused tests for quick-open filters and list-files (main + relay). +4. Run `pnpm typecheck` and `pnpm lint`. +5. Validate in Electron on local + SSH worktrees with gitignored non-env files and nested linked worktrees. diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index c4ac20fee..d315cc53e 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -43,6 +43,10 @@ function createMockProcess(): ChildProcess { return p } +function isIgnoredRgPass(args: string[]): boolean { + return args.includes('--no-ignore-vcs') +} + describe('filesystem-list-files', () => { beforeEach(() => { vi.clearAllMocks() @@ -50,12 +54,12 @@ describe('filesystem-list-files', () => { checkRgAvailableMock.mockResolvedValue(true) }) - it('merges normal files and env files and filters correctly', async () => { + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 @@ -74,15 +78,23 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n') p1.emit('close', 0, null) - // Simulate stdout output for env files + // Simulate stdout output for ignored files ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') + ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate + ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') p2.emit('close', 0, null) }, 10) const result = await promise - expect(result).toEqual(['file1.ts', '.github/workflows/ci.yml', 'dir1/file2.js', '.env.local']) + expect(result).toEqual([ + 'file1.ts', + '.github/workflows/ci.yml', + 'dir1/file2.js', + '.env.local', + 'dist/generated.js' + ]) }) it('rejects rg failures instead of resolving a false-empty list', async () => { @@ -90,7 +102,7 @@ describe('filesystem-list-files', () => { const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 @@ -112,7 +124,7 @@ describe('filesystem-list-files', () => { const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 @@ -135,7 +147,7 @@ describe('filesystem-list-files', () => { const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 @@ -158,7 +170,7 @@ describe('filesystem-list-files', () => { const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 @@ -176,7 +188,7 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') p1.emit('close', 0, null) - // Empty env result + // Empty ignored result p2.emit('close', 0, null) }, 10) @@ -211,6 +223,7 @@ describe('filesystem-list-files', () => { gitP1.emit('close', 0, null) ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') + ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') gitP2.emit('close', 0, null) }, 10) @@ -229,6 +242,7 @@ describe('filesystem-list-files', () => { expect(result).toContain('src/index.ts') expect(result).toContain('package.json') expect(result).toContain('.env.local') + expect(result).toContain('dist/generated.js') expect(result).not.toContain('node_modules/dep/index.js') }) @@ -272,7 +286,7 @@ describe('filesystem-list-files', () => { const p2 = createMockProcess() spawnMock.mockImplementation((_cmd, args: string[]) => { - if (args.includes('**/.env*')) { + if (isIgnoredRgPass(args)) { return p2 } return p1 diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index aba69a3e9..07f55ec76 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -43,7 +43,7 @@ export async function listQuickOpenFiles( // UNC paths up-front before the shared line normalizer runs. const wslInfo = parseWslPath(authorizedRootPath) - const { primary, envPass } = buildRgArgsForQuickOpen({ + const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` // filters output after traversal but does not prune the nested worktree. @@ -159,7 +159,7 @@ export async function listQuickOpenFiles( } try { - await Promise.all([runRg(primary), runRg(envPass)]) + await Promise.all([runRg(primary), runRg(ignoredPass)]) } catch (err) { killSurvivors() throw err @@ -172,15 +172,14 @@ export async function listQuickOpenFiles( * * Why two git ls-files calls: the first lists tracked + untracked-but-not-ignored * files (mirrors rg --files --hidden with gitignore respect). The second - * surfaces .env* files that are typically gitignored but users frequently - * need in quick-open (mirrors the second rg call with --no-ignore-vcs). + * surfaces ignored files (mirrors the second rg call with --no-ignore-vcs). */ function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] ): Promise { const files = new Set() - const { primary, envPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) + const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve) => { @@ -251,5 +250,7 @@ function listFilesWithGit( }) } - return Promise.all([runGitLsFiles(primary), runGitLsFiles(envPass)]).then(() => Array.from(files)) + return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]).then(() => + Array.from(files) + ) } diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 6d8c69ea4..db7c23c43 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -26,7 +26,7 @@ import { * List files using `git ls-files`. Fallback when rg is not installed. * * Why both passes: primary surfaces tracked + untracked-non-ignored; - * envPass surfaces gitignored .env* files that users frequently Quick Open. + * ignoredPass surfaces gitignored files that users frequently Quick Open. * Exclude pathspecs are prepended by the shared builder so nested linked * worktrees are pruned by git directly; post-filtering remains as a * correctness backstop. @@ -36,7 +36,7 @@ export function listFilesWithGit( excludePathPrefixes: readonly string[] = [] ): Promise { const files = new Set() - const { primary, envPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) + const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { @@ -112,7 +112,9 @@ export function listFilesWithGit( }) } - return Promise.all([runGitLsFiles(primary), runGitLsFiles(envPass)]).then(() => Array.from(files)) + return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]).then(() => + Array.from(files) + ) } /** diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts new file mode 100644 index 000000000..0eae4db89 --- /dev/null +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { EventEmitter } from 'events' +import type { ChildProcess } from 'child_process' +import { listFilesWithGit } from './fs-handler-git-fallback' +import { listFilesWithRg } from './fs-handler-list-files' + +function createMockProcess(): ChildProcess { + const p = new EventEmitter() as unknown as ChildProcess + ;(p as unknown as Record).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + ;(p as unknown as Record).exitCode = null + ;(p as unknown as Record).signalCode = null + return p +} + +describe('relay quick open ignored file listing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rg ignored pass includes ignored non-env files and keeps blocklists/excludes', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('--no-ignore-vcs')) { + return ignoredProc + } + return primaryProc + }) + + const promise = listFilesWithRg('/remote/root', ['packages/other']) + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + primaryProc.emit('close', 0, null) + + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) + + const ignoredArgs = spawnMock.mock.calls.find((call) => + (call[1] as string[]).includes('--no-ignore-vcs') + )?.[1] as string[] + expect(ignoredArgs).toBeDefined() + expect(ignoredArgs).toContain('--no-ignore-vcs') + expect(ignoredArgs).not.toContain('.env*') + expect(ignoredArgs).not.toContain('**/.env*') + expect(ignoredArgs).toContain('!**/node_modules') + expect(ignoredArgs).toContain('!packages/other') + expect(ignoredArgs).toContain('!packages/other/**') + }) + + it('git fallback ignored pass includes ignored non-env files', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root', ['packages/other']) + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + primaryProc.emit('close', 0, null) + + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) + + const ignoredArgs = spawnMock.mock.calls[1][1] as string[] + expect(ignoredArgs).toEqual([ + 'ls-files', + '--others', + '--ignored', + '--exclude-standard', + '--', + '.', + ':(exclude,glob)packages/other', + ':(exclude,glob)packages/other/**' + ]) + }) + + it('git fallback rejects signal exits instead of returning partial results', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root') + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + primaryProc.emit('close', 0, null) + + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ignoredProc.emit('close', null, 'SIGTERM') + }, 10) + + await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') + }) +}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index e01555b81..9ccee533b 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -8,7 +8,7 @@ * matching files" even though the file existed on disk. This implementation: * - streams via spawn (no maxBuffer failure mode) * - prunes traversal at rg level using the shared blocklist globs - * - runs a second --no-ignore-vcs pass for .env* files + * - runs a second --no-ignore-vcs pass for ignored files * - honors excludePathPrefixes for nested linked worktrees * - rejects (not resolves) on timeout / spawn error / signal exit so * the UI shows a load error instead of a false-empty list @@ -34,7 +34,7 @@ export function listFilesWithRg( let done = false const children: ChildProcess[] = [] - const { primary, envPass } = buildRgArgsForQuickOpen({ + const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg only applies root-relative exclude globs as traversal pruning // when the search target is relative to cwd. Absolute targets still // emit root-relative-looking paths for filters, but they do not prune. @@ -162,7 +162,7 @@ export function listFilesWithRg( } } - Promise.all([runPass(primary), runPass(envPass)]) + Promise.all([runPass(primary), runPass(ignoredPass)]) .then(() => { if (done) { return diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index a6edfa8e2..ed6ea79a6 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -114,16 +114,16 @@ describe('buildRgArgsForQuickOpen', () => { expect(primary).not.toContain('--follow') }) - it('.env* pass includes --no-ignore-vcs and both root + nested globs, no --follow', () => { - const { envPass } = buildRgArgsForQuickOpen({ + it('ignored pass includes --no-ignore-vcs without .env* whitelist globs, no --follow', () => { + const { ignoredPass } = buildRgArgsForQuickOpen({ searchRoot: '/root', excludePathPrefixes: [], forceSlashSeparator: false }) - expect(envPass).toContain('--no-ignore-vcs') - expect(envPass).toContain('.env*') - expect(envPass).toContain('**/.env*') - expect(envPass).not.toContain('--follow') + expect(ignoredPass).toContain('--no-ignore-vcs') + expect(ignoredPass).not.toContain('.env*') + expect(ignoredPass).not.toContain('**/.env*') + expect(ignoredPass).not.toContain('--follow') }) it('forceSlashSeparator emits --path-separator /', () => { @@ -203,21 +203,22 @@ describe('buildGitLsFilesArgsForQuickOpen', () => { expect(primary).toEqual(['--cached', '--others', '--exclude-standard']) }) - it('env pass surfaces gitignored .env at root and nested, without --exclude-standard', () => { - const { envPass } = buildGitLsFilesArgsForQuickOpen() - expect(envPass).toContain('--others') - expect(envPass).toContain('.env*') - expect(envPass).toContain(':(glob)**/.env*') - expect(envPass).not.toContain('--exclude-standard') + it('ignored pass surfaces ignored files without .env* pathspec whitelist', () => { + const { ignoredPass } = buildGitLsFilesArgsForQuickOpen() + expect(ignoredPass).toEqual(['--others', '--ignored', '--exclude-standard']) + expect(ignoredPass).not.toContain('.env*') + expect(ignoredPass).not.toContain(':(glob)**/.env*') }) it('exclude prefixes prepend positive "." pathspec', () => { - const { primary } = buildGitLsFilesArgsForQuickOpen(['packages/app']) + const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(['packages/app']) const dashDashIdx = primary.indexOf('--') expect(dashDashIdx).toBeGreaterThanOrEqual(0) // Positive pathspec must appear before any exclude pathspec. expect(primary[dashDashIdx + 1]).toBe('.') expect(primary).toContain(':(exclude,glob)packages/app') expect(primary).toContain(':(exclude,glob)packages/app/**') + expect(ignoredPass).toContain(':(exclude,glob)packages/app') + expect(ignoredPass).toContain(':(exclude,glob)packages/app/**') }) }) diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index 7532ac089..d5d927c24 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -5,8 +5,8 @@ * * Why this module exists (design doc: docs/design/share-quick-open-file-listing.md): * Before extraction, the local and relay listFiles implementations had diverged - * on blocklist, .env* handling, nested-worktree exclusions, timeout strategy, - * and buffering. A home-dir worktree over SSH would descend into $HOME dotfile + * on blocklist, ignored-file handling, nested-worktree exclusions, timeout + * strategy, and buffering. A home-dir worktree over SSH would descend into $HOME dotfile * caches, hit a 10s timeout, and silently resolve with a partial result — * Quick Open showed "No matching files" even though the scan was incomplete. * Centralizing the policy prevents future drift. @@ -223,8 +223,8 @@ export type RgArgsOptions = { export type RgArgs = { /** Main pass: all non-ignored files, hidden dotfiles included. */ primary: string[] - /** Second pass: gitignored .env* files. */ - envPass: string[] + /** Second pass: ignored files, hidden dotfiles included. */ + ignoredPass: string[] } /** @@ -258,25 +258,19 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { opts.searchRoot ] - // .env* pass: must include --no-ignore-vcs so rg surfaces gitignored .env - // files, which is the whole reason the second pass exists. Two positive - // globs (root-level and nested) because rg treats any positive --glob as a - // whitelist; no preceding negative pattern is needed. - const envPass = [ + // Ignored pass: --no-ignore-vcs broadens traversal to gitignored and + // parent/global ignored files; blocklist globs remain the guardrail. + const ignoredPass = [ '--files', '--hidden', '--no-ignore-vcs', ...sepArgs, - '--glob', - '.env*', - '--glob', - '**/.env*', ...hiddenDirGlobs, ...excludeGlobs, opts.searchRoot ] - return { primary, envPass } + return { primary, ignoredPass } } // ─── rg stdout line normalization ──────────────────────────────────── @@ -331,7 +325,7 @@ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMo export type GitLsFilesArgs = { primary: string[] - envPass: string[] + ignoredPass: string[] } /** @@ -340,9 +334,8 @@ export type GitLsFilesArgs = { * prepended so exclude-only pathspecs do not depend on git's edge-case * defaults. * - * The `.env*` pass uses BOTH `.env*` (root-level) and `**\/.env*` (nested) - * because the `**\/` prefix alone does not match a root-level `.env` file — - * this was the silent bug in the prior local implementation. + * The ignored pass asks git for ignored untracked files. Non-git roots keep + * their existing non-git fallback limits in the callers. */ export function buildGitLsFilesArgsForQuickOpen( excludePathPrefixes: readonly string[] = [] @@ -355,17 +348,6 @@ export function buildGitLsFilesArgsForQuickOpen( const trailingPathspecs = excludeSpecs.length > 0 ? ['--', '.', ...excludeSpecs] : [] const primary = ['--cached', '--others', '--exclude-standard', ...trailingPathspecs] - // Second pass: untracked AND ignored .env* files. Do not pass - // --exclude-standard — that would re-hide gitignored .env files, which is - // the whole reason this pass exists. - // Why :(glob): default git pathspec uses fnmatch, where `*` crossing `/` is - // implementation-dependent. `:(glob)` pins the semantics explicitly so - // `**/.env*` reliably surfaces nested `.env` files across git versions. - const nestedEnvSpec = ':(glob)**/.env*' - const envPassPathspecs = - excludeSpecs.length > 0 - ? ['--', '.env*', nestedEnvSpec, ...excludeSpecs] - : ['--', '.env*', nestedEnvSpec] - const envPass = ['--others', ...envPassPathspecs] - return { primary, envPass } + const ignoredPass = ['--others', '--ignored', '--exclude-standard', ...trailingPathspecs] + return { primary, ignoredPass } }