feat(sync): runnable FS->Docmost push (dry-run default, --apply writes)
Wire the push cycle (SPEC §6) into a runnable command; SAFE BY DEFAULT. - runPush + main(): dry-run by default (plan only, ZERO Docmost writes, no ref advance); --apply is the ONLY path that builds a client and mutates Docmost - orchestration mirrors pull.ts: assertGitAvailable -> ensureRepo -> merge-in-progress guard (§9/§12) -> checkout main -> commit local working tree (Docmost-Sync-Source: local, §7.3) -> base = refs/docmost/last-pushed else docmost -> diffNameStatus(base, main) -> computePushActions -> (apply) -> write-back created pageIds + advance refs; divergent-docmost escalates (exit 1) - npm run push (dry-run) / npm run push -- --apply (writes; needs creds) - fix (review Blocker): pass the WHOLE VaultGit to applyPushActions (bare method refs lost `this` -> --apply crashed on real git); regression test exercises the --apply path against a REAL VaultGit temp repo + fake client (proven to catch it) - symmetric divergent-docmost escalation in both ff branches; dry-run logs the local commit explicitly; SPEC §6 notes the dry-run/local-commit behavior - 737 -> 747 green (x2 stable); build clean; corpus STABLE Deferred (daemon increment): FS-watcher/debounce (§7.1), git-remote push (§7.2), continuous poll loop, pull-side §10 record consumption, fractional-index position.
This commit is contained in:
142
test/run-push-realgit.test.ts
Normal file
142
test/run-push-realgit.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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, vi } from 'vitest';
|
||||
import { runPush, LAST_PUSHED_REF } from '../src/push.js';
|
||||
import type { PushDeps } from '../src/push.js';
|
||||
import { VaultGit } from '../src/git.js';
|
||||
import type { Settings } from '../src/settings.js';
|
||||
import { serializeDocmostMarkdownBody } from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// runPush `--apply` against a REAL VaultGit in a temp repo (NO Docmost — the
|
||||
// client is faked). This guards the real-git BINDING contract that the plain-
|
||||
// object git fakes in run-push.test.ts cannot catch: the applier's git deps
|
||||
// (`updateRef`/`fastForwardBranch`/`showFileAtRef`) call `this.run`/`this.runRaw`
|
||||
// internally, so they only work when their `this` receiver is preserved. Passing
|
||||
// bare method references (`git.updateRef`, …) would throw `this.runRaw is not a
|
||||
// function` here. Only the LOCAL temp git is mutated; nothing is sent to Docmost.
|
||||
|
||||
/** True if a usable `git` binary is on PATH (skip the suite otherwise). */
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('git', ['--version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal valid Settings fixture (only fields runPush reads matter). */
|
||||
function makeSettings(vaultPath: string): Settings {
|
||||
return {
|
||||
docmostApiUrl: 'https://docmost.example.com',
|
||||
docmostEmail: 'you@example.com',
|
||||
docmostPassword: 'secret',
|
||||
docmostSpaceId: 'space-1',
|
||||
vaultPath,
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
logLevel: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording client fake; createPage returns an assigned id + updatedAt. */
|
||||
function makeClientFake() {
|
||||
return {
|
||||
importPageMarkdown: vi.fn(async () => ({
|
||||
data: { updatedAt: '2026-06-20T00:00:00.000Z' },
|
||||
success: true,
|
||||
})),
|
||||
createPage: vi.fn(async (title: string) => ({
|
||||
data: { id: 'new-id', title, updatedAt: '2026-06-20T00:00:00.000Z' },
|
||||
success: true,
|
||||
})),
|
||||
deletePage: vi.fn(async () => ({ success: true })),
|
||||
movePage: vi.fn(async () => ({ success: true })),
|
||||
renamePage: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('runPush --apply against a REAL VaultGit (binding contract)', () => {
|
||||
let available = false;
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
available = await gitAvailable();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes through real git: createPage runs, last-pushed advances, no throw', async () => {
|
||||
if (!available) return; // skip gracefully when git is unavailable
|
||||
|
||||
// Temp vault repo under the OS tmpdir (mirrors test/git.test.ts setup).
|
||||
dir = await mkdtemp(join(tmpdir(), 'docmost-push-realgit-'));
|
||||
const vault = dir;
|
||||
const git = new VaultGit(vault);
|
||||
await git.ensureRepo();
|
||||
// The `docmost` mirror branches off `main` at the initial commit; this is
|
||||
// also the diff base (last-pushed is unset, so runPush falls back to it).
|
||||
await git.ensureBranch('docmost', 'main');
|
||||
|
||||
// A brand-new local file with meta carrying title + spaceId but NO pageId,
|
||||
// committed on `main` AHEAD of the base -> computePushActions yields a CREATE.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
await writeFile(join(vault, 'New.md'), newFile, 'utf8');
|
||||
await git.stageAll();
|
||||
await git.commit('add New.md', {
|
||||
authorName: 'Human',
|
||||
authorEmail: 'human@local',
|
||||
});
|
||||
|
||||
// last-pushed must be UNSET so the run actually advances it for the first time.
|
||||
expect(await git.revParse(LAST_PUSHED_REF)).toBeNull();
|
||||
|
||||
const client = makeClientFake();
|
||||
const logs: string[] = [];
|
||||
const deps: PushDeps = {
|
||||
settings: makeSettings(vault),
|
||||
// The WHOLE real VaultGit — its methods must keep their `this` binding.
|
||||
git,
|
||||
makeClient: () => client as any,
|
||||
readFile: (path) =>
|
||||
import('node:fs/promises').then((fs) =>
|
||||
fs.readFile(join(vault, ...path.split('/')), 'utf8'),
|
||||
),
|
||||
writeFile: async (path, text) => {
|
||||
const fs = await import('node:fs/promises');
|
||||
await fs.writeFile(join(vault, ...path.split('/')), text, 'utf8');
|
||||
},
|
||||
log: (line) => logs.push(line),
|
||||
};
|
||||
|
||||
// The run must NOT throw — this is what FAILS before Fix 1 (the bare-method
|
||||
// git deps would throw `this.runRaw is not a function` on the real VaultGit).
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.mode).toBe('apply');
|
||||
expect(res.failures).toEqual([]);
|
||||
// The FAKE client was actually called (the write path ran).
|
||||
expect(client.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(res.applied?.created).toBe(1);
|
||||
// The assigned pageId was written back to disk + committed.
|
||||
expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'new-id' }]);
|
||||
|
||||
// CRITICALLY: refs/docmost/last-pushed ACTUALLY advanced in the real repo —
|
||||
// it now resolves to a real commit (proving updateRef ran with binding).
|
||||
const lastPushed = await git.revParse(LAST_PUSHED_REF);
|
||||
expect(lastPushed).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(res.divergentDocmost).toBe(false);
|
||||
});
|
||||
});
|
||||
398
test/run-push.test.ts
Normal file
398
test/run-push.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/push.js';
|
||||
import type { PushDeps } from '../src/push.js';
|
||||
import type { Settings } from '../src/settings.js';
|
||||
import { serializeDocmostMarkdownBody } from '../packages/docmost-client/src/lib/markdown-document.js';
|
||||
|
||||
// runPush orchestration (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT. Driven by
|
||||
// FAKES only — no live Docmost, git, fs, or network. Asserts the SAFE-BY-DEFAULT
|
||||
// contract: a dry-run builds NO client, makes ZERO Docmost calls, advances NO
|
||||
// refs; `--apply` is the ONLY path that writes. Also covers the merge-in-progress
|
||||
// abort, the divergent-`docmost` escalation, and the base selection fallback.
|
||||
|
||||
/** A minimal valid Settings fixture (only fields runPush reads matter). */
|
||||
function makeSettings(): Settings {
|
||||
return {
|
||||
docmostApiUrl: 'https://docmost.example.com',
|
||||
docmostEmail: 'you@example.com',
|
||||
docmostPassword: 'secret',
|
||||
docmostSpaceId: 'space-1',
|
||||
vaultPath: '/vault',
|
||||
pollIntervalMs: 15000,
|
||||
debounceMs: 2000,
|
||||
logLevel: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A recording git fake covering exactly the `PushDeps['git']` surface. Options
|
||||
* configure the diff rows, which refs resolve, and what the ff returns.
|
||||
*/
|
||||
function makeGit(opts?: {
|
||||
mergeInProgress?: boolean;
|
||||
lastPushed?: string | null;
|
||||
docmostSha?: string | null;
|
||||
mainSha?: string;
|
||||
/** Diff rows returned by diffNameStatus(base, main). */
|
||||
changes?: { status: 'A' | 'M' | 'D' | 'R' | 'C'; path: string; oldPath?: string }[];
|
||||
/** Pre-image tree at the base ref (path -> text) for showFileAtRef. */
|
||||
prevTree?: Record<string, string>;
|
||||
ffResult?: { ok: boolean; reason?: string };
|
||||
/** When set, commit returns this per call (queue); defaults to always-true. */
|
||||
commitResults?: boolean[];
|
||||
}) {
|
||||
const calls = {
|
||||
assertGitAvailable: 0,
|
||||
ensureRepo: 0,
|
||||
checkout: [] as string[],
|
||||
stageAll: 0,
|
||||
commit: [] as string[],
|
||||
updateRef: [] as { ref: string; target: string }[],
|
||||
fastForwardBranch: [] as { branch: string; toCommit: string }[],
|
||||
diffNameStatus: [] as { from: string; to: string }[],
|
||||
};
|
||||
const prevTree = opts?.prevTree ?? {};
|
||||
const commitQueue = [...(opts?.commitResults ?? [])];
|
||||
let mainSha = opts?.mainSha ?? 'main-sha-1';
|
||||
|
||||
const git: PushDeps['git'] = {
|
||||
assertGitAvailable: vi.fn(async () => {
|
||||
calls.assertGitAvailable++;
|
||||
}),
|
||||
ensureRepo: vi.fn(async () => {
|
||||
calls.ensureRepo++;
|
||||
}),
|
||||
isMergeInProgress: vi.fn(async () => opts?.mergeInProgress ?? false),
|
||||
checkout: vi.fn(async (name: string) => {
|
||||
calls.checkout.push(name);
|
||||
}),
|
||||
stageAll: vi.fn(async () => {
|
||||
calls.stageAll++;
|
||||
}),
|
||||
commit: vi.fn(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
return commitQueue.length > 0 ? (commitQueue.shift() as boolean) : true;
|
||||
}),
|
||||
readRef: vi.fn(async (ref: string) =>
|
||||
ref === LAST_PUSHED_REF ? (opts?.lastPushed ?? null) : null,
|
||||
),
|
||||
revParse: vi.fn(async (ref: string) => {
|
||||
if (ref === DOCMOST_BRANCH) return opts?.docmostSha ?? null;
|
||||
if (ref === 'main') return mainSha;
|
||||
return null;
|
||||
}),
|
||||
diffNameStatus: vi.fn(async (from: string, to: string) => {
|
||||
calls.diffNameStatus.push({ from, to });
|
||||
return opts?.changes ?? [];
|
||||
}),
|
||||
showFileAtRef: vi.fn(async (_ref: string, path: string) =>
|
||||
path in prevTree ? prevTree[path] : null,
|
||||
),
|
||||
updateRef: vi.fn(async (ref: string, target: string) => {
|
||||
calls.updateRef.push({ ref, target });
|
||||
}),
|
||||
fastForwardBranch: vi.fn(async (branch: string, toCommit: string) => {
|
||||
calls.fastForwardBranch.push({ branch, toCommit });
|
||||
return opts?.ffResult ?? { ok: true };
|
||||
}),
|
||||
};
|
||||
return {
|
||||
git,
|
||||
calls,
|
||||
/** Advance the fake `main` HEAD (so a write-back commit yields a new sha). */
|
||||
setMainSha: (sha: string) => {
|
||||
mainSha = sha;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording client fake; createPage returns a configurable assigned id. */
|
||||
function makeClientFake(opts?: { createId?: string }) {
|
||||
return {
|
||||
importPageMarkdown: vi.fn(async () => ({ success: true })),
|
||||
createPage: vi.fn(async (title: string) => ({
|
||||
data: { id: opts?.createId ?? 'assigned-id', title },
|
||||
success: true,
|
||||
})),
|
||||
deletePage: vi.fn(async () => ({ success: true })),
|
||||
movePage: vi.fn(async () => ({ success: true })),
|
||||
renamePage: vi.fn(async () => ({ success: true })),
|
||||
};
|
||||
}
|
||||
|
||||
/** A recording fs fake over a path->text store. */
|
||||
function makeFs(initial: Record<string, string> = {}) {
|
||||
const store: Record<string, string> = { ...initial };
|
||||
const reads: string[] = [];
|
||||
const writes: { path: string; text: string }[] = [];
|
||||
return {
|
||||
store,
|
||||
reads,
|
||||
writes,
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
reads.push(path);
|
||||
if (!(path in store)) throw new Error(`no such file: ${path}`);
|
||||
return store[path];
|
||||
}),
|
||||
writeFile: vi.fn(async (path: string, text: string) => {
|
||||
store[path] = text;
|
||||
writes.push({ path, text });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Assemble PushDeps with a recording logger and a makeClient FACTORY spy. */
|
||||
function makeDeps(
|
||||
git: PushDeps['git'],
|
||||
fs: ReturnType<typeof makeFs>,
|
||||
client?: ReturnType<typeof makeClientFake>,
|
||||
) {
|
||||
const logs: string[] = [];
|
||||
const makeClient = vi.fn(() => (client ?? makeClientFake()) as any);
|
||||
const deps: PushDeps = {
|
||||
settings: makeSettings(),
|
||||
git,
|
||||
makeClient,
|
||||
readFile: fs.readFile,
|
||||
writeFile: fs.writeFile,
|
||||
log: (line) => logs.push(line),
|
||||
};
|
||||
return { deps, logs, makeClient };
|
||||
}
|
||||
|
||||
describe('runPush — dry-run is the DEFAULT (safe)', () => {
|
||||
it('logs a plan, builds NO client, makes ZERO Docmost calls, advances NO refs', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nedited body\n';
|
||||
const { git, calls } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const { deps, logs, makeClient } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.mode).toBe('dry-run');
|
||||
expect(res.planned).toEqual({
|
||||
creates: 0,
|
||||
updates: 1,
|
||||
deletes: 0,
|
||||
renamesMoves: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
// The client FACTORY was never invoked -> zero Docmost contact.
|
||||
expect(makeClient).not.toHaveBeenCalled();
|
||||
// No ref advance, no mirror ff.
|
||||
expect(calls.updateRef).toEqual([]);
|
||||
expect(calls.fastForwardBranch).toEqual([]);
|
||||
// A plan WAS logged (counts + the per-item update line).
|
||||
expect(logs.join('\n')).toMatch(/DRY-RUN/);
|
||||
expect(logs.join('\n')).toMatch(/update: p-1 \(Doc\.md\)/);
|
||||
// It still diffs the base against main and works on main.
|
||||
expect(calls.diffNameStatus).toEqual([{ from: LAST_PUSHED_REF, to: 'main' }]);
|
||||
expect(calls.checkout).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('commits the working tree with the local provenance trailer before diffing', async () => {
|
||||
const { git, calls } = makeGit({ lastPushed: 'base-sha' });
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
await runPush(deps, { dryRun: true });
|
||||
|
||||
// The first commit is the human working-tree commit on main (SPEC §7.3).
|
||||
expect(calls.commit[0]).toBe('local: working-tree changes');
|
||||
expect(calls.stageAll).toBeGreaterThanOrEqual(1);
|
||||
const trailerArg = (git.commit as any).mock.calls[0][1];
|
||||
expect(trailerArg.trailers).toEqual(['Docmost-Sync-Source: local']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — --apply is the ONLY write path', () => {
|
||||
it('builds the client, calls applyPushActions, records created pageIds, advances last-pushed', async () => {
|
||||
// A brand-new local file: meta has title + spaceId but NO pageId yet.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
const { git, calls, setMainSha } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
mainSha: 'main-1',
|
||||
changes: [{ status: 'A', path: 'New.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'New.md': newFile });
|
||||
const client = makeClientFake({ createId: 'page-new' });
|
||||
const { deps, makeClient } = makeDeps(git, fs, client);
|
||||
// After the write-back commit, `main` moves to a new commit.
|
||||
(git.commit as any).mockImplementation(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
if (subject === 'local: record created pageIds') setMainSha('main-2');
|
||||
return true;
|
||||
});
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.mode).toBe('apply');
|
||||
// The client factory WAS used and createPage ran (the write path).
|
||||
expect(makeClient).toHaveBeenCalledTimes(1);
|
||||
expect(client.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(res.applied?.created).toBe(1);
|
||||
// The assigned pageId was written back into the file on disk.
|
||||
expect(res.applied?.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new' }]);
|
||||
expect(fs.store['New.md']).toMatch(/page-new/);
|
||||
// A "record created pageIds" commit persisted the write-back.
|
||||
expect(calls.commit).toContain('local: record created pageIds');
|
||||
// last-pushed was advanced — first by the applier (main-1), then re-advanced
|
||||
// to the write-back commit (main-2).
|
||||
const lastPushedAdvances = calls.updateRef.filter(
|
||||
(u) => u.ref === LAST_PUSHED_REF,
|
||||
);
|
||||
expect(lastPushedAdvances.map((u) => u.target)).toEqual(['main-1', 'main-2']);
|
||||
expect(res.divergentDocmost).toBe(false);
|
||||
expect(res.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('ESCALATES a divergent docmost mirror in the write-back branch too (SPEC §5, symmetric)', async () => {
|
||||
// A create -> the pageId is written back and a "record created pageIds"
|
||||
// commit is made, which triggers the write-back-branch ff. Here the applier's
|
||||
// MAIN push ff succeeds (ok) but the WRITE-BACK ff diverges — the write-back
|
||||
// branch must escalate identically to the main branch (set divergentDocmost,
|
||||
// log the same prominent WARNING), so main() exits 1.
|
||||
const newFile = serializeDocmostMarkdownBody(
|
||||
{ version: 1, title: 'New', spaceId: 'sp-1' },
|
||||
'fresh body',
|
||||
);
|
||||
const { git, calls, setMainSha } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
mainSha: 'main-1',
|
||||
changes: [{ status: 'A', path: 'New.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'New.md': newFile });
|
||||
const client = makeClientFake({ createId: 'page-new' });
|
||||
const { deps, logs } = makeDeps(git, fs, client);
|
||||
(git.commit as any).mockImplementation(async (subject: string) => {
|
||||
calls.commit.push(subject);
|
||||
if (subject === 'local: record created pageIds') setMainSha('main-2');
|
||||
return true;
|
||||
});
|
||||
// First ff (applier 7b, main push) is OK; second ff (write-back) DIVERGES.
|
||||
let ffCall = 0;
|
||||
(git.fastForwardBranch as any).mockImplementation(
|
||||
async (branch: string, toCommit: string) => {
|
||||
calls.fastForwardBranch.push({ branch, toCommit });
|
||||
ffCall++;
|
||||
return ffCall === 1
|
||||
? { ok: true }
|
||||
: { ok: false, reason: 'not-fast-forward' };
|
||||
},
|
||||
);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
// The apply still happened, but the write-back divergence is escalated.
|
||||
expect(res.applied?.created).toBe(1);
|
||||
expect(res.divergentDocmost).toBe(true);
|
||||
// The SAME prominent WARNING (DIVERGED + §5) — not a soft warning.
|
||||
expect(logs.join('\n')).toMatch(/WARNING/);
|
||||
expect(logs.join('\n')).toMatch(/DIVERGED/);
|
||||
expect(logs.join('\n')).toMatch(/write-back/);
|
||||
});
|
||||
|
||||
it('an update goes through importPageMarkdown (collab path)', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-9"}\n-->\n\nbody\n';
|
||||
const { git } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const client = makeClientFake();
|
||||
const { deps } = makeDeps(git, fs, client);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-9', file);
|
||||
expect(res.applied?.updated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — merge-in-progress aborts (SPEC §9/§12)', () => {
|
||||
it('stops with a clear message, no diff, no client, no apply', async () => {
|
||||
const { git, calls } = makeGit({ mergeInProgress: true });
|
||||
const fs = makeFs();
|
||||
const { deps, logs, makeClient } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
expect(res.aborted).toBe('merge-in-progress');
|
||||
// Never diffed, never built a client, never checked out / committed.
|
||||
expect(calls.diffNameStatus).toEqual([]);
|
||||
expect(makeClient).not.toHaveBeenCalled();
|
||||
expect(calls.checkout).toEqual([]);
|
||||
expect(logs.join('\n')).toMatch(/unresolved merge/);
|
||||
expect(logs.join('\n')).toMatch(/SPEC §9/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — divergent docmost escalation (SPEC §5)', () => {
|
||||
it('sets the escalation flag and logs a WARNING, but the apply still happened', async () => {
|
||||
const file =
|
||||
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nbody\n';
|
||||
const { git } = makeGit({
|
||||
lastPushed: 'base-sha',
|
||||
changes: [{ status: 'M', path: 'Doc.md' }],
|
||||
// The applier refuses to clobber a divergent mirror.
|
||||
ffResult: { ok: false, reason: 'not-fast-forward' },
|
||||
});
|
||||
const fs = makeFs({ 'Doc.md': file });
|
||||
const client = makeClientFake();
|
||||
const { deps, logs } = makeDeps(git, fs, client);
|
||||
|
||||
const res = await runPush(deps, { dryRun: false });
|
||||
|
||||
// The apply STILL happened (the page was updated)...
|
||||
expect(res.applied?.updated).toBe(1);
|
||||
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
// ...but the divergence is escalated, not silent.
|
||||
expect(res.divergentDocmost).toBe(true);
|
||||
expect(logs.join('\n')).toMatch(/WARNING/);
|
||||
expect(logs.join('\n')).toMatch(/DIVERGED/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runPush — base selection (last-pushed else docmost)', () => {
|
||||
it('uses refs/docmost/last-pushed when it resolves', async () => {
|
||||
const { git, calls } = makeGit({ lastPushed: 'lp-sha' });
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.base).toEqual({
|
||||
ref: LAST_PUSHED_REF,
|
||||
source: 'last-pushed',
|
||||
sha: 'lp-sha',
|
||||
});
|
||||
expect(calls.diffNameStatus[0].from).toBe(LAST_PUSHED_REF);
|
||||
});
|
||||
|
||||
it('falls back to the docmost branch when last-pushed is missing', async () => {
|
||||
const { git, calls } = makeGit({
|
||||
lastPushed: null, // last-pushed does not resolve -> fall back.
|
||||
docmostSha: 'doc-sha',
|
||||
});
|
||||
const fs = makeFs();
|
||||
const { deps } = makeDeps(git, fs);
|
||||
|
||||
const res = await runPush(deps, { dryRun: true });
|
||||
|
||||
expect(res.base).toEqual({
|
||||
ref: DOCMOST_BRANCH,
|
||||
source: 'docmost',
|
||||
sha: 'doc-sha',
|
||||
});
|
||||
// The diff is taken against the docmost mirror branch.
|
||||
expect(calls.diffNameStatus[0].from).toBe(DOCMOST_BRANCH);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user