Files
gbrain/test/sources-resync-recovery.test.ts
T
b31de6613e v0.42.33.0 fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881) (#1960)
* fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881)

recloneIfMissing deleted local_path whenever a source had a remote_url and a
non-healthy on-disk state, with no check that gbrain actually created the clone.
A source whose local_path was a user's live working tree (remote_url set, no
gbrain-created clone) could have its directory removed and re-cloned over.

- isOwnedClone(): ownership, not path-containment. True only for a config
  .managed_clone marker (written by addSource --url) or exact normalized-path
  equality with defaultCloneDir(id) (back-compat for pre-marker default clones).
- recloneIfMissing: ownership guard aborts before ANY filesystem op; EXDEV-safe
  sibling-temp clone + atomic swap (old aside -> new in -> drop old) with
  best-effort restore + a message naming where the original is preserved;
  symlink-leaf reject before the destructive rename.
- sync.ts validate_repo_state guards reclone on isOwnedClone (no per-sync warn).
- sources restore degrades to a warning for an unowned source instead of the
  misleading "missing clone, try sync" hint.

Tests: #1881 regression (tree survives), isOwnedClone matrix, symlink reject,
EXDEV swap residue-free, --clone-dir owned-via-marker, restore CV3, unownedHint
healthy/degraded, sync-level refusal.

* chore: bump version and changelog (v0.42.33.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document sources-ops reclone-ownership invariant for v0.42.33.0 (#1881)

Add the missing src/core/sources-ops.ts entry to KEY_FILES.md capturing the
must-never-violate reclone-ownership guarantee: gbrain only deletes/re-clones a
clone it created (isOwnedClone), never a user working tree. Covers managed_clone
marker, defaultCloneDir back-compat, EXDEV-safe swap, TOCTOU + symlink-leaf
guards, unmanaged_path SourceOpError, and the read-only sources restore path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:28 -07:00

288 lines
11 KiB
TypeScript

/**
* sources sync re-clone recovery — exercises the v0.28 branch in
* src/commands/sync.ts that recovers from a missing/corrupted clone dir
* by re-cloning when the source has a remote_url.
*
* Setup uses fake-git in PATH so we can simulate clones without network.
* Real-Postgres E2E coverage of the same flow lives in
* test/e2e/sources-remote-mcp.test.ts.
*/
import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync, chmodSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { addSource, recloneIfMissing } from '../src/core/sources-ops.ts';
import { validateRepoState } from '../src/core/git-remote.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-resync-test-${process.pid}`);
const GBRAIN_HOME = join(FAKE_GIT_DIR, 'gbrain-home');
const CLONE_ROOT = join(GBRAIN_HOME, '.gbrain', 'clones');
function writeFakeGit(): void {
mkdirSync(FAKE_GIT_DIR, { recursive: true });
writeFileSync(join(FAKE_GIT_DIR, 'mode'), 'ok');
const script = `#!/usr/bin/env bash
mode=$(cat "${join(FAKE_GIT_DIR, 'mode')}" 2>/dev/null || echo ok)
url_to_return=\${REMOTE_GET_URL_OUTPUT:-https://github.com/example/repo}
case "$mode" in
clone-fail) exit 1 ;;
esac
has_clone=0
has_remote_get_url=0
for ((i=1; i<=$#; i++)); do
arg="\${!i}"
next_idx=$((i+1))
next="\${!next_idx:-}"
if [ "$arg" = "clone" ]; then has_clone=1; fi
if [ "$arg" = "remote" ] && [ "$next" = "get-url" ]; then has_remote_get_url=1; fi
done
if [ "$has_clone" = "1" ]; then
dest="\${@: -1}"
mkdir -p "$dest/.git"
echo "ref: refs/heads/main" > "$dest/.git/HEAD"
exit 0
fi
if [ "$has_remote_get_url" = "1" ]; then
echo "$url_to_return"
exit 0
fi
exit 0
`;
const path = join(FAKE_GIT_DIR, 'git');
writeFileSync(path, script);
chmodSync(path, 0o755);
}
const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;
beforeAll(async () => {
writeFakeGit();
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
rmSync(FAKE_GIT_DIR, { recursive: true, force: true });
});
beforeEach(async () => {
await resetPgliteState(engine);
rmSync(GBRAIN_HOME, { recursive: true, force: true });
mkdirSync(GBRAIN_HOME, { recursive: true });
writeFileSync(join(FAKE_GIT_DIR, 'mode'), 'ok');
});
// ---------------------------------------------------------------------------
// validateRepoState — direct probe of all 6 states using the fake git
// ---------------------------------------------------------------------------
describe('validateRepoState — full state matrix (sync re-clone driver)', () => {
test('healthy: existing .git + matching origin URL', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'state-healthy',
remoteUrl: 'https://github.com/example/repo',
});
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('healthy');
});
});
test('missing: clone dir was rmd', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'state-missing',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(row.local_path!, { recursive: true, force: true });
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('missing');
});
});
test('not-a-dir: clone path is a file', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'state-file',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(row.local_path!, { recursive: true, force: true });
mkdirSync(CLONE_ROOT, { recursive: true });
writeFileSync(row.local_path!, 'corrupted');
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('not-a-dir');
});
});
test('no-git: directory exists but no .git/ inside', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'state-no-git',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(join(row.local_path!, '.git'), { recursive: true, force: true });
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('no-git');
});
});
test('corrupted: .git exists but git remote get-url fails', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'state-corrupted',
remoteUrl: 'https://github.com/example/repo',
});
writeFileSync(join(FAKE_GIT_DIR, 'mode'), 'clone-fail'); // makes git exit 1 always
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('corrupted');
});
});
test('url-drift: remote points elsewhere', async () => {
await withEnv(
{ GBRAIN_HOME, PATH: fakePath(), REMOTE_GET_URL_OUTPUT: 'https://github.com/different/repo' },
async () => {
const row = await addSource(engine, {
id: 'state-drift',
remoteUrl: 'https://github.com/example/repo',
});
expect(validateRepoState(row.local_path!, 'https://github.com/example/repo'))
.toBe('url-drift');
},
);
});
});
// ---------------------------------------------------------------------------
// recloneIfMissing — recovery contract under each starting state
// ---------------------------------------------------------------------------
describe('recloneIfMissing — recovery from each degraded state', () => {
test('recovers from "missing" by re-cloning', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'rec-missing',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(row.local_path!, { recursive: true, force: true });
const recloned = await recloneIfMissing(engine, 'rec-missing');
expect(recloned).toBe(true);
expect(existsSync(join(row.local_path!, '.git'))).toBe(true);
});
});
test('recovers from "no-git" by re-cloning over the empty dir', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'rec-nogit',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(join(row.local_path!, '.git'), { recursive: true, force: true });
const recloned = await recloneIfMissing(engine, 'rec-nogit');
expect(recloned).toBe(true);
expect(existsSync(join(row.local_path!, '.git'))).toBe(true);
});
});
test('recovers from "not-a-dir" by replacing the file with a clone', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'rec-file',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(row.local_path!, { recursive: true, force: true });
mkdirSync(CLONE_ROOT, { recursive: true });
writeFileSync(row.local_path!, 'corrupted');
const recloned = await recloneIfMissing(engine, 'rec-file');
expect(recloned).toBe(true);
expect(existsSync(join(row.local_path!, '.git'))).toBe(true);
});
});
test('idempotent on healthy clones (returns false, no clone)', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
await addSource(engine, {
id: 'rec-healthy',
remoteUrl: 'https://github.com/example/repo',
});
expect(await recloneIfMissing(engine, 'rec-healthy')).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// Sync-time integration: the same path performSync uses
// ---------------------------------------------------------------------------
describe('performSync re-clone branch (driven by sync.ts:320 logic)', () => {
test('healthy clone: validateRepoState passes through to existing pull path', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'sync-healthy',
remoteUrl: 'https://github.com/example/repo',
});
// Simulate the sync.ts:320 lookup
const cfgRows = await engine.executeRaw<{ config: unknown }>(
`SELECT config FROM sources WHERE id = $1`,
['sync-healthy'],
);
const cfg = cfgRows[0].config as Record<string, unknown>;
const remoteUrl = cfg.remote_url as string;
const state = validateRepoState(row.local_path!, remoteUrl);
expect(state).toBe('healthy');
});
});
test('missing clone: state becomes "missing", re-clone fires', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
const row = await addSource(engine, {
id: 'sync-missing',
remoteUrl: 'https://github.com/example/repo',
});
rmSync(row.local_path!, { recursive: true, force: true });
// sync.ts:320 detects 'missing' and calls recloneIfMissing
const state = validateRepoState(row.local_path!, 'https://github.com/example/repo');
expect(state).toBe('missing');
const recloned = await recloneIfMissing(engine, 'sync-missing');
expect(recloned).toBe(true);
});
});
// #1881: the sync branch must NOT re-clone over an unowned user working tree.
test('unowned local_path (remote_url, no marker): refuses, working tree survives', async () => {
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
// Federated row shape created by the gstack orchestrator: remote_url set,
// local_path = a live user tree OUTSIDE the clone root, no managed_clone.
const userTree = join(FAKE_GIT_DIR, 'sync-user-tree');
rmSync(userTree, { recursive: true, force: true });
mkdirSync(userTree, { recursive: true });
const sentinel = join(userTree, 'KEEP_ME.txt');
writeFileSync(sentinel, 'live repo');
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config)
VALUES ('sync-unowned', 'flutter', $1,
'{"remote_url":"https://github.com/example/repo","federated":true}'::jsonb)`,
[userTree],
);
// no-git → the sync branch would historically call recloneIfMissing →
// rm the tree. Now it must throw unmanaged_path and leave the tree intact.
const state = validateRepoState(userTree, 'https://github.com/example/repo');
expect(state).toBe('no-git');
await expect(recloneIfMissing(engine, 'sync-unowned')).rejects.toThrow(
/unmanaged_path|not a clone gbrain created/,
);
expect(existsSync(userTree)).toBe(true);
expect(existsSync(sentinel)).toBe(true);
});
});
});