mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix frontmatter scans to respect git excludes (#2462)
This commit is contained in:
@@ -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<void> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string>();
|
||||
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<typeof lstatSync>;
|
||||
try {
|
||||
st = lstatSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isSymbolicLink() || !st.isFile()) continue;
|
||||
files.push(full);
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user