Files
gbrain/test/sync-monorepo.test.ts
T
f72de97943 feat(sync): --src-subpath + --exclude for monorepo subdir-source support (#753, supersedes #774) (#2942)
* 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>
2026-07-17 15:43:09 -07:00

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);
});
});