mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* feat(sync): --src-subpath + --exclude for monorepo subdir-source support (#753) Rebased port of PR #774 onto current master. A single git repo can hold N logical sources at subdirectories: git operations run at the discovered repo root (git rev-parse --show-toplevel — worktrees and submodules resolve natively) while file walking, imports, deletes and renames are scoped to the subpath. Passing the subdirectory directly as the repo path (auto-discovery) works through the same code path. Path-containment guards (the point of the feature): - NAV-1/NAV-2: the realpath-resolved scope must live inside the realpath-resolved git root — ../-traversal and symlinked subdirs pointing outside the repo are rejected before any git op. - NAV-1 TOCTOU: per-file realpath re-validation during the incremental import drain and rename reimport; symlink-escape files are recorded as failures (fail-closed — the bookmark cannot advance past an escape). - NAV-4: an --exclude set that filters out every candidate warns loudly. Scoped syncs use git-root-relative slugs + source_path in BOTH the full and incremental paths (runImport gains slugRoot), fixing the original PR's full/incremental slug divergence in the auto-discovery flow. --exclude matches scope-relative paths in both paths; exclusion never deletes previously-imported pages. The full-sync delete reconcile is scope-restricted and relativizes against the slug base so a healthy scoped source can't trip the #2828 mass-delete valve. .gitignore management resolves to the git root at every call site. Preserves all master-side sync work since the original branch: the #2828 mass-delete safety valve, #1794 resumable checkpoints + pinned targets, #1950 stall watchdog, #2335 heartbeat bump, #1970 bookmark reachability, and the git-ls-files walker (#2315/#2462/#2678, whose symlink/cycle hardening is untouched). Co-authored-by: Jeremy Knows <jeremy@veefriends.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: listEverCommittedPaths uses gitContextRoot after #753 root-triple refactor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Jeremy Knows <jeremy@veefriends.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
/**
|
|
* #753/#774 — --src-subpath + --exclude monorepo subdir-source support.
|
|
*
|
|
* A single git repo can hold N logical sources at subdirectories (wiki/,
|
|
* memory/, ...). `gbrain sync --src-subpath wiki` (or passing the subdir
|
|
* directly as the repo path) scopes file walking + imports to the subdir
|
|
* while git operations (pull, rev-parse, diff) run at the discovered repo
|
|
* root. Slugs stay git-root-relative (`wiki/page1`) so full and incremental
|
|
* syncs of the same scope agree.
|
|
*
|
|
* Security pins (the point of the feature's guards):
|
|
* NAV-1/NAV-2 — `--src-subpath ../escape` and a symlinked subdir pointing
|
|
* outside the repo are realpath-checked and rejected before any git op.
|
|
* NAV-1 TOCTOU — per-file realpath checks during the incremental import
|
|
* drain (see the isPathSafe guard in sync.ts's importOnePath).
|
|
* NAV-4 — an --exclude set that filters out everything warns loudly.
|
|
*
|
|
* Regression note: against pre-#774 master every subdir test fails with
|
|
* "Not a git repository".
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
// Helper: create a minimal valid markdown file
|
|
function mdPage(title: string, body = 'Content.'): string {
|
|
return `---\ntype: note\ntitle: ${title}\n---\n\n${body}`;
|
|
}
|
|
|
|
// Helper: init a git repo with author identity
|
|
function gitInit(dir: string): void {
|
|
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
}
|
|
|
|
// Helper: stage + commit everything in a git repo
|
|
function gitCommit(dir: string, msg = 'initial'): void {
|
|
execSync('git add -A', { cwd: dir, stdio: 'pipe' });
|
|
execSync(`git commit -m "${msg}"`, { cwd: dir, stdio: 'pipe' });
|
|
}
|
|
|
|
describe('sync monorepo subdir-source support (#753/#774)', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-monorepo-'));
|
|
gitInit(repoPath);
|
|
mkdirSync(join(repoPath, 'wiki'), { recursive: true });
|
|
mkdirSync(join(repoPath, 'memory'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'wiki', 'page1.md'), mdPage('Wiki Page 1'));
|
|
writeFileSync(join(repoPath, 'wiki', 'page2.md'), mdPage('Wiki Page 2'));
|
|
writeFileSync(join(repoPath, 'memory', 'note1.md'), mdPage('Memory Note 1'));
|
|
writeFileSync(join(repoPath, 'memory', 'note2.md'), mdPage('Memory Note 2'));
|
|
gitCommit(repoPath);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Back-compat: sync at git root (no srcSubpath) still works
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('back-compat: sync at git root without srcSubpath imports all files', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(4); // wiki/page1 + wiki/page2 + memory/note1 + memory/note2
|
|
// Slug shape unchanged for git-root syncs.
|
|
expect(await engine.getPage('wiki/page1')).not.toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Auto-discovery: repoPath IS a non-git-root subdir
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('auto-discovery: repoPath is a git subdir — discoverGitRoot succeeds', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Pass the wiki/ subdir directly as repoPath (no explicit srcSubpath).
|
|
// Pre-#774: throws "Not a git repository".
|
|
// Post-#774: gitContextRoot = repo root, syncScopeRoot = wiki/.
|
|
const result = await performSync(engine, {
|
|
repoPath: join(repoPath, 'wiki'),
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(2); // only wiki/page1 + wiki/page2
|
|
// Slugs are git-root-relative in BOTH spellings (subdir repoPath and
|
|
// --src-subpath) so full and incremental syncs of the same scope agree.
|
|
expect(await engine.getPage('wiki/page1')).not.toBeNull();
|
|
expect(await engine.getPage('page1')).toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// srcSubpath explicit flag: scope to subdir from git root
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('--src-subpath wiki: only wiki/ files are imported', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(2);
|
|
// Verify the imported slugs are from wiki/ only (git-root-relative)
|
|
const wikiPage = await engine.getPage('wiki/page1');
|
|
expect(wikiPage).not.toBeNull();
|
|
const memoryPage = await engine.getPage('memory/note1');
|
|
expect(memoryPage).toBeNull();
|
|
});
|
|
|
|
test('--src-subpath memory: only memory/ files are imported', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'memory',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(2);
|
|
const memoryPage = await engine.getPage('memory/note1');
|
|
expect(memoryPage).not.toBeNull();
|
|
const wikiPage = await engine.getPage('wiki/page1');
|
|
expect(wikiPage).toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Two sources in one repo, scoped independently
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('2 sources in 1 repo: sync each scope independently, no cross-contamination', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
|
|
const wikiResult = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(wikiResult.status).toBe('first_sync');
|
|
expect(wikiResult.added).toBe(2);
|
|
|
|
// Reset only page state, keep the engine connected for second sync
|
|
await resetPgliteState(engine);
|
|
|
|
const memResult = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'memory',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(memResult.status).toBe('first_sync');
|
|
expect(memResult.added).toBe(2);
|
|
|
|
// After memory sync, memory pages exist and wiki pages don't
|
|
expect(await engine.getPage('memory/note1')).not.toBeNull();
|
|
expect(await engine.getPage('wiki/page1')).toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Incremental sync respects the scope (the gap #774 left untested)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('incremental --src-subpath: only in-scope diff paths are processed', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
|
|
const first = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(first.status).toBe('first_sync');
|
|
|
|
// Commit 2: touch one file in each scope + add one wiki file.
|
|
writeFileSync(join(repoPath, 'wiki', 'page1.md'), mdPage('Wiki Page 1', 'Updated.'));
|
|
writeFileSync(join(repoPath, 'memory', 'note1.md'), mdPage('Memory Note 1', 'Updated.'));
|
|
writeFileSync(join(repoPath, 'wiki', 'page3.md'), mdPage('Wiki Page 3'));
|
|
gitCommit(repoPath, 'second');
|
|
|
|
const second = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
expect(second.status).toBe('synced');
|
|
expect(second.added).toBe(1); // wiki/page3 only — memory change filtered by scope
|
|
expect(second.modified).toBe(1); // wiki/page1
|
|
expect(await engine.getPage('wiki/page3')).not.toBeNull();
|
|
expect(await engine.getPage('memory/note1')).toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Path-traversal sanitization (NAV-1 + NAV-2)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('path-traversal: --src-subpath ../escape is rejected before any git op', async () => {
|
|
const outsideDir = mkdtempSync(join(tmpdir(), 'gbrain-escape-'));
|
|
try {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await expect(
|
|
performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: '../' + outsideDir.split('/').pop(),
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
}),
|
|
).rejects.toThrow(/outside git repo|does not exist/i);
|
|
} finally {
|
|
rmSync(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('path-traversal: symlink subdir pointing outside repo is rejected (NAV-1 TOCTOU)', async () => {
|
|
const outsideDir = mkdtempSync(join(tmpdir(), 'gbrain-sym-target-'));
|
|
writeFileSync(join(outsideDir, 'secret.md'), mdPage('Secret'));
|
|
const symlinkPath = join(repoPath, 'symlink-escape');
|
|
try {
|
|
symlinkSync(outsideDir, symlinkPath);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await expect(
|
|
performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'symlink-escape',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
}),
|
|
).rejects.toThrow(/outside git repo/i);
|
|
} finally {
|
|
rmSync(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('path-traversal: absolute --src-subpath outside the repo is rejected', async () => {
|
|
const outsideDir = mkdtempSync(join(tmpdir(), 'gbrain-abs-escape-'));
|
|
writeFileSync(join(outsideDir, 'secret.md'), mdPage('Secret'));
|
|
try {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// path.join(repoPath, '/abs/path') keeps the traversal relative, but a
|
|
// crafted subpath can still resolve outside via ..-segments; both are
|
|
// caught by the same realpath containment check.
|
|
await expect(
|
|
performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: join('..', '..', outsideDir.slice(1)),
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
}),
|
|
).rejects.toThrow(/outside git repo|does not exist/i);
|
|
} finally {
|
|
rmSync(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// --exclude: repeatable glob pattern flag
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('--exclude: single pattern excludes matching files from full sync', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Sync wiki/ but exclude page2.md (patterns are scope-relative)
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
exclude: ['page2.md'],
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(1); // only page1 (page2 excluded)
|
|
expect(await engine.getPage('wiki/page1')).not.toBeNull();
|
|
expect(await engine.getPage('wiki/page2')).toBeNull();
|
|
});
|
|
|
|
test('--exclude: glob pattern with wildcard', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Exclude all files matching *2.md
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
exclude: ['*2.md'],
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
expect(result.added).toBe(1); // only page1 (page2 excluded by *2.md)
|
|
});
|
|
|
|
test('--exclude applies to the incremental path too', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const first = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
expect(first.status).toBe('first_sync');
|
|
|
|
writeFileSync(join(repoPath, 'wiki', 'draft-a.md'), mdPage('Draft A'));
|
|
writeFileSync(join(repoPath, 'wiki', 'page3.md'), mdPage('Wiki Page 3'));
|
|
gitCommit(repoPath, 'drafts');
|
|
|
|
const second = await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
exclude: ['draft-*.md'],
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
expect(second.status).toBe('synced');
|
|
expect(second.added).toBe(1); // page3 only; draft-a excluded
|
|
expect(await engine.getPage('wiki/page3')).not.toBeNull();
|
|
expect(await engine.getPage('wiki/draft-a')).toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// --exclude '**/*' emits warning (NAV-4)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
test('--exclude **/* emits warning when all files are excluded (NAV-4)', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const warnMessages: string[] = [];
|
|
const origWarn = console.warn;
|
|
console.warn = (...args: unknown[]) => {
|
|
warnMessages.push(args.join(' '));
|
|
origWarn(...args);
|
|
};
|
|
try {
|
|
await performSync(engine, {
|
|
repoPath,
|
|
srcSubpath: 'wiki',
|
|
exclude: ['**/*'],
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
} finally {
|
|
console.warn = origWarn;
|
|
}
|
|
const hasExcludeWarn = warnMessages.some(m => m.includes('--exclude') || m.includes('No files matched'));
|
|
expect(hasExcludeWarn).toBe(true);
|
|
});
|
|
});
|