Files
gbrain/test/storage-sync.test.ts

241 lines
9.1 KiB
TypeScript

/**
* Tests for sync.ts manageGitignore() — step 8 of v0.22.3 storage tiering.
*
* Issue #2: function was defined but never invoked. Now wired into runSync
* after a successful sync (skips on dry_run / blocked_by_failures / failure).
*
* Tests cover: happy path, idempotency, GBRAIN_NO_GITIGNORE escape hatch,
* submodule detection, write-error graceful degradation, and the "no
* config — no-op" path.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, chmodSync, symlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { manageGitignore } from '../src/commands/sync.ts';
import { __resetMissingStorageWarning } from '../src/core/storage-config.ts';
let tmp: string;
let warnings: string[];
let originalWarn: typeof console.warn;
let originalEnv: string | undefined;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'gbrain-mgi-test-'));
__resetMissingStorageWarning();
warnings = [];
originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '));
};
originalEnv = process.env.GBRAIN_NO_GITIGNORE;
delete process.env.GBRAIN_NO_GITIGNORE;
});
afterEach(() => {
console.warn = originalWarn;
if (originalEnv === undefined) delete process.env.GBRAIN_NO_GITIGNORE;
else process.env.GBRAIN_NO_GITIGNORE = originalEnv;
// Restore permissions for cleanup.
try {
chmodSync(tmp, 0o755);
} catch {
/* ignore */
}
rmSync(tmp, { recursive: true, force: true });
});
function writeStorageConfig(): void {
writeFileSync(
join(tmp, 'gbrain.yml'),
`storage:
db_tracked:
- people/
db_only:
- media/x/
- media/articles/
`,
);
}
describe('manageGitignore', () => {
test('no-op when gbrain.yml is absent', () => {
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
expect(warnings).toEqual([]);
});
test('no-op when storage config has empty db_only', () => {
writeFileSync(
join(tmp, 'gbrain.yml'),
`storage:
db_tracked:
- people/
db_only: []
`,
);
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
});
test('appends db_only directories to .gitignore — happy path', () => {
writeStorageConfig();
manageGitignore(tmp);
const content = readFileSync(join(tmp, '.gitignore'), 'utf-8');
expect(content).toContain('# Auto-managed by gbrain');
expect(content).toContain('media/x/');
expect(content).toContain('media/articles/');
});
test('idempotent — running twice does NOT duplicate entries', () => {
writeStorageConfig();
manageGitignore(tmp);
manageGitignore(tmp);
const content = readFileSync(join(tmp, '.gitignore'), 'utf-8');
const xCount = (content.match(/^media\/x\/$/gm) || []).length;
const articlesCount = (content.match(/^media\/articles\/$/gm) || []).length;
expect(xCount).toBe(1);
expect(articlesCount).toBe(1);
});
test('preserves user-written .gitignore entries', () => {
writeStorageConfig();
writeFileSync(join(tmp, '.gitignore'), '# my own rules\n*.swp\nnode_modules/\n');
manageGitignore(tmp);
const content = readFileSync(join(tmp, '.gitignore'), 'utf-8');
expect(content).toContain('# my own rules');
expect(content).toContain('*.swp');
expect(content).toContain('node_modules/');
expect(content).toContain('media/x/');
});
test('GBRAIN_NO_GITIGNORE=1 skips entirely', () => {
writeStorageConfig();
process.env.GBRAIN_NO_GITIGNORE = '1';
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
});
test('skips with actionable warning when repo is a git submodule', () => {
writeStorageConfig();
// Submodule: .git is a file containing `gitdir: ...` instead of a directory.
writeFileSync(join(tmp, '.git'), 'gitdir: ../.git/modules/sub\n');
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
expect(warnings.some((w) => /submodule/.test(w))).toBe(true);
});
test('proceeds when .git is a directory (regular repo)', () => {
writeStorageConfig();
mkdirSync(join(tmp, '.git'));
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(true);
expect(warnings.filter((w) => /submodule/.test(w))).toEqual([]);
});
test('warns and skips when .gitignore write fails (read-only filesystem simulation)', () => {
writeStorageConfig();
// Create a .gitignore as a directory — write to that path will fail with EISDIR.
mkdirSync(join(tmp, '.gitignore'));
manageGitignore(tmp);
expect(warnings.some((w) => /Could not (read|update)/.test(w))).toBe(true);
});
// ────────────────────────────────────────────────────────────────
// Worktree vs submodule discrimination (closes #889)
// ────────────────────────────────────────────────────────────────
test('REGRESSION: submodule with relative gitdir/modules/ → skip (D49 contract)', () => {
writeStorageConfig();
writeFileSync(join(tmp, '.git'), 'gitdir: ../.git/modules/sub\n');
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
expect(warnings.some((w) => /submodule/.test(w))).toBe(true);
});
test('absorbed submodule with absolute gitdir/modules/ → skip (closes edge case)', () => {
writeStorageConfig();
// After `git submodule absorbgitdirs`, the gitdir path becomes absolute.
writeFileSync(join(tmp, '.git'), 'gitdir: /home/user/parent/.git/modules/sub\n');
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(false);
expect(warnings.some((w) => /submodule/.test(w))).toBe(true);
});
test('CRITICAL: worktree with absolute gitdir/worktrees/ → MANAGE (closes #889)', () => {
writeStorageConfig();
writeFileSync(join(tmp, '.git'), 'gitdir: /home/user/repo/.git/worktrees/feature-branch\n');
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(true);
expect(warnings.filter((w) => /submodule/.test(w))).toEqual([]);
});
test('worktree with relative gitdir/worktrees/ → MANAGE', () => {
writeStorageConfig();
writeFileSync(join(tmp, '.git'), 'gitdir: ../.git/worktrees/feature-branch\n');
manageGitignore(tmp);
expect(existsSync(join(tmp, '.gitignore'))).toBe(true);
expect(warnings.filter((w) => /submodule/.test(w))).toEqual([]);
});
test('malformed .git file (no gitdir: prefix) → MANAGE (preserves catch behavior)', () => {
writeStorageConfig();
writeFileSync(join(tmp, '.git'), 'garbage content\n');
manageGitignore(tmp);
// No gitdir prefix → not a submodule → MANAGE.
expect(existsSync(join(tmp, '.gitignore'))).toBe(true);
expect(warnings.filter((w) => /submodule/.test(w))).toEqual([]);
});
});
// #2788: collector-output vs db_only collision warning at .gitignore-write time.
describe('manageGitignore collector/db_only collision warning (#2788)', () => {
let recipesDir: string;
const SECRET_ENV_KEYS = ['CLAWVISOR_URL', 'CLAWVISOR_AGENT_TOKEN', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'];
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
recipesDir = mkdtempSync(join(tmpdir(), 'gbrain-recipes-'));
savedEnv = {};
// Make embedded recipes (calendar-to-brain) deterministically unconfigured
// and point recipe discovery at our temp dir.
for (const k of [...SECRET_ENV_KEYS, 'GBRAIN_RECIPES_DIR', 'GBRAIN_HOME']) {
savedEnv[k] = process.env[k];
}
for (const k of SECRET_ENV_KEYS) delete process.env[k];
process.env.GBRAIN_RECIPES_DIR = recipesDir;
process.env.GBRAIN_HOME = recipesDir; // heartbeat reads stay hermetic
writeFileSync(
join(recipesDir, 'test-collector.md'),
'---\nid: test-collector\nname: Test Collector\noutput_paths:\n - media/x/inbox/\n---\nBody.\n',
);
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
rmSync(recipesDir, { recursive: true, force: true });
});
test('warns when a configured collector output dir sits inside a db_only path', () => {
writeStorageConfig(); // db_only includes media/x/
manageGitignore(tmp);
const hit = warnings.find((w) => /collector 'test-collector'/.test(w));
expect(hit).toBeDefined();
expect(hit).toContain("'media/x/inbox/'");
expect(hit).toContain("db_only path 'media/x/'");
expect(hit).toContain('silently skip');
// .gitignore management still happens — the warning never blocks it.
expect(existsSync(join(tmp, '.gitignore'))).toBe(true);
});
test('no warning when the collector writes outside every db_only path', () => {
writeFileSync(join(tmp, 'gbrain.yml'), 'storage:\n db_only:\n - archive/\n');
manageGitignore(tmp);
expect(warnings.filter((w) => /collector 'test-collector'/.test(w))).toEqual([]);
});
});