mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
154 lines
6.4 KiB
TypeScript
154 lines
6.4 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|