🐛 fix(vscode): 代码阅读工作区索引跳过嵌套的 checkout 和忽略文件忽略的,任务名不再查到两处

This commit is contained in:
cruldra committed 2026-09-26 20:14:30 +08:00
1 parent de355661f0
commit 113b02657c
7 files changed
+202 -12

No files matched your search

+4 -1
View File
@@ -431,7 +431,10 @@ export interface WorkspaceIndex {
id: string
/** 按哪种语言解析:宿主用这个 languageId 的语言插件。 */
languageId: string
/** 看哪些文件:相对各工作区文件夹的 glob('**\/*.py');files.exclude、search.exclude 排除的,和 .venv、node_modules、__pycache__ 里的不看。 */
/**
* 看哪些文件:相对各工作区文件夹的 glob('**\/*.py');files.exclude、search.exclude 排除的,.venv、node_modules、__pycache__ 里的,
* 嵌套的 checkout(子仓库、git worktree)里的,和忽略文件(.gitignore、.ignore,按 search.useIgnoreFiles)忽略的不看。
*/
include: string
/** 便宜的文字预筛:源码里不可能有要找的东西就不解析。 */
mentions: (source: string) => boolean
+25 -4
View File
@@ -1,8 +1,10 @@
import type { WorkspaceFolder } from 'vscode'
import type { IndexHost, WorkspaceFile } from './workspaceIndex'
import type { FolderReader } from './workspaceScope'
import * as path from 'node:path'
import { minimatch } from 'minimatch'
import { RelativePattern, Uri, workspace } from 'vscode'
import { folderScope } from './workspaceScope'
/** 建索引时总不看的目录:虚拟环境、依赖、Python 字节码缓存。 */
const ALWAYS_EXCLUDED = ['**/.venv', '**/node_modules', '**/__pycache__']
@@ -36,17 +38,34 @@ function workspaceFile(uri: Uri): { file: WorkspaceFile, folder: WorkspaceFolder
return { folder, file: { uri: uri.toString(), folder: folder.uri.toString(), path: path.posix.relative(folder.uri.path, uri.path) } }
}
/** 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。 */
/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */
function scopeOf(folder: WorkspaceFolder): ReturnType<typeof folderScope> {
const at = (path: string): Uri => Uri.joinPath(folder.uri, path)
const reader: FolderReader = {
exists: path => Promise.resolve(workspace.fs.stat(at(path))).then(() => true, () => false),
read: path => Promise.resolve(workspace.fs.readFile(at(path))).then(bytes => new TextDecoder('utf-8').decode(bytes), () => undefined),
}
return folderScope(reader, workspace.getConfiguration('search', folder.uri).get<boolean>('useIgnoreFiles', true))
}
/**
* 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。
* 不看的:files.exclude、search.exclude 排除的、总不看的目录,加上嵌套的 checkout 和忽略文件忽略的(见 folderScope)。
* 插件声明的引擎版本(^1.97)的正式 API 里 findFiles 不能按忽略文件筛,所以忽略文件自己读。
*/
export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost['report']): IndexHost {
return {
async list(include) {
const found = await Promise.all((workspace.workspaceFolders ?? []).map(async (folder) => {
const patterns = excludedPatterns(folder)
const scope = scopeOf(folder)
const uris = await workspace.findFiles(new RelativePattern(folder, include), excludeGlob(patterns))
return uris.flatMap((uri) => {
const files = uris.flatMap((uri) => {
const file = workspaceFile(uri)?.file
return file && !isExcluded(file.path, patterns) ? [file] : []
})
const skipped = await Promise.all(files.map(file => scope.skips(file.path)))
return files.filter((_, i) => !skipped[i])
}))
return found.flat()
},
@@ -54,9 +73,11 @@ export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost[
return new TextDecoder('utf-8').decode(await workspace.fs.readFile(Uri.parse(file.uri)))
},
watch(include, listener) {
const forward = (kind: 'change' | 'create') => (uri: Uri): void => {
const forward = (kind: 'change' | 'create') => async (uri: Uri): Promise<void> => {
const found = workspaceFile(uri)
if (found && minimatch(found.file.path, include, { dot: true }) && !isExcluded(found.file.path, excludedPatterns(found.folder)))
if (!found || !minimatch(found.file.path, include, { dot: true }) || isExcluded(found.file.path, excludedPatterns(found.folder)))
return
if (!await scopeOf(found.folder).skips(found.file.path))
listener({ kind, file: found.file })
}
// 删目录时 VS Code 只报目录本身,匹配不上 include,所以删除另用一个什么都盯的
@@ -0,0 +1,84 @@
import type { FolderReader } from './workspaceScope'
import { describe, expect, it } from 'vitest'
import { folderScope } from './workspaceScope'
/** 内存里的工作区文件夹:路径 → 内容(目录不用列,列出来的文件所在的目录都算有);记下读过哪些。 */
function memoryFolder(files: Record<string, string>): FolderReader & { asked: string[] } {
const asked: string[] = []
const directories = new Set(Object.keys(files).flatMap((file) => {
const parts = file.split('/')
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'))
}))
return {
asked,
exists: async (path) => {
asked.push(path)
return path in files || directories.has(path)
},
read: async (path) => {
asked.push(path)
return files[path]
},
}
}
async function kept(files: Record<string, string>, paths: string[], useIgnoreFiles = true): Promise<string[]> {
const scope = folderScope(memoryFolder(files), useIgnoreFiles)
const skipped = await Promise.all(paths.map(path => scope.skips(path)))
return paths.filter((_, i) => !skipped[i])
}
const TASKS = 'backend/src/luffy_agent/commerce/tasks.py'
const WORKTREE = '.claude/worktrees/agent-aa35211c29ea9e068'
describe('工作区扫描还要跳过的文件', () => {
it('嵌套的 git worktree(子目录里的 .git 是个文件)整个跳过,工作区自己的 .git 不算', async () => {
const files = {
'.git/HEAD': 'ref: refs/heads/main',
[TASKS]: '',
[`${WORKTREE}/.git`]: 'gitdir: /repo/.git/worktrees/agent-aa35211c29ea9e068',
[`${WORKTREE}/${TASKS}`]: '',
}
expect(await kept(files, [TASKS, `${WORKTREE}/${TASKS}`], false)).toEqual([TASKS])
})
it('嵌套的仓库(子目录里的 .git 是个目录)也跳过', async () => {
expect(await kept({ 'vendor/lib/.git/HEAD': '', 'vendor/lib/a.py': '', 'vendor/b.py': '' }, ['vendor/lib/a.py', 'vendor/b.py'])).toEqual(['vendor/b.py'])
})
it('.gitignore 忽略的目录跳过;每层的规则相对那一层,取反照 gitignore 写法;.ignore 也读', async () => {
const files = {
'.gitignore': '.claude/worktrees/\nbuild/\n*.log\n!keep.log\n',
'pkg/.gitignore': 'generated/\n',
'tools/.ignore': 'scratch.py\n',
[`${WORKTREE}/${TASKS}`]: '',
}
const paths = [
TASKS,
`${WORKTREE}/${TASKS}`,
'build/out/a.py',
'src/build.py',
'pkg/generated/a.py',
'generated/a.py',
'logs/x.log',
'logs/keep.log',
'tools/scratch.py',
'tools/run.py',
]
expect(await kept(files, paths)).toEqual([TASKS, 'src/build.py', 'generated/a.py', 'logs/keep.log', 'tools/run.py'])
})
it('search.useIgnoreFiles 关着时不读忽略文件,嵌套的 checkout 照样跳过', async () => {
const files = { '.gitignore': 'build/\n', 'sub/.git': 'gitdir: x', 'sub/a.py': '' }
expect(await kept(files, ['build/a.py', 'sub/a.py'], false)).toEqual(['build/a.py'])
})
it('同一个目录只查一次', async () => {
const folder = memoryFolder({ 'a/b/c.py': '', 'a/b/d.py': '' })
const scope = folderScope(folder, true)
await scope.skips('a/b/c.py')
await scope.skips('a/b/d.py')
expect(folder.asked.filter(path => path === 'a/b/.git')).toEqual(['a/b/.git'])
expect(folder.asked.filter(path => path === 'a/.gitignore')).toEqual(['a/.gitignore'])
})
})
+77
View File
@@ -0,0 +1,77 @@
import type { Ignore } from 'ignore'
import ignore from 'ignore'
/** 读一个工作区文件夹里的东西,路径相对这个文件夹、用 / 分隔。 */
export interface FolderReader {
/** 有没有这个文件或目录。 */
exists: (path: string) => Promise<boolean>
/** 文本文件的内容;没有、读不了时没有。 */
read: (path: string) => Promise<string | undefined>
}
/** 和 VS Code 搜索一样读的忽略文件(search.useIgnoreFiles)。 */
const IGNORE_FILES = ['.gitignore', '.ignore']
/** path 所在的各层目录,从工作区文件夹('')往下,不含 path 自己。 */
function directoriesOf(path: string): string[] {
const parts = path.split('/').slice(0, -1)
return ['', ...parts.map((_, i) => parts.slice(0, i + 1).join('/'))]
}
function joined(directory: string, name: string): string {
return directory ? `${directory}/${name}` : name
}
/**
* 工作区文件夹里建索引时还要跳过的文件(files.exclude、search.exclude 之外):
* ① 在自己是一份 checkout 的子目录里(带 `.git` 文件或目录:子仓库、git worktree,例如 `.claude/worktrees/…`)——
* 那是另一份代码的副本,算进来同一个定义会出现两次;工作区文件夹自己的 `.git` 不算。
* ② useIgnoreFiles 开着时(同 VS Code 的 search.useIgnoreFiles,默认开),被路上各层目录的 `.gitignore`、`.ignore` 忽略的:
* 每层的规则按 gitignore 写法、相对那一层目录判断;哪一层忽略了就跳过,下层不能再把上层忽略的加回来。
* 工作区文件夹上面的目录、全局的忽略文件、`.git/info/exclude` 不读(VS Code 的 search.useParentIgnoreFiles、
* search.useGlobalIgnoreFiles 默认也不读)。
* 一个实例记着查过的目录,一次扫描、一个文件改动各用一个。
*/
export function folderScope(reader: FolderReader, useIgnoreFiles: boolean): { skips: (path: string) => Promise<boolean> } {
const checkouts = new Map<string, Promise<boolean>>()
const rules = new Map<string, Promise<Ignore | undefined>>()
const isCheckout = (directory: string): Promise<boolean> => {
let found = checkouts.get(directory)
if (!found) {
found = reader.exists(joined(directory, '.git'))
checkouts.set(directory, found)
}
return found
}
const rulesIn = (directory: string): Promise<Ignore | undefined> => {
let found = rules.get(directory)
if (!found) {
found = Promise.all(IGNORE_FILES.map(name => reader.read(joined(directory, name)))).then((texts) => {
const written = texts.filter((text): text is string => text !== undefined)
return written.length > 0 ? ignore().add(written.join('\n')) : undefined
})
rules.set(directory, found)
}
return found
}
return {
async skips(path) {
const directories = directoriesOf(path)
for (const directory of directories.slice(1)) {
if (await isCheckout(directory))
return true
}
if (!useIgnoreFiles)
return false
for (const directory of directories) {
const rule = await rulesIn(directory)
if (rule?.ignores(directory ? path.slice(directory.length + 1) : path))
return true
}
return false
},
}
}