From 73bbbde01dcdf5da4bca9fa57be44a5cd8ebd60d Mon Sep 17 00:00:00 2001 From: kubi <140750+kubi-dev@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:32:48 +0700 Subject: [PATCH] fix frontmatter scans to respect git excludes (#2462) --- src/commands/frontmatter.ts | 8 ++++ src/core/brain-writer.ts | 15 ++++++- src/core/git-visible-files.ts | 59 ++++++++++++++++++++++++++++ test/brain-writer-walk-prune.test.ts | 46 +++++++++++++++++++++- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 src/core/git-visible-files.ts diff --git a/src/commands/frontmatter.ts b/src/commands/frontmatter.ts index 4e5295a9d..951e35905 100644 --- a/src/commands/frontmatter.ts +++ b/src/commands/frontmatter.ts @@ -30,6 +30,7 @@ import { type AuditReport, type AuditFix, } from '../core/brain-writer.ts'; +import { collectGitVisibleFiles } from '../core/git-visible-files.ts'; import { isSyncable, pruneDir, slugifyPath } from '../core/sync.ts'; export async function runFrontmatter(args: string[]): Promise { @@ -272,6 +273,13 @@ export function collectFiles( if (st.isFile()) { return [target]; } + + const gitFiles = collectGitVisibleFiles(target, (rel) => isSyncable(rel, { strategy: 'markdown' })); + if (gitFiles) { + if (visitDir) visitDir(target); + return gitFiles; + } + const out: string[] = []; const stack = [target]; if (visitDir) visitDir(target); diff --git a/src/core/brain-writer.ts b/src/core/brain-writer.ts index ad17f41b9..a65e6864b 100644 --- a/src/core/brain-writer.ts +++ b/src/core/brain-writer.ts @@ -22,6 +22,7 @@ import { join, relative, resolve, dirname, basename, isAbsolute } from 'path'; import type { BrainEngine } from './engine.ts'; import type { ProgressReporter } from './progress.ts'; import { gbrainPath } from './config.ts'; +import { collectGitVisibleFiles } from './git-visible-files.ts'; import { parseMarkdown, type ParseValidationCode, @@ -579,7 +580,7 @@ function scanOneSource( let ignoredMissingOpen = 0; let interrupted = false; - walkDir(rootResolved, (absPath) => { + const visitFile = (absPath: string): boolean | void => { // Per-file deadline + abort gate. Deadline is the load-bearing // wall-clock bound (sync I/O blocks the event loop so timer-based // AbortSignal.timeout can't fire mid-walk — codex C1). @@ -625,7 +626,17 @@ function scanOneSource( opts.onProgress.tick(50); } return true; - }, opts.visitDir); + }; + + const gitFiles = collectGitVisibleFiles(rootResolved, (rel) => isSyncable(rel, { strategy: 'markdown' })); + if (gitFiles) { + if (opts.visitDir) opts.visitDir(rootResolved); + for (const absPath of gitFiles) { + if (visitFile(absPath) === false) break; + } + } else { + walkDir(rootResolved, visitFile, opts.visitDir); + } if (opts.onProgress) { opts.onProgress.heartbeat(`scanned ${scanned} pages in ${sourceId}`); diff --git a/src/core/git-visible-files.ts b/src/core/git-visible-files.ts new file mode 100644 index 000000000..441147523 --- /dev/null +++ b/src/core/git-visible-files.ts @@ -0,0 +1,59 @@ +import { execFileSync } from 'child_process'; +import { lstatSync } from 'fs'; +import { join } from 'path'; + +/** + * Return files visible to git from `dir`, respecting .gitignore, + * .git/info/exclude, and global git excludes. Returns null when `dir` is not + * inside a git work tree or git is unavailable, so callers can keep their + * existing filesystem-walk fallback. + */ +export function collectGitVisibleFiles( + dir: string, + acceptRelPath: (relPath: string) => boolean, +): string[] | null { + let stdout: string; + try { + stdout = execFileSync( + 'git', + ['-C', dir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + } catch { + return null; + } + + const ignoredTracked = new Set(); + try { + const ignoredStdout = execFileSync( + 'git', + ['-C', dir, 'ls-files', '-ci', '--exclude-standard', '-z'], + { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + for (const rel of ignoredStdout.split('\0')) { + if (rel) ignoredTracked.add(rel); + } + } catch { + // Best effort: older Git or unusual worktrees still get the standard list. + } + + const files: string[] = []; + for (const rel of stdout.split('\0')) { + if (!rel) continue; + if (ignoredTracked.has(rel)) continue; + const normalizedRel = rel.replace(/\\/g, '/'); + if (!acceptRelPath(normalizedRel)) continue; + + const full = join(dir, rel); + let st: ReturnType; + try { + st = lstatSync(full); + } catch { + continue; + } + if (st.isSymbolicLink() || !st.isFile()) continue; + files.push(full); + } + + return files.sort(); +} diff --git a/test/brain-writer-walk-prune.test.ts b/test/brain-writer-walk-prune.test.ts index 4e668f063..1cd3540a0 100644 --- a/test/brain-writer-walk-prune.test.ts +++ b/test/brain-writer-walk-prune.test.ts @@ -19,9 +19,10 @@ */ import { describe, expect, test, beforeAll, afterAll } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; +import { execFileSync } from 'child_process'; import { join } from 'path'; import { tmpdir } from 'os'; -import { walkDir } from '../src/core/brain-writer.ts'; +import { scanBrainSources, walkDir } from '../src/core/brain-writer.ts'; import { collectFiles } from '../src/commands/frontmatter.ts'; let root: string; @@ -148,3 +149,46 @@ describe('collectFiles (frontmatter.ts) — descent-time pruning parity', () => expect(files).toEqual([target]); }); }); + +describe('frontmatter walkers — git-visible file parity', () => { + test('collectFiles respects .git/info/exclude like sync/import', () => { + const repo = mkdtempSync(join(tmpdir(), 'frontmatter-git-visible-')); + try { + execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' }); + mkdirSync(join(repo, 'people'), { recursive: true }); + mkdirSync(join(repo, 'local-skills'), { recursive: true }); + writeFileSync(join(repo, '.git', 'info', 'exclude'), 'local-skills/\n'); + writeFileSync(join(repo, 'people', 'alice.md'), '---\ntitle: Alice\n---\n\nbody\n'); + writeFileSync(join(repo, 'local-skills', 'SKILL.md'), '---\nname: bad\n# malformed frontmatter\n'); + + const files = collectFiles(repo).map((f) => f.replace(repo + '/', '')); + expect(files).toContain('people/alice.md'); + expect(files).not.toContain('local-skills/SKILL.md'); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test('scanBrainSources ignores git-excluded malformed markdown', async () => { + const repo = mkdtempSync(join(tmpdir(), 'frontmatter-audit-git-visible-')); + try { + execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' }); + mkdirSync(join(repo, 'people'), { recursive: true }); + mkdirSync(join(repo, 'local-skills'), { recursive: true }); + writeFileSync(join(repo, '.git', 'info', 'exclude'), 'local-skills/\n'); + writeFileSync(join(repo, 'people', 'alice.md'), '---\ntitle: Alice\n---\n\nbody\n'); + writeFileSync(join(repo, 'local-skills', 'SKILL.md'), '---\nname: bad\n# malformed frontmatter\n'); + + const engine = { + executeRaw: async () => [{ id: 'repo', local_path: repo }], + } as any; + const report = await scanBrainSources(engine, { sourceId: 'repo' }); + + expect(report.total).toBe(0); + expect(report.per_source[0].files_scanned).toBe(1); + expect(report.per_source[0].sample).toEqual([]); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +});