mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.48.0 feat(durability): auto-harden brain repos for git durability on PAT+URL (#2241)
* feat(git): divergence-safe pull, push-probe, default-branch detection for brain durability Add GIT_ENV_AUTH + divergenceSafePull (skip-on-dirty, conflict-abort-clean, never-mid-rebase), detectDefaultBranch, pushProbe, and an env-gated GBRAIN_GIT_ALLOW_FILE_TRANSPORT escape hatch. Export GIT_ENV. pullRepo's --ff-only contract is unchanged. * feat(durability): brain-repo hardening core (hook, helper, cron, PAT, AGENTS rules) hardenBrainRepo/unhardenBrainRepo: local untracked post-commit hook + committed brain-commit-push.sh (one shared push-retry template), repo-scoped credential with existing-helper reuse, push-probe verify, active-resolver-file rules with taxonomy from _brain-filing-rules.json, minimal DB-free pull cron. PAT redaction via redactSecretsInText. * feat(sources): harden/pull/unharden commands + auto-harden on add --url sources harden/pull/unharden subcommands; --pat-file/--no-harden on add; auto-harden managed clones on add; unharden-before-remove. cli.ts pre-connect early-exit for DB-free 'sources pull --path' (the cron entry, never opens PGLite). * test(durability): unit + integration coverage for brain-repo durability git helpers, core harden/unharden, hook+helper E2E (real background push), cron generators. 41 tests across 4 files. * chore: bump version and changelog (v0.42.48.0) Brain-repo git durability: auto-harden a brain's working tree (local auto-push hook, committed commit-push helper, always-on agent rules, DB-free pull cron, repo-scoped credential, push-probe verify) the moment gbrain gets a PAT + URL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sources): route harden exit code through setCliExitVerdict A raw process.exitCode write is zeroed by the owned-verdict flush-exit (#2084 PGLite-Emscripten pollution defense); cli-exit-verdict-pin guard caught it. Use setCliExitVerdict(3) so 'sources harden' actually reports needs-attention to cron/automation. * docs: document brain-repo durability (KEY_FILES + multi-source guide) KEY_FILES: extend git-remote.ts entry (divergenceSafePull, pushProbe, detectDefaultBranch, GIT_ENV_AUTH, GBRAIN_GIT_ALLOW_FILE_TRANSPORT) + add brain-repo-durability.ts/sources-harden.ts entry. multi-source-brains.md: add a Durability (auto-harden) how-to covering sources harden/pull/unharden, --pat-file, the guarantees, and the security posture. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9d88680a51
commit
7ea92d602c
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* End-to-end durability hook + helper (v0.42.44): the generated bash actually
|
||||
* pushes. Real git, local bare remote. Validates the D13 guarantee (helper),
|
||||
* the D9 self-contained local hook, and the D7 "one push-retry template" claim
|
||||
* (the hook works even with the committed helper deleted).
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { hardenBrainRepo } from '../src/core/brain-repo-durability.ts';
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
function originHead(bare: string): string {
|
||||
return git(bare, 'rev-parse', 'refs/heads/main');
|
||||
}
|
||||
async function waitForOrigin(bare: string, expectSha: string, ms = 8000): Promise<boolean> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
try { if (originHead(bare) === expectSha) return true; } catch { /* */ }
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let root: string, work: string, bare: string;
|
||||
let oldHome: string | undefined, oldGbrainHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'bdh-'));
|
||||
oldHome = process.env.HOME; oldGbrainHome = process.env.GBRAIN_HOME;
|
||||
process.env.HOME = mkdtempSync(join(root, 'home-'));
|
||||
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
|
||||
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
|
||||
bare = mkdtempSync(join(root, 'origin-')) + '.git';
|
||||
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
|
||||
work = mkdtempSync(join(root, 'work-'));
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
|
||||
git(work, 'config', 'user.email', 't@t.t'); git(work, 'config', 'user.name', 'tester');
|
||||
writeFileSync(join(work, 'README.md'), 'init\n');
|
||||
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
|
||||
git(work, 'remote', 'set-head', 'origin', 'main');
|
||||
await hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: 'ghp_x', installCron: false });
|
||||
});
|
||||
afterEach(() => {
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = oldGbrainHome;
|
||||
delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('brain-commit-push.sh (D13 guarantee)', () => {
|
||||
test('add → commit → push lands on origin', () => {
|
||||
mkdirSync(join(work, 'people'), { recursive: true });
|
||||
writeFileSync(join(work, 'people', 'alice.md'), '# alice\n');
|
||||
// helper requires explicit path; stages people/alice.md
|
||||
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'add alice', 'people/alice.md'], {
|
||||
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
||||
});
|
||||
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
|
||||
// origin actually has the file
|
||||
const verify = mkdtempSync(join(root, 'verify-'));
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore' });
|
||||
expect(existsSync(join(verify, 'people', 'alice.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses success when the push cannot land (exit non-zero)', () => {
|
||||
git(work, 'remote', 'set-url', 'origin', join(root, 'gone.git'));
|
||||
writeFileSync(join(work, 'x.md'), 'x\n');
|
||||
let code = 0;
|
||||
try {
|
||||
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg', 'x.md'], {
|
||||
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
||||
});
|
||||
} catch (e: any) { code = e.status ?? 1; }
|
||||
expect(code).not.toBe(0); // committed but push failed → loud failure
|
||||
});
|
||||
|
||||
test('refuses a blind add (no explicit path)', () => {
|
||||
let code = 0;
|
||||
try {
|
||||
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg'], {
|
||||
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
|
||||
});
|
||||
} catch (e: any) { code = e.status ?? 1; }
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-commit hook (D9 local, D7 self-contained)', () => {
|
||||
test('a direct commit auto-pushes in the background', async () => {
|
||||
writeFileSync(join(work, 'note.md'), 'note\n');
|
||||
git(work, 'add', 'note.md'); git(work, 'commit', '-qm', 'note'); // fires .git/hooks/post-commit
|
||||
const head = git(work, 'rev-parse', 'HEAD');
|
||||
expect(await waitForOrigin(bare, head)).toBe(true);
|
||||
});
|
||||
|
||||
test('the hook works even with the committed helper deleted (self-contained)', async () => {
|
||||
rmSync(join(work, 'scripts', 'brain-commit-push.sh'));
|
||||
git(work, 'add', '-A'); git(work, 'commit', '-qm', 'remove helper');
|
||||
const head = git(work, 'rev-parse', 'HEAD');
|
||||
expect(await waitForOrigin(bare, head)).toBe(true);
|
||||
});
|
||||
|
||||
test('logs a clear LOCAL-ONLY line when origin is unreachable', async () => {
|
||||
git(work, 'remote', 'set-url', 'origin', join(root, 'gone2.git'));
|
||||
writeFileSync(join(work, 'orphan.md'), 'o\n');
|
||||
git(work, 'add', 'orphan.md'); git(work, 'commit', '-qm', 'orphan');
|
||||
const log = join(process.env.GBRAIN_HOME!, 'brain-push.log');
|
||||
const deadline = Date.now() + 8000;
|
||||
let found = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(log) && readFileSync(log, 'utf-8').includes('NEEDS ATTENTION')) { found = true; break; }
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
}
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* brain-repo-durability core (v0.42.44): hardenBrainRepo / unhardenBrainRepo /
|
||||
* acceptPat. Real git against a local bare remote. HOME + GBRAIN_HOME are
|
||||
* redirected to a tmp dir; installCron:false so the suite never touches launchd.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
hardenBrainRepo, unhardenBrainRepo, acceptPat,
|
||||
} from '../src/core/brain-repo-durability.ts';
|
||||
|
||||
const PAT = 'ghp_TESTSECRETTOKEN0123456789abcdef';
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
function commitCount(work: string): number {
|
||||
return parseInt(git(work, 'rev-list', '--count', 'HEAD'), 10);
|
||||
}
|
||||
/** git config read that returns '' instead of throwing when the key is unset. */
|
||||
function cfg(work: string, key: string): string {
|
||||
try { return git(work, 'config', '--local', '--get', key); } catch { return ''; }
|
||||
}
|
||||
|
||||
let root: string;
|
||||
let work: string;
|
||||
let bare: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldGbrainHome: string | undefined;
|
||||
|
||||
function makePair(): void {
|
||||
bare = mkdtempSync(join(root, 'origin-')) + '.git';
|
||||
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
|
||||
work = mkdtempSync(join(root, 'work-'));
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
|
||||
git(work, 'config', 'user.email', 't@t.t');
|
||||
git(work, 'config', 'user.name', 'tester');
|
||||
writeFileSync(join(work, 'README.md'), 'init\n');
|
||||
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
|
||||
try { git(work, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ }
|
||||
}
|
||||
|
||||
async function harden(extra: Record<string, unknown> = {}) {
|
||||
return hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: PAT, installCron: false, ...extra });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'brd-'));
|
||||
oldHome = process.env.HOME; oldGbrainHome = process.env.GBRAIN_HOME;
|
||||
process.env.HOME = mkdtempSync(join(root, 'home-'));
|
||||
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
|
||||
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
|
||||
makePair();
|
||||
});
|
||||
afterEach(() => {
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = oldGbrainHome;
|
||||
delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('hardenBrainRepo', () => {
|
||||
test('installs hook (local, untracked, +x), helper, and AGENTS rules', async () => {
|
||||
const r = await harden();
|
||||
// hook
|
||||
const hookPath = join(work, '.git', 'hooks', 'post-commit');
|
||||
expect(existsSync(hookPath)).toBe(true);
|
||||
expect(readFileSync(hookPath, 'utf-8')).toContain('post-commit hook');
|
||||
expect(statSync(hookPath).mode & 0o111).toBeTruthy(); // executable
|
||||
// helper (committed, +x)
|
||||
const helperPath = join(work, 'scripts', 'brain-commit-push.sh');
|
||||
expect(existsSync(helperPath)).toBe(true);
|
||||
expect(statSync(helperPath).mode & 0o111).toBeTruthy();
|
||||
// AGENTS.md with managed block + taxonomy
|
||||
const agents = readFileSync(join(work, 'AGENTS.md'), 'utf-8');
|
||||
expect(agents).toContain('BEGIN gbrain-brain-durability');
|
||||
expect(agents).toContain('people/');
|
||||
expect(agents).toContain('brain-commit-push.sh');
|
||||
// verify pushed scaffolding → clean against origin
|
||||
expect(r.clean_against_origin).toBe(true);
|
||||
expect(r.needs_attention).toEqual([]);
|
||||
});
|
||||
|
||||
test('is idempotent — second run adds NO new commit', async () => {
|
||||
await harden();
|
||||
const after1 = commitCount(work);
|
||||
const r2 = await harden();
|
||||
expect(commitCount(work)).toBe(after1); // no churn
|
||||
// every step is ok/skipped on the second pass (nothing left to fix)
|
||||
expect(r2.steps.every(s => s.status === 'ok' || s.status === 'skipped')).toBe(true);
|
||||
});
|
||||
|
||||
test('the post-commit hook is UNTRACKED (never committed)', async () => {
|
||||
await harden();
|
||||
const tracked = git(work, 'ls-files');
|
||||
expect(tracked.includes('post-commit')).toBe(false);
|
||||
expect(tracked).toContain('scripts/brain-commit-push.sh'); // helper IS tracked
|
||||
});
|
||||
|
||||
test('D3 — patches RESOLVER.md when it exists, not AGENTS.md', async () => {
|
||||
writeFileSync(join(work, 'RESOLVER.md'), '# my resolver\n\nuser content\n');
|
||||
git(work, 'add', 'RESOLVER.md'); git(work, 'commit', '-qm', 'resolver');
|
||||
await harden();
|
||||
expect(readFileSync(join(work, 'RESOLVER.md'), 'utf-8')).toContain('BEGIN gbrain-brain-durability');
|
||||
expect(existsSync(join(work, 'AGENTS.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('AGENTS block patch preserves user content above and below', async () => {
|
||||
writeFileSync(join(work, 'AGENTS.md'), '# Top\n\nkeep above\n\n## footer\nkeep below\n');
|
||||
git(work, 'add', 'AGENTS.md'); git(work, 'commit', '-qm', 'agents');
|
||||
await harden();
|
||||
const body = readFileSync(join(work, 'AGENTS.md'), 'utf-8');
|
||||
expect(body).toContain('keep above');
|
||||
expect(body).toContain('keep below');
|
||||
expect(body).toContain('BEGIN gbrain-brain-durability');
|
||||
// patch-in-place: exactly one managed block
|
||||
expect(body.split('BEGIN gbrain-brain-durability').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
test('D11 — writes a repo-scoped credential (0600 store, local config, ownership key)', async () => {
|
||||
await harden();
|
||||
const store = join(process.env.GBRAIN_HOME!, 'git-credentials');
|
||||
expect(existsSync(store)).toBe(true);
|
||||
expect(statSync(store).mode & 0o077).toBe(0); // not group/other readable
|
||||
expect(git(work, 'config', '--local', '--get', 'credential.helper')).toContain('store --file');
|
||||
expect(cfg(work, 'gbrain.durability.managedcredential')).toBe('true');
|
||||
});
|
||||
|
||||
test('D11 — reuses an existing credential.helper (no plaintext store written)', async () => {
|
||||
git(work, 'config', 'credential.helper', 'osxkeychain');
|
||||
await harden();
|
||||
const store = join(process.env.GBRAIN_HOME!, 'git-credentials');
|
||||
expect(existsSync(store)).toBe(false);
|
||||
expect(git(work, 'config', '--local', '--get', 'credential.helper')).toBe('osxkeychain');
|
||||
});
|
||||
|
||||
test('PAT never appears in the serialized report', async () => {
|
||||
const r = await harden();
|
||||
expect(JSON.stringify(r).includes(PAT)).toBe(false);
|
||||
});
|
||||
|
||||
test('detached HEAD → pull step needs_attention (refuses to push to a wrong ref)', async () => {
|
||||
const sha = git(work, 'rev-parse', 'HEAD');
|
||||
git(work, 'checkout', '-q', sha); // detached
|
||||
const r = await harden({ verify: false });
|
||||
const pull = r.steps.find(s => s.step === 'pull');
|
||||
expect(pull?.status).toBe('needs_attention');
|
||||
});
|
||||
|
||||
test('D10 — verify reports needs_attention when push-probe fails (read-only/unreachable)', async () => {
|
||||
git(work, 'remote', 'set-url', 'origin', join(root, 'unreachable.git'));
|
||||
const r = await harden();
|
||||
const verify = r.steps.find(s => s.step === 'verify');
|
||||
expect(verify?.status).toBe('needs_attention');
|
||||
expect(r.clean_against_origin).toBe(false);
|
||||
expect(r.needs_attention.length).toBeGreaterThan(0);
|
||||
// No scaffolding commit when we can't confirm a push.
|
||||
expect(r.steps.find(s => s.step === 'commit')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('dry-run makes no commit and writes no files', async () => {
|
||||
const before = commitCount(work);
|
||||
await harden({ dryRun: true });
|
||||
expect(commitCount(work)).toBe(before);
|
||||
expect(existsSync(join(work, 'scripts', 'brain-commit-push.sh'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhardenBrainRepo', () => {
|
||||
test('removes hook + credential wiring; leaves committed content', async () => {
|
||||
await harden();
|
||||
const steps = await unhardenBrainRepo({ repoPath: work, sourceId: 'wiki' });
|
||||
expect(existsSync(join(work, '.git', 'hooks', 'post-commit'))).toBe(false);
|
||||
expect(cfg(work, 'gbrain.durability.managedcredential')).toBe('');
|
||||
// committed helper stays
|
||||
expect(existsSync(join(work, 'scripts', 'brain-commit-push.sh'))).toBe(true);
|
||||
expect(steps.find(s => s.step === 'hook')?.status).toBe('fixed');
|
||||
});
|
||||
|
||||
test('idempotent when not hardened (all skipped)', async () => {
|
||||
const steps = await unhardenBrainRepo({ repoPath: work, sourceId: 'wiki' });
|
||||
expect(steps.every(s => s.status === 'skipped')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptPat (D8)', () => {
|
||||
test('reads + trims a pat-file', () => {
|
||||
const p = join(root, 'pat.txt');
|
||||
writeFileSync(p, `${PAT}\n`, { mode: 0o600 });
|
||||
const r = acceptPat({ patFile: p });
|
||||
expect(r?.token).toBe(PAT);
|
||||
expect(r?.warnings).toEqual([]);
|
||||
});
|
||||
test('throws on a missing pat-file', () => {
|
||||
expect(() => acceptPat({ patFile: join(root, 'nope.txt') })).toThrow();
|
||||
});
|
||||
test('throws on an empty pat-file', () => {
|
||||
const p = join(root, 'empty.txt'); writeFileSync(p, ' \n', { mode: 0o600 });
|
||||
expect(() => acceptPat({ patFile: p })).toThrow();
|
||||
});
|
||||
test('warns (but continues) on loose perms', () => {
|
||||
const p = join(root, 'loose.txt'); writeFileSync(p, PAT); chmodSync(p, 0o644);
|
||||
const r = acceptPat({ patFile: p });
|
||||
expect(r?.token).toBe(PAT);
|
||||
expect(r?.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
test('falls back to GBRAIN_GITHUB_PAT env', () => {
|
||||
const old = process.env.GBRAIN_GITHUB_PAT;
|
||||
process.env.GBRAIN_GITHUB_PAT = PAT;
|
||||
try { expect(acceptPat({})?.source).toBe('env:GBRAIN_GITHUB_PAT'); }
|
||||
finally { if (old === undefined) delete process.env.GBRAIN_GITHUB_PAT; else process.env.GBRAIN_GITHUB_PAT = old; }
|
||||
});
|
||||
test('returns null when no PAT is available', () => {
|
||||
const old = process.env.GBRAIN_GITHUB_PAT; delete process.env.GBRAIN_GITHUB_PAT;
|
||||
try { expect(acceptPat({})).toBeNull(); }
|
||||
finally { if (old !== undefined) process.env.GBRAIN_GITHUB_PAT = old; }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Durability cron generators (v0.42.44, D2 + D12): pure-string renderers.
|
||||
* Asserts the cron is DB-free (gbrain sources pull --path, NOT `pull <id>`),
|
||||
* secret-free, self-disabling, and that the launchd plist is periodic.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { renderCronWrapper, generateBrainPullPlist } from '../src/core/brain-repo-durability.ts';
|
||||
|
||||
const TOKEN = 'ghp_SHOULD_NEVER_APPEAR';
|
||||
|
||||
describe('renderCronWrapper (D2 DB-free)', () => {
|
||||
const w = renderCronWrapper('wiki', '/data/clones/wiki', 'main', '/usr/local/bin/gbrain', '/home/u/.gbrain/brain-push.log');
|
||||
|
||||
test('calls the DB-free path command, not the engine-opening one', () => {
|
||||
expect(w).toContain("sources pull --path '/data/clones/wiki'");
|
||||
expect(w).toContain("--branch 'main'");
|
||||
expect(w).not.toMatch(/sources pull '?wiki'?(\s|$)/); // never `sources pull wiki`
|
||||
});
|
||||
|
||||
test('self-disables when the captured checkout is gone', () => {
|
||||
expect(w).toContain("if [ ! -d '/data/clones/wiki/.git' ]");
|
||||
expect(w).toContain('path gone, skipping');
|
||||
});
|
||||
|
||||
test('sources the shell profile (secret-free) and never bakes a token', () => {
|
||||
expect(w).toContain('source ~/.zshenv');
|
||||
expect(w.includes(TOKEN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBrainPullPlist (D12 launchd)', () => {
|
||||
const plist = generateBrainPullPlist('com.gbrain.brain-pull.wiki', '/home/u/.gbrain/brain-pull-wiki.sh', '/home/u', 1800);
|
||||
|
||||
test('is periodic (StartInterval), not a KeepAlive daemon', () => {
|
||||
expect(plist).toContain('<key>StartInterval</key><integer>1800</integer>');
|
||||
expect(plist).not.toContain('<key>KeepAlive</key>');
|
||||
});
|
||||
|
||||
test('carries the per-source label and the wrapper path only (no secret)', () => {
|
||||
expect(plist).toContain('<string>com.gbrain.brain-pull.wiki</string>');
|
||||
expect(plist).toContain('/home/u/.gbrain/brain-pull-wiki.sh');
|
||||
expect(plist.includes(TOKEN)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* git-remote durability helpers (v0.42.44): divergenceSafePull, detectDefaultBranch,
|
||||
* pushProbe, isWorkingTreeDirty. Real git against local bare remotes.
|
||||
*
|
||||
* Local file transport is enabled via GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1 (the
|
||||
* documented escape hatch the durability paths honor).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import {
|
||||
divergenceSafePull, detectDefaultBranch, pushProbe, isWorkingTreeDirty,
|
||||
} from '../src/core/git-remote.ts';
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
function gitIn(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
let root: string;
|
||||
let bare: string;
|
||||
|
||||
/** A bare origin + a working clone with one commit on `main`. */
|
||||
function makePair(): { bare: string; work: string } {
|
||||
const b = mkdtempSync(join(root, 'origin-')) + '.git';
|
||||
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', b], { stdio: 'ignore' });
|
||||
const w = mkdtempSync(join(root, 'work-'));
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', b, w], { stdio: 'ignore' });
|
||||
git(w, 'config', 'user.email', 't@t.t');
|
||||
git(w, 'config', 'user.name', 'tester');
|
||||
writeFileSync(join(w, 'README.md'), 'init\n');
|
||||
git(w, 'add', 'README.md');
|
||||
git(w, 'commit', '-qm', 'init');
|
||||
git(w, 'push', '-q', 'origin', 'main');
|
||||
// Set origin/HEAD so detectDefaultBranch can resolve it.
|
||||
try { git(w, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ }
|
||||
return { bare: b, work: w };
|
||||
}
|
||||
|
||||
function secondClone(b: string): string {
|
||||
const w = mkdtempSync(join(root, 'work2-'));
|
||||
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', b, w], { stdio: 'ignore' });
|
||||
git(w, 'config', 'user.email', 'u@u.u');
|
||||
git(w, 'config', 'user.name', 'tester2');
|
||||
return w;
|
||||
}
|
||||
|
||||
beforeAll(() => { process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1'; });
|
||||
afterAll(() => { delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT; });
|
||||
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'gd-')); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe('detectDefaultBranch', () => {
|
||||
test('resolves origin/HEAD', () => {
|
||||
const { work } = makePair();
|
||||
expect(detectDefaultBranch(work)).toBe('main');
|
||||
});
|
||||
test('falls back to main when nothing resolves', () => {
|
||||
const empty = mkdtempSync(join(root, 'bare-')); // not a git repo
|
||||
expect(detectDefaultBranch(empty)).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkingTreeDirty', () => {
|
||||
test('false when clean, true after an edit', () => {
|
||||
const { work } = makePair();
|
||||
expect(isWorkingTreeDirty(work)).toBe(false);
|
||||
writeFileSync(join(work, 'README.md'), 'changed\n');
|
||||
expect(isWorkingTreeDirty(work)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('divergenceSafePull', () => {
|
||||
test('up_to_date when already current', () => {
|
||||
const { work } = makePair();
|
||||
expect(divergenceSafePull(work, 'main').status).toBe('up_to_date');
|
||||
});
|
||||
|
||||
test('advanced when origin has a new commit', () => {
|
||||
const { bare, work } = makePair();
|
||||
const other = secondClone(bare);
|
||||
writeFileSync(join(other, 'b.txt'), 'b\n');
|
||||
git(other, 'add', 'b.txt'); git(other, 'commit', '-qm', 'b'); git(other, 'push', '-q', 'origin', 'main');
|
||||
const out = divergenceSafePull(work, 'main');
|
||||
expect(out.status).toBe('advanced');
|
||||
expect(existsSync(join(work, 'b.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
test('skipped_dirty when the working tree is dirty', () => {
|
||||
const { work } = makePair();
|
||||
writeFileSync(join(work, 'README.md'), 'local edit\n');
|
||||
expect(divergenceSafePull(work, 'main').status).toBe('skipped_dirty');
|
||||
});
|
||||
|
||||
test('rebases local commits over origin', () => {
|
||||
const { bare, work } = makePair();
|
||||
const other = secondClone(bare);
|
||||
writeFileSync(join(other, 'remote.txt'), 'r\n');
|
||||
git(other, 'add', 'remote.txt'); git(other, 'commit', '-qm', 'remote'); git(other, 'push', '-q', 'origin', 'main');
|
||||
// local commit on a DIFFERENT file → clean rebase
|
||||
writeFileSync(join(work, 'local.txt'), 'l\n');
|
||||
git(work, 'add', 'local.txt'); git(work, 'commit', '-qm', 'local');
|
||||
const out = divergenceSafePull(work, 'main');
|
||||
expect(out.status).toBe('advanced');
|
||||
expect(existsSync(join(work, 'remote.txt'))).toBe(true);
|
||||
expect(existsSync(join(work, 'local.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
test('conflict_aborted leaves NO rebase state', () => {
|
||||
const { bare, work } = makePair();
|
||||
const other = secondClone(bare);
|
||||
writeFileSync(join(other, 'README.md'), 'remote version\n');
|
||||
git(other, 'add', 'README.md'); git(other, 'commit', '-qm', 'remote'); git(other, 'push', '-q', 'origin', 'main');
|
||||
// local commit touching the SAME line → rebase conflict
|
||||
writeFileSync(join(work, 'README.md'), 'local version\n');
|
||||
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'local');
|
||||
const out = divergenceSafePull(work, 'main');
|
||||
expect(out.status).toBe('conflict_aborted');
|
||||
// The "never mid-rebase" invariant:
|
||||
expect(existsSync(join(work, '.git', 'rebase-merge'))).toBe(false);
|
||||
expect(existsSync(join(work, '.git', 'rebase-apply'))).toBe(false);
|
||||
// Working tree is usable (HEAD is the local commit, not a conflicted state).
|
||||
expect(gitIn(work, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushProbe', () => {
|
||||
test('ok against a writable remote', () => {
|
||||
const { work } = makePair();
|
||||
expect(pushProbe(work, 'main')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('not ok when origin is unreachable', () => {
|
||||
const { work } = makePair();
|
||||
git(work, 'remote', 'set-url', 'origin', join(root, 'does-not-exist.git'));
|
||||
const r = pushProbe(work, 'main');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('redactDetail scrubs a token from the failure detail', () => {
|
||||
const { work } = makePair();
|
||||
git(work, 'remote', 'set-url', 'origin', join(root, 'nope-ghp_SECRETTOKEN.git'));
|
||||
const r = pushProbe(work, 'main', { redactDetail: (s) => s.replaceAll('ghp_SECRETTOKEN', '***') });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.detail.includes('ghp_SECRETTOKEN')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user