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:
Garry Tan
2026-06-17 06:41:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9d88680a51
commit 7ea92d602c
15 changed files with 1801 additions and 4 deletions
+123
View File
@@ -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);
});
});
+223
View File
@@ -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; }
});
});
+44
View File
@@ -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);
});
});
+153
View File
@@ -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);
});
});