fix: include gitignored files in quick open (#1762)
This commit is contained in:
parent
a076656ff8
commit
c8d97f3b98
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const files = new Set<string>()
|
||||
const { primary, envPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const files = new Set<string>()
|
||||
const { primary, envPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).stdout = new EventEmitter()
|
||||
;(
|
||||
(p as unknown as Record<string, unknown>).stdout as EventEmitter & {
|
||||
setEncoding: () => void
|
||||
}
|
||||
).setEncoding = vi.fn()
|
||||
;(p as unknown as Record<string, unknown>).stderr = new EventEmitter()
|
||||
;(p as unknown as Record<string, unknown>).kill = vi.fn()
|
||||
;(p as unknown as Record<string, unknown>).exitCode = null
|
||||
;(p as unknown as Record<string, unknown>).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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/**')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue