test(git-sync): add reviewer-requested coverage across engine, server, client
Implements the test cases called out in the PR #119 review threads (code-review, test-strategy report, red-team) — TESTS ONLY, no production code changes. packages/git-sync (vitest): - lib converter/markdown gaps: pageBreak data-loss (it.fails repro), subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge, column.width number<->string drift, empty details. - engine units: parentFolderFile, planReconciliation swap/chained move, buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions / applyPullActions failure isolation. - real temp-git integration: diffNameStatus -z rename+add/modify alignment, copy-line behavior, per-invocation committer identity (no leak into repo/global config). - ENFORCED type-level GitSyncClient contract via vitest typecheck over a *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched). apps/server (jest): - orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex skip ladder + release-on-throw, merge guard, pull/push order, remote template substitution, poll lifecycle. - page-change listener: loop-guard, debounce coalescing, id resolution, error swallowing. - vault registry, controller authz (trigger + status), env validation/getters, page.service git-sync provenance stamping, persistence precedence (agent > git-sync > user) + no boundary snapshot, space.service audit-delta, space.repo jsonb-merge, converter-gate corpus extension (mention/math/details/marks). apps/client (vitest + testing-library): - history-item git-sync badge: render gating + non-clickable. - edit-space-form toggle: initial state, optimistic payload, rollback on error, disabled states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
claude code agent 227
parent
876a401268
commit
f1a894ab79
236
packages/git-sync/test/git-integration-gaps.test.ts
Normal file
236
packages/git-sync/test/git-integration-gaps.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
VaultGit,
|
||||
BOT_AUTHOR_NAME,
|
||||
BOT_AUTHOR_EMAIL,
|
||||
} from '../src/engine/git';
|
||||
|
||||
// Integration coverage gaps for `git.ts` flagged by the PR #119 reviewers
|
||||
// (test-strategy report, Module 2). These create REAL temp git repos (mirroring
|
||||
// test/git.test.ts's setup/teardown) to exercise the actual `git` binary, since
|
||||
// the behaviors under test (the `-z` NUL-token alignment, copy detection, and
|
||||
// per-invocation committer identity) only manifest against real git.
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** True if a usable `git` binary is on PATH (skip gracefully otherwise). */
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the author "Name <email>" of HEAD in a repo dir. */
|
||||
async function headAuthor(dir: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'log', '-1', '--pretty=%an <%ae>'],
|
||||
{ cwd: dir },
|
||||
);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/** Read the committer "Name <email>" of HEAD in a repo dir. */
|
||||
async function headCommitter(dir: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['--no-pager', 'log', '-1', '--pretty=%cn <%ce>'],
|
||||
{ cwd: dir },
|
||||
);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/** Read a LOCAL git config value (or '' if unset) in a repo dir. */
|
||||
async function localConfig(dir: string, key: string): Promise<string> {
|
||||
const r = await execFileAsync('git', ['config', '--local', '--get', key], {
|
||||
cwd: dir,
|
||||
}).catch(() => ({ stdout: '' }) as { stdout: string });
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
describe('VaultGit integration gaps (temp repo)', () => {
|
||||
let available = false;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
available = await gitAvailable();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function freshDir(): Promise<string> {
|
||||
dir = await mkdtemp(join(tmpdir(), 'docmost-vault-gap-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
// --- 7. diffNameStatus: rename mixed with add + modify in ONE diff ----------
|
||||
//
|
||||
// The `-z` parser walks NUL-delimited tokens pulling 1 or 2 path tokens per
|
||||
// status (R/C take TWO: old + new; A/M/D take ONE). A misalignment — pulling
|
||||
// the wrong number of tokens for any row — would SHIFT every subsequent path
|
||||
// and misclassify a move as a delete (or vice versa). This test mixes an R
|
||||
// (rename) with an A (add) and an M (modify) in a SINGLE diff so the walk MUST
|
||||
// stay aligned across the 2-token R row and the 1-token A/M rows.
|
||||
it('diffNameStatus keeps -z token alignment with R + A + M in one diff', async (ctx) => {
|
||||
// Truly SKIP (not silently pass) when git is unavailable — a green result on
|
||||
// a git-less machine would falsely claim this integration ran.
|
||||
if (!available) ctx.skip();
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Base commit: `keep.md` (to be modified) and `old-name.md` (to be renamed).
|
||||
const renameBody = 'line a\nline b\nline c\nline d\n';
|
||||
await writeFile(join(vault, 'keep.md'), 'v1\n', 'utf8');
|
||||
await writeFile(join(vault, 'old-name.md'), renameBody, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('base', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
const base = await git.revParse('HEAD');
|
||||
expect(base).toBeTruthy();
|
||||
|
||||
// Second commit: MODIFY keep.md, ADD fresh.md, RENAME old-name.md ->
|
||||
// new-name.md (identical content so -M detects a rename, not delete+add).
|
||||
await writeFile(join(vault, 'keep.md'), 'v2\n', 'utf8');
|
||||
await writeFile(join(vault, 'fresh.md'), 'brand new\n', 'utf8');
|
||||
await rm(join(vault, 'old-name.md'));
|
||||
await writeFile(join(vault, 'new-name.md'), renameBody, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('mixed change', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
const entries = await git.diffNameStatus(base!, 'HEAD');
|
||||
const byPath = new Map(entries.map((e) => [e.path, e]));
|
||||
|
||||
// The modify and the add are each classified correctly (1 path token each).
|
||||
expect(byPath.get('keep.md')).toEqual({ status: 'M', path: 'keep.md' });
|
||||
expect(byPath.get('fresh.md')).toEqual({ status: 'A', path: 'fresh.md' });
|
||||
|
||||
// The rename is a SINGLE R row carrying BOTH old + new paths (2 path tokens)
|
||||
// — proof the walk consumed exactly two tokens here and stayed aligned. If
|
||||
// alignment were off, the rename would surface as a D (delete) of
|
||||
// old-name.md and/or an A of new-name.md instead.
|
||||
const r = byPath.get('new-name.md');
|
||||
expect(r?.status).toBe('R');
|
||||
expect(r?.oldPath).toBe('old-name.md');
|
||||
expect(r?.score).toBe(100);
|
||||
|
||||
// Exactly three rows, and crucially NO stray D/A for the renamed file (which
|
||||
// is the tell-tale of a -z misalignment).
|
||||
expect(entries.length).toBe(3);
|
||||
expect(entries.some((e) => e.status === 'D')).toBe(false);
|
||||
expect(byPath.has('old-name.md')).toBe(false);
|
||||
});
|
||||
|
||||
// --- 8. diffNameStatus: copy (C) status lines -------------------------------
|
||||
//
|
||||
// DOCUMENTED OUTCOME (reported as such): `C` (copy) rows are NOT reachable
|
||||
// through the engine's actual git invocation. `diffNameStatus` invokes
|
||||
// `git diff --name-status -M -z` — `-M` enables rename detection ONLY; copy
|
||||
// detection requires `-C`/`--find-copies`, which the engine does NOT pass. So a
|
||||
// file that is a verbatim COPY of another (the original is KEPT) is reported as
|
||||
// a plain ADD (`A`), never `C`. This test pins that real behavior so a future
|
||||
// change that turns on `-C` (and would start emitting `C` rows) is caught.
|
||||
it('diffNameStatus reports a pure copy as A, not C (engine uses -M only)', async (ctx) => {
|
||||
if (!available) ctx.skip();
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Base: a single source file with enough content to be copy-detectable.
|
||||
const body = 'aaa\nbbb\nccc\nddd\neee\nfff\n';
|
||||
await writeFile(join(vault, 'src.md'), body, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add src', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
const base = await git.revParse('HEAD');
|
||||
|
||||
// KEEP src.md and add an identical copy dup.md (a pure copy, not a rename).
|
||||
await copyFile(join(vault, 'src.md'), join(vault, 'dup.md'));
|
||||
await git.stageAll();
|
||||
await git.commit('add copy of src', {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
});
|
||||
|
||||
const entries = await git.diffNameStatus(base!, 'HEAD');
|
||||
|
||||
// With -M only (no -C), git does NOT emit a C row: the copy is a plain add.
|
||||
expect(entries).toEqual([{ status: 'A', path: 'dup.md' }]);
|
||||
expect(entries.some((e) => e.status === 'C')).toBe(false);
|
||||
});
|
||||
|
||||
// --- 9. commit: per-invocation committer/author does NOT leak into config ----
|
||||
//
|
||||
// The engine sets author + committer identity via GIT_AUTHOR_*/GIT_COMMITTER_*
|
||||
// env vars per `git commit` invocation (commitRaw). This underpins the §10
|
||||
// provenance/loop-guard: the identity must travel WITH the commit, not be
|
||||
// written into the repo config (which would make it global to every later
|
||||
// hand-run commit). We commit with the distinct "Local" identity (different
|
||||
// from the repo's default `user.name`/`user.email`, which ensureRepo seeds as
|
||||
// the bot identity) and assert the commit carries the passed identity while the
|
||||
// repo config is UNCHANGED (still the bot default).
|
||||
it('commit passes committer/author per-invocation without mutating repo config', async (ctx) => {
|
||||
if (!available) ctx.skip();
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// ensureRepo seeds the repo's LOCAL user.* with the bot identity. Capture it
|
||||
// so we can prove the per-commit identity does NOT overwrite it.
|
||||
expect(await localConfig(vault, 'user.name')).toBe(BOT_AUTHOR_NAME);
|
||||
expect(await localConfig(vault, 'user.email')).toBe(BOT_AUTHOR_EMAIL);
|
||||
|
||||
// Commit with a DIFFERENT identity, passed per-invocation only.
|
||||
const LOCAL_NAME = 'Local';
|
||||
const LOCAL_EMAIL = 'local@local';
|
||||
await writeFile(join(vault, 'page.md'), 'hello\n', 'utf8');
|
||||
await git.stageAll();
|
||||
const made = await git.commit('docmost: sync 1 page(s)', {
|
||||
authorName: LOCAL_NAME,
|
||||
authorEmail: LOCAL_EMAIL,
|
||||
});
|
||||
expect(made).toBe(true);
|
||||
|
||||
// The commit's author AND committer are the passed per-invocation identity
|
||||
// (committer matches author via GIT_COMMITTER_* — not the repo default).
|
||||
expect(await headAuthor(vault)).toBe(`${LOCAL_NAME} <${LOCAL_EMAIL}>`);
|
||||
expect(await headCommitter(vault)).toBe(`${LOCAL_NAME} <${LOCAL_EMAIL}>`);
|
||||
|
||||
// CRITICAL: the per-commit identity did NOT leak into the repo config — the
|
||||
// LOCAL user.* is still the bot default ensureRepo seeded.
|
||||
expect(await localConfig(vault, 'user.name')).toBe(BOT_AUTHOR_NAME);
|
||||
expect(await localConfig(vault, 'user.email')).toBe(BOT_AUTHOR_EMAIL);
|
||||
|
||||
// And the identity never reached the GLOBAL config either (the env-var path
|
||||
// writes no config at all). `--global --get` exits non-zero / empty when the
|
||||
// value differs or is unset; assert it is NOT the per-commit identity.
|
||||
const globalName = await execFileAsync('git', [
|
||||
'config',
|
||||
'--global',
|
||||
'--get',
|
||||
'user.name',
|
||||
])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => '');
|
||||
expect(globalName).not.toBe(LOCAL_NAME);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user