🐛 fix(vscode): 代码阅读工作区索引跳过嵌套的 checkout 和忽略文件忽略的,任务名不再查到两处
This commit is contained in:
1 parent
de355661f0
commit
113b02657c
7 files changed
+202
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user