feat(sync): FS->Docmost push #2 — loop-close (§6.3/§10) + fix flaky property timeout
- git.ts: fastForwardBranch(branch, toCommit) — advances ONLY on a true
fast-forward (merge-base --is-ancestor), refuses a non-ff without clobbering
divergent docmost history
- push.ts: after a CLEAN push (failures===0) advance both refs/docmost/last-pushed
AND fast-forward the docmost mirror, so the next pull sees no diff for pushed
pages (loop-guard, git-native); a partial push advances NEITHER (§12)
- push.ts: per-page error isolation (one bad page doesn't block the batch,
failures recorded); create requires a non-empty spaceId else skipped (§8 spirit)
- loop-guard.ts: bodyHash() (sha256) + per-page pushed:[{pageId,updatedAt?,bodyHash}]
record for the §10 self-write suppression (pull-side consumption deferred)
- test: markdown-roundtrip property tests get a 30s per-test timeout (deterministic
inputs via fixed seed; the only flakiness was wall-clock under parallel load,
which intermittently failed CI/docker)
- 709 -> 724 green (3x stable); build clean; corpus STABLE
Deferred (next/final increment): move/rename apply, pull-side loop-guard consumption,
FS-watcher/debounce (§7.1), git-remote push (§7.2), runnable live main(),
escalate-on-divergent-docmost.
This commit is contained in:
@@ -622,4 +622,89 @@ describe('VaultGit (integration; temp repo)', () => {
|
||||
expect(preImage).toBe(meta);
|
||||
expect(preImage).toContain('page-123');
|
||||
});
|
||||
|
||||
it('fastForwardBranch advances a true fast-forward (the loop-close, SPEC §6 step 3)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// docmost branches off main at the initial commit; main then moves ahead.
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const base = await git.revParse('refs/heads/docmost');
|
||||
|
||||
await writeFile(join(vault, 'page.md'), 'pushed content\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('push page', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// docmost is BEHIND main and an ancestor -> a true fast-forward advances it.
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(base);
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: true });
|
||||
// The branch now points at the pushed main commit (mirror reflects Docmost).
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(mainTip);
|
||||
|
||||
// It does NOT touch the working tree / current branch (still on main).
|
||||
expect(await git.currentBranch()).toBe('main');
|
||||
});
|
||||
|
||||
it('fastForwardBranch is a no-op (ok) when the branch is already at the target', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// Already equal -> a degenerate fast-forward, still ok, branch unchanged.
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: true });
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(mainTip);
|
||||
});
|
||||
|
||||
it('fastForwardBranch REFUSES a non-fast-forward (never clobbers divergent history)', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
|
||||
// Make docmost diverge: it has a commit that main does NOT contain.
|
||||
await git.checkout('main'); // ensure on main first
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
await git.checkout('docmost');
|
||||
await writeFile(join(vault, 'only-on-docmost.md'), 'mirror-only\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('docmost-only commit', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const docmostTip = await git.revParse('refs/heads/docmost');
|
||||
|
||||
// main moves ahead independently (divergent from docmost).
|
||||
await git.checkout('main');
|
||||
await writeFile(join(vault, 'only-on-main.md'), 'main-only\n', 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('main-only commit', { authorName: BOT_AUTHOR_NAME, authorEmail: BOT_AUTHOR_EMAIL });
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
// docmost is NOT an ancestor of main -> the ff is REFUSED, branch untouched.
|
||||
const res = await git.fastForwardBranch('docmost', mainTip!);
|
||||
expect(res).toEqual({ ok: false, reason: 'not-fast-forward' });
|
||||
expect(await git.revParse('refs/heads/docmost')).toBe(docmostTip);
|
||||
});
|
||||
|
||||
it('fastForwardBranch refuses a missing branch / unresolved target with a reason', async () => {
|
||||
if (!available) return;
|
||||
const vault = await freshDir();
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
const mainTip = await git.revParse('HEAD');
|
||||
|
||||
const noBranch = await git.fastForwardBranch('nope', mainTip!);
|
||||
expect(noBranch.ok).toBe(false);
|
||||
expect(noBranch.reason).toContain('nope');
|
||||
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
const noTarget = await git.fastForwardBranch('docmost', 'deadbeefdeadbeef');
|
||||
expect(noTarget.ok).toBe(false);
|
||||
expect(noTarget.reason).toContain('deadbeefdeadbeef');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user