Three more git-sync QA defects from the 2nd live pass on PR #119, plus a callout-fidelity nit: 1. SPURIOUS conflict leaked raw markers into canonical main (root cause). On an ordinary round-trip the only difference between the docmost mirror (normalize- on-write) and a user's raw push is trailing/empty-line normalization, which made git's line-based docmost->main merge CONFLICT, and the wedge fix then committed the file WITH literal <<<<<<< / ======= / >>>>>>> markers onto main (git and the DB silently diverged for cycles). Fix: on a conflict, normalize trailing/empty lines on BOTH sides (showStage :2:/:3:) before comparing — a trailing-only diff is recognized as spurious and resolved to the clean normalized form. A GENUINE same-block conflict is auto-resolved to OURS (git wins, mirroring the live-doc 3-way rule); the docmost side stays on the `docmost` branch + page history. Raw markers NEVER reach main again. 2. Concurrent UI<->git edit silently lost the UI side. The git->Docmost 3-way merge ran against a live Y.Doc that hadn't yet received the user's debounced in-flight edit, so git clean-applied (no conflict detected) and the edit vanished even on a different block. Fix: flush the pending debounced store before the merge so the in-flight edit is drained into the live doc first — a different-block edit is merged, a same-block one is detected and pinned to history (recoverable). 3. Smart-HTTP HEAD flapped to the read-only `docmost` mirror (~1/4 of clones). The engine transiently checks out `docmost` mid-pull and the host advertises whatever HEAD resolves to. Fix: VaultGit.pinHeadToMain(); the cycle restores HEAD->main in a finally; and the upload-pack ref advertisement is served HEAD-pinned under the per-space lock so it can never observe a mid-cycle HEAD. 4. (callout) clampCalloutType now mirrors the editor's GITHUB_ALERT_TYPE_MAP for non-schema aliases (tip->success, caution->danger, important->info) instead of flatly collapsing to info. The editor schema genuinely supports only the six banner types, so unknown types still fall back to info (by design). Tests: deterministic real-git trailing-blank round-trip (no conflict, no markers, in sync over 2 cycles) + genuine-conflict no-marker-leak; HEAD advertisement stability; pre/post-flush concurrent-edit survival; serveReadAdvertisement lock pin; widened callout-alias coverage. Engine vitest + server tsc + collaboration / git-http / orchestrator specs all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { 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';
|
|
|
|
/**
|
|
* QA #119 bug #3 — the smart-HTTP host advertises whatever `HEAD` resolves to as
|
|
* a clone's default branch. The engine transiently checks out the read-only
|
|
* `docmost` mirror during a cycle, so a clone racing a cycle could default to
|
|
* `docmost`. `VaultGit.pinHeadToMain()` pins the symref back to `main` so the
|
|
* advertised HEAD is deterministic. Verified against a REAL temp git repo,
|
|
* including the actual `git upload-pack --advertise-refs` HEAD symref capability
|
|
* a clone reads. Skips gracefully if git is unavailable.
|
|
*/
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
async function gitAvailable(): Promise<boolean> {
|
|
try {
|
|
await execFileAsync('git', ['--version']);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('VaultGit.pinHeadToMain — advertised HEAD is stably main (real git)', () => {
|
|
let available = false;
|
|
let dir: string;
|
|
|
|
beforeAll(async () => {
|
|
available = await gitAvailable();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function headSymref(vault: string): Promise<string> {
|
|
const { stdout } = await execFileAsync(
|
|
'git',
|
|
['symbolic-ref', '--short', 'HEAD'],
|
|
{ cwd: vault },
|
|
);
|
|
return stdout.trim();
|
|
}
|
|
|
|
/** The HEAD symref a clone would read from `git upload-pack --advertise-refs`. */
|
|
async function advertisedHead(vault: string): Promise<string | null> {
|
|
const { stdout } = await execFileAsync(
|
|
'git',
|
|
['upload-pack', '--advertise-refs', vault],
|
|
{ cwd: vault },
|
|
);
|
|
// protocol v0/v2 advertise `symref=HEAD:refs/heads/<branch>` in the caps.
|
|
const m = stdout.match(/symref=HEAD:refs\/heads\/([^\s\0]+)/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
it('pins HEAD back to main after the engine checked out docmost', async () => {
|
|
if (!available) return;
|
|
dir = await mkdtemp(join(tmpdir(), 'docmost-head-'));
|
|
const git = new VaultGit(dir);
|
|
await git.ensureRepo();
|
|
await git.ensureBranch('docmost', 'main');
|
|
await writeFile(join(dir, 'A.md'), 'hello\n', 'utf8');
|
|
await git.stageAll();
|
|
await git.commit('seed', {
|
|
authorName: BOT_AUTHOR_NAME,
|
|
authorEmail: BOT_AUTHOR_EMAIL,
|
|
});
|
|
// Keep docmost reachable as a real branch ref.
|
|
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
|
|
|
|
// Simulate a cycle mid-pull: the engine checks out the read-only mirror.
|
|
await git.checkout('docmost');
|
|
expect(await headSymref(dir)).toBe('docmost');
|
|
expect(await advertisedHead(dir)).toBe('docmost'); // the bug, pre-pin
|
|
|
|
// Pin: the advertised default branch must be `main` again.
|
|
await git.pinHeadToMain();
|
|
expect(await headSymref(dir)).toBe('main');
|
|
expect(await advertisedHead(dir)).toBe('main');
|
|
|
|
// Idempotent: pinning when already on main is a clean no-op.
|
|
await git.pinHeadToMain();
|
|
expect(await headSymref(dir)).toBe('main');
|
|
expect(await advertisedHead(dir)).toBe('main');
|
|
});
|
|
});
|