mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
241 lines
9.1 KiB
TypeScript
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([]);
|
|
});
|
|
});
|