fix(git-sync): kill spurious marker-leaking conflict, concurrent-edit loss, flapping HEAD

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>
This commit is contained in:
claude code agent 227
2026-06-28 22:05:32 +03:00
parent b7e5cb6970
commit b47751349f
16 changed files with 948 additions and 106 deletions

View File

@@ -147,6 +147,86 @@ describe('CollaborationHandler.gitSyncWriteBody (owner-routed body write)', () =
]); ]);
}); });
it('FLUSHES the pending debounced store BEFORE merging so an in-flight edit survives (finding #2)', async () => {
// QA #119 finding #2: the 3-way merge must run against the latest live-doc
// state. A concurrent UI edit that is still in-flight (the store is debounced)
// must be drained into the live doc BEFORE git merges, or git clean-applies and
// the edit is silently dropped — even on a DIFFERENT block. Model the drain via
// the pending-store flush: when it runs, the in-flight block-0 edit lands.
const shared = new Y.Doc();
const frag = shared.getXmlFragment('default');
shared.transact(() => {
frag.insert(
0,
[
{ text: 'alpha', id: 'p1' },
{ text: 'beta', id: 'p2' },
].map((s) => {
const el = new Y.XmlElement('paragraph');
el.setAttribute('id', s.id);
const t = new Y.XmlText();
t.insert(0, s.text);
el.insert(0, [t]);
return el;
}),
);
});
const order: string[] = [];
const debouncer = {
isDebounced: jest.fn(() => true),
executeNow: jest.fn(async () => {
order.push('flush');
// The in-flight client edit to block 0 only lands once the pending store
// is flushed (i.e. the event loop is drained) — BEFORE the merge.
shared.transact(() =>
((frag.get(0) as Y.XmlElement).get(0) as Y.XmlText).insert(5, ' EDIT'),
);
}),
};
const openDirectConnection = jest.fn(async () => ({
transact: async (fn: (doc: Y.Doc) => void) => {
order.push('merge');
fn(shared);
},
disconnect: jest.fn(async () => undefined),
}));
const hocuspocus = { openDirectConnection, debouncer } as any;
const handler = new CollaborationHandler();
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
prosemirrorJson: pmDoc('alpha', 'beta2'), // git changes block 1
baseProsemirrorJson: pmDoc('alpha', 'beta'),
userId: 'svc-user',
});
// The flush ran, and it ran BEFORE the merge transaction.
expect(debouncer.executeNow).toHaveBeenCalledTimes(1);
expect(order).toEqual(['flush', 'merge']);
// Both the in-flight block-0 edit and git's block-1 change survive — the
// pre-flush bug would have produced ['alpha', 'beta2'] (UI edit dropped).
expect(texts(shared.getXmlFragment('default'))).toEqual([
'alpha EDIT',
'beta2',
]);
});
it('does not flush when no store is pending (isDebounced false)', async () => {
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'a', id: 'p1' }]);
const executeNow = jest.fn();
(hocuspocus as any).debouncer = {
isDebounced: jest.fn(() => false),
executeNow,
};
const handler = new CollaborationHandler();
await handler.getHandlers(hocuspocus).gitSyncWriteBody('page.x', {
prosemirrorJson: pmDoc('a', 'b'),
userId: 'svc-user',
});
expect(executeNow).not.toHaveBeenCalled();
expect(texts(shared.getXmlFragment('default'))).toEqual(['a', 'b']);
});
it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => { it('crash-safe: a transform failure never opens the connection or mutates the live doc', async () => {
const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]); const { hocuspocus, shared } = fakeHocuspocus([{ text: 'alpha', id: 'p1' }]);
const before = texts(shared.getXmlFragment('default')); const before = texts(shared.getXmlFragment('default'));

View File

@@ -158,6 +158,24 @@ export class CollaborationHandler {
) )
: null; : null;
// CONCURRENT-EDIT FLUSH (QA #119, finding #2). The 3-way merge below runs
// against the LIVE Y.Doc, so a concurrent UI edit is only preserved if it
// is already part of that doc. A user's edit is debounced before it lands
// (the editor batches; the collab store is debounced up to 10s), so the
// merge could otherwise run against a PRE-EDIT doc: git would then
// clean-apply (no same-block conflict detected) and the in-flight UI edit
// — even on a DIFFERENT block — would be silently dropped.
//
// Flushing the pending debounced store here (a) drains the event loop so a
// just-arrived client Yjs update is applied to the live doc BEFORE we
// merge, and (b) persists the live doc so the merge baseline is current
// even on the doc-reload-from-DB path. After the flush the merge sees the
// latest state, so an edit on a different block is MERGED (not overwritten)
// and a genuine same-block edit is detected as a conflict -> the
// boundary-snapshot in PersistenceExtension pins it to page history
// (recoverable) instead of vanishing silently.
await this.flushPendingStore(hocuspocus, documentName);
// actor:'git-sync' + the service user flow into PersistenceExtension // actor:'git-sync' + the service user flow into PersistenceExtension
// (lastUpdatedSource='git-sync', lastUpdatedById=userId). // (lastUpdatedSource='git-sync', lastUpdatedById=userId).
await this.withYdocConnection( await this.withYdocConnection(
@@ -195,6 +213,33 @@ export class CollaborationHandler {
}; };
} }
/**
* Flush any pending DEBOUNCED store for `documentName` so the live Y.Doc and the
* DB are current BEFORE a git-sync merge reads them (QA #119, finding #2 —
* concurrent UI edit silently lost). Mirrors the PersistenceExtension.onDisconnect
* flush: only acts when a store is actually pending (`isDebounced`), runs the
* SAME scheduled payload (`executeNow`, preserving the edit's context/actor), and
* never throws — a flush failure must not abort the git-sync write. Awaiting it
* also drains the event loop, so a client Yjs update sitting in the socket buffer
* is applied to the live doc before the merge transaction runs.
*/
private async flushPendingStore(
hocuspocus: Hocuspocus,
documentName: string,
): Promise<void> {
const debounceId = `onStoreDocument-${documentName}`;
try {
const debouncer = (hocuspocus as any)?.debouncer;
if (!debouncer?.isDebounced?.(debounceId)) return;
await debouncer.executeNow(debounceId);
} catch (err) {
this.logger.warn(
`git-sync pre-merge flush failed for ${documentName}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
async withYdocConnection( async withYdocConnection(
hocuspocus: Hocuspocus, hocuspocus: Hocuspocus,
documentName: string, documentName: string,

View File

@@ -152,8 +152,18 @@ describe('git-sync callout type fidelity (QA "callout type -> [!info]")', () =>
}); });
} }
it('maps a known GitHub/Obsidian alias to the editor banner (tip -> success)', async () => {
// `tip` is not a schema callout type — it is an input alias the editor itself
// maps onto the supported set (GITHUB_ALERT_TYPE_MAP: tip -> success). git-sync
// mirrors that so the ingest lands on the closest banner instead of flatly info.
const content = editorPage('tip');
const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe('success');
});
it('flattens a genuinely unknown callout type to info', async () => { it('flattens a genuinely unknown callout type to info', async () => {
const content = editorPage('tip'); // not an editor-canonical type const content = editorPage('banana'); // not a type and not a known alias
const gitContent = await gitRoundTrip(content); const gitContent = await gitRoundTrip(content);
const co = gitContent.find((b: any) => b.type === 'callout'); const co = gitContent.find((b: any) => b.type === 'callout');
expect(co?.attrs?.type).toBe('info'); expect(co?.attrs?.type).toBe('info');

View File

@@ -46,7 +46,10 @@ interface Built {
abilityFactory: { createForUser: AnyMock }; abilityFactory: { createForUser: AnyMock };
abilityCan: AnyMock; abilityCan: AnyMock;
vaultRegistry: { ensureServable: AnyMock }; vaultRegistry: { ensureServable: AnyMock };
orchestrator: { ingestExternalPush: AnyMock }; orchestrator: {
ingestExternalPush: AnyMock;
serveReadAdvertisement: AnyMock;
};
backend: { run: AnyMock }; backend: { run: AnyMock };
} }
@@ -88,7 +91,14 @@ function build(opts: BuildOptions = {}): Built {
}; };
const vaultRegistry = { ensureServable: jest.fn(async () => undefined) }; const vaultRegistry = { ensureServable: jest.fn(async () => undefined) };
const orchestrator = { ingestExternalPush: jest.fn(async () => undefined) }; const orchestrator = {
ingestExternalPush: jest.fn(async () => undefined),
// The read-advertisement wrapper pins HEAD under the lock then serves; the
// mock just runs the serve callback so the read path still hits backend.run.
serveReadAdvertisement: jest.fn(
async (_spaceId: string, serve: () => Promise<void>) => serve(),
),
};
const backend = { run: jest.fn(async () => undefined) }; const backend = { run: jest.fn(async () => undefined) };
const service = new GitHttpService( const service = new GitHttpService(
@@ -231,6 +241,48 @@ describe('GitHttpService.handle', () => {
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled(); expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
}); });
it('upload-pack ref advertisement is served HEAD-pinned via serveReadAdvertisement (bug #3)', async () => {
// GET info/refs?service=git-upload-pack carries the HEAD symref a clone reads
// for its default branch, so it must be served with HEAD pinned to `main`
// (under the lock) — not streamed raw — or a clone racing a mid-pull cycle
// would default to the read-only `docmost` mirror.
const built = build({ abilityCan: true });
const { reply } = fakeReply();
const req = fakeRequest({
url: '/git/space-1.git/info/refs?service=git-upload-pack',
method: 'GET',
authorization: basic('dev@example.com', 'pw'),
});
await built.service.handle(req, reply);
expect(built.orchestrator.serveReadAdvertisement).toHaveBeenCalledTimes(1);
expect(built.orchestrator.serveReadAdvertisement.mock.calls[0][0]).toBe(
'space-1',
);
// The wrapper still streams the backend (the mock runs the serve callback).
expect(built.backend.run).toHaveBeenCalledTimes(1);
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
});
it('a POST git-upload-pack pack fetch streams directly (no HEAD-pin needed, resolved by SHA)', async () => {
// The pack negotiation is object-SHA based; only the ref advertisement carries
// the HEAD symref, so the pack POST streams the backend directly (no lock).
const built = build({ abilityCan: true });
const { reply } = fakeReply();
const req = fakeRequest({
url: '/git/space-1.git/git-upload-pack',
method: 'POST',
authorization: basic('dev@example.com', 'pw'),
});
await built.service.handle(req, reply);
expect(built.orchestrator.serveReadAdvertisement).not.toHaveBeenCalled();
expect(built.backend.run).toHaveBeenCalledTimes(1);
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
});
it('cloud deployment resolves the workspace by the host subdomain', async () => { it('cloud deployment resolves the workspace by the host subdomain', async () => {
const built = build({ selfHosted: false }); const built = build({ selfHosted: false });
const { reply } = fakeReply(); const { reply } = fakeReply();

View File

@@ -376,7 +376,25 @@ export class GitHttpService implements OnModuleDestroy {
const isReceivePack = const isReceivePack =
req.method === 'POST' && parsedPath.subpath === 'git-receive-pack'; req.method === 'POST' && parsedPath.subpath === 'git-receive-pack';
if (serviceKind === 'read' || !isReceivePack) { if (serviceKind === 'read' || !isReceivePack) {
await this.backend.run(backendRequest, rawReq, rawRes); // The clone's default branch comes from the HEAD symref advertised by the
// upload-pack ref advertisement (or a dumb `GET HEAD`). The engine
// transiently checks out the read-only `docmost` mirror mid-cycle, so serve
// THAT advertisement with HEAD pinned to `main` under the per-space lock so
// a clone never defaults to `docmost` (bug #3). Pack streaming and every
// other read are resolved by object SHA and need no pin, so they stream
// directly (no lock) as before.
const isReadAdvertise =
req.method === 'GET' &&
((parsedPath.subpath === 'info/refs' &&
service === 'git-upload-pack') ||
parsedPath.subpath === 'HEAD');
if (isReadAdvertise) {
await this.orchestrator.serveReadAdvertisement(spaceId, () =>
this.backend.run(backendRequest, rawReq, rawRes),
);
} else {
await this.backend.run(backendRequest, rawReq, rawRes);
}
return; return;
} }

View File

@@ -118,6 +118,7 @@ function build(opts: BuildOptions = {}): Built {
ensureBranch: jest.fn(async () => undefined), ensureBranch: jest.fn(async () => undefined),
checkout: jest.fn(async () => undefined), checkout: jest.fn(async () => undefined),
listTrackedFiles: jest.fn(async () => []), listTrackedFiles: jest.fn(async () => []),
pinHeadToMain: jest.fn(async () => undefined),
...(vaultOverrides as Record<string, AnyMock>), ...(vaultOverrides as Record<string, AnyMock>),
}; };
const vaultRegistry = { const vaultRegistry = {
@@ -380,6 +381,11 @@ describe('GitSyncOrchestrator', () => {
expect(order).toEqual(['receive-pack', 'cycle']); expect(order).toEqual(['receive-pack', 'cycle']);
}); });
// Explicit timeout: ingestExternalPush exhausts the full bounded
// acquire-retry budget (GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS = 5_000ms) before it
// gives up and throws, which races jest's DEFAULT 5_000ms test timeout — flaky
// on a loaded/slow runner. Give it headroom so it deterministically observes
// the eventual LockHeldError instead of timing out first.
it('throws GitSyncLockHeldError and does NOT run the receive-pack when the lock is held', async () => { it('throws GitSyncLockHeldError and does NOT run the receive-pack when the lock is held', async () => {
const built = build(); const built = build();
built.redis.set.mockResolvedValue(null); // acquire fails → lock-held built.redis.set.mockResolvedValue(null); // acquire fails → lock-held
@@ -392,7 +398,7 @@ describe('GitSyncOrchestrator', () => {
// We must never write to the working tree concurrently with a cycle. // We must never write to the working tree concurrently with a cycle.
expect(runReceivePack).not.toHaveBeenCalled(); expect(runReceivePack).not.toHaveBeenCalled();
expect(runCycleMock).not.toHaveBeenCalled(); expect(runCycleMock).not.toHaveBeenCalled();
}); }, 15_000);
it('swallows a post-push cycle error (the push is durable; poll retries)', async () => { it('swallows a post-push cycle error (the push is durable; poll retries)', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
@@ -444,6 +450,37 @@ describe('GitSyncOrchestrator', () => {
}); });
}); });
describe('serveReadAdvertisement (bug #3 — stable advertised HEAD)', () => {
it('pins HEAD to main and serves under the space lock', async () => {
const built = build();
const serve = jest.fn(async () => undefined);
await built.orchestrator.serveReadAdvertisement('space-1', serve);
// The lock was taken (redis SET NX) and released (CAS eval).
expect(built.redis.set).toHaveBeenCalledTimes(1);
expect(built.redis.eval).toHaveBeenCalled();
// HEAD pinned BEFORE serving, on the right vault.
expect(built.vaultRegistry.getVault).toHaveBeenCalledWith('space-1');
expect(built.vault.pinHeadToMain).toHaveBeenCalledTimes(1);
expect(serve).toHaveBeenCalledTimes(1);
const pinOrder = built.vault.pinHeadToMain.mock.invocationCallOrder[0];
const serveOrder = serve.mock.invocationCallOrder[0];
expect(pinOrder).toBeLessThan(serveOrder);
});
it('serves WITHOUT a pin/lock when git-sync is globally disabled', async () => {
const built = build({ enabled: false });
const serve = jest.fn(async () => undefined);
await built.orchestrator.serveReadAdvertisement('space-1', serve);
expect(serve).toHaveBeenCalledTimes(1);
expect(built.redis.set).not.toHaveBeenCalled();
expect(built.vault.pinHeadToMain).not.toHaveBeenCalled();
});
});
describe('module lifecycle', () => { describe('module lifecycle', () => {
it('registers exactly one interval on init and tears it down idempotently on destroy', () => { it('registers exactly one interval on init and tears it down idempotently on destroy', () => {
const built = build(); const built = build();

View File

@@ -305,6 +305,53 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
} }
} }
/**
* Serve a git smart-HTTP READ ADVERTISEMENT (`GET info/refs?service=git-upload-pack`
* or a dumb `GET HEAD`) with the repo's symbolic `HEAD` deterministically pinned
* to `main` (bug #3). The advertised `HEAD` symref decides a clone's default
* branch; the engine transiently checks out the read-only `docmost` mirror during
* a cycle, so an unsynchronized advertisement could route a clone to `docmost`
* (~1/4 of clones under continuous syncing).
*
* Running the pin + the advertisement under the SAME per-space lock the cycle
* uses guarantees no cycle is mid-flight while we pin (HEAD cannot flap) and that
* the pin never corrupts a cycle's checkout. The advertisement is cheap (a ref
* listing, no pack stream), so holding the lock for it is fine. A bounded
* retry-acquire absorbs a brief overlap with a cycle; if the lock still cannot be
* taken (a long cycle), we fall back to serving WITHOUT the pin — the cycle's
* finally-restore leaves HEAD on `main` between cycles, so the advertisement is
* still almost always correct (degrades only under sustained contention).
*/
async serveReadAdvertisement(
spaceId: string,
serve: () => Promise<void>,
): Promise<void> {
if (!this.environmentService.isGitSyncEnabled()) {
await serve();
return;
}
const result = await this.spaceLock.withSpaceLock(
spaceId,
async () => {
const vault = await this.vaultRegistry.getVault(spaceId);
await vault.pinHeadToMain();
await serve();
},
{
acquireRetry: {
timeoutMs: GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS,
baseMs: GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS,
maxMs: GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS,
},
},
);
// Lock contended for the whole budget (in-progress / another replica): serve
// anyway. `serve` (backend.run) never ran inside the lock in this case.
if (typeof result === 'object' && result !== null && 'skipped' in result) {
await serve();
}
}
/** /**
* Drive ONE reconcile cycle for a space. The PULL->PUSH branch choreography * Drive ONE reconcile cycle for a space. The PULL->PUSH branch choreography
* lives in the engine's `runCycle` (so it can never drift from the engine it * lives in the engine's `runCycle` (so it can never drift from the engine it

View File

@@ -1,4 +1,4 @@
import { VaultGit } from "./git.js"; import { VaultGit, DEFAULT_BRANCH } from "./git.js";
import { GitSyncClient } from "./client.types.js"; import { GitSyncClient } from "./client.types.js";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
import { readExisting, computePullActions, applyPullActions } from "./pull.js"; import { readExisting, computePullActions, applyPullActions } from "./pull.js";
@@ -142,67 +142,87 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
} }
} }
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring). try {
await vault.ensureBranch("docmost", "main"); // 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
await vault.checkout("docmost"); await vault.ensureBranch("docmost", "main");
await vault.checkout("docmost");
// 4. PULL -------------------------------------------------------------------- // 4. PULL ------------------------------------------------------------------
const existing = await readExisting({ const existing = await readExisting({
listTracked: () => vault.listTrackedFiles("*.md"), listTracked: () => vault.listTrackedFiles("*.md"),
readFile: (relPath) => safeFs.readFile(abs(relPath)), readFile: (relPath) => safeFs.readFile(abs(relPath)),
}); });
const tree = await client.listSpaceTree(spaceId); const tree = await client.listSpaceTree(spaceId);
const pullActions = computePullActions({ const pullActions = computePullActions({
pages: tree.pages, pages: tree.pages,
treeComplete: tree.complete, treeComplete: tree.complete,
existing, existing,
}); });
// Bail before the first destructive write phase if the lock was lost. // Bail before the first destructive write phase if the lock was lost.
signal?.throwIfAborted(); signal?.throwIfAborted();
const pullResult = await applyPullActions( const pullResult = await applyPullActions(
{ {
client, client,
git: vault,
writeFile: (absPath, text) => safeFs.writeFile(absPath, text),
mkdir: (absDir) => safeFs.mkdir(absDir),
rm: (absPath) => safeFs.rm(absPath),
log,
},
pullActions,
vaultRoot,
);
// 5. PUSH ------------------------------------------------------------------
const pushDeps = {
settings,
git: vault, git: vault,
writeFile: (absPath, text) => safeFs.writeFile(absPath, text), makeClient: () => client,
mkdir: (absDir) => safeFs.mkdir(absDir), readFile: (relPath: string) => safeFs.readFile(abs(relPath)),
rm: (absPath) => safeFs.rm(absPath), writeFile: (relPath: string, text: string) =>
safeFs.writeFile(abs(relPath), text),
log, log,
}, };
pullActions,
vaultRoot,
);
// 5. PUSH -------------------------------------------------------------------- // Bail before pushing to Docmost if the lock was lost during pull.
const pushDeps = { signal?.throwIfAborted();
settings,
git: vault,
makeClient: () => client,
readFile: (relPath: string) => safeFs.readFile(abs(relPath)),
writeFile: (relPath: string, text: string) => safeFs.writeFile(abs(relPath), text),
log,
};
// Bail before pushing to Docmost if the lock was lost during pull. const pushResult = await runPush(pushDeps, { dryRun: false });
signal?.throwIfAborted();
const pushResult = await runPush(pushDeps, { dryRun: false }); return {
ran: true,
return { pull: {
ran: true, written: pullResult.written,
pull: { deleted: pullResult.deleted,
written: pullResult.written, conflict: pullResult.merge.conflict,
deleted: pullResult.deleted, },
conflict: pullResult.merge.conflict, push: {
}, mode: pushResult.mode,
push: { failures: pushResult.failures?.length ?? 0,
mode: pushResult.mode, },
failures: pushResult.failures?.length ?? 0, // Forward a divergent-`docmost` escalation so the caller can act on the §5
}, // invariant breach without scraping logs (red-team #15).
// Forward a divergent-`docmost` escalation so the caller can act on the §5 divergentDocmost: pushResult.divergentDocmost ?? false,
// invariant breach without scraping logs (red-team #15). };
divergentDocmost: pushResult.divergentDocmost ?? false, } finally {
}; // STABLE SERVED HEAD (bug #3). The pull transiently checks out the read-only
// `docmost` mirror, and the smart-HTTP host advertises whatever HEAD resolves
// to — so a clone racing a cycle could default to `docmost`. The happy path
// already ends on `main` (runPush), but a throw mid-pull would leave HEAD on
// `docmost`; restore it here so the advertised default branch is `main` BETWEEN
// cycles. Best-effort: skipped if the lock was lost (do not write the working
// tree after a possible takeover), and a failing checkout (e.g. a dirty tree
// from an aborted write) is swallowed — the next cycle's recovery resyncs and
// the read advertisement pins HEAD under the lock regardless.
if (!signal?.aborted) {
try {
await vault.checkout(DEFAULT_BRANCH);
} catch {
/* best-effort: next cycle recovers; advertisement pins HEAD under lock */
}
}
}
} }

View File

@@ -683,6 +683,43 @@ export class VaultGit {
if (r.code !== 0) return null; if (r.code !== 0) return null;
return r.stdout; return r.stdout;
} }
/**
* Read ONE side of a conflicted file from the merge index (`git show :N:path`),
* where the stage `N` is the standard 3-way merge slot:
* 1 = merge BASE (common ancestor), 2 = OURS (the current branch = `main`),
* 3 = THEIRS (the merged-in branch = `docmost`).
* Returns the blob text, or `null` when that stage is absent (e.g. an add/add
* conflict has no base, a modify/delete conflict has only one content side).
*
* Used by the pull cycle (SPEC §9) to RESOLVE a conflicted docmost->main merge
* deterministically instead of committing raw conflict markers onto the
* published `main`: a conflict whose two sides differ ONLY in trailing/empty
* lines is SPURIOUS (normalize -> identical -> clean), and a genuine conflict is
* resolved to a clean side (no `<<<<<<<`/`>>>>>>>` markers ever reach `main`).
*/
async showStage(stage: 1 | 2 | 3, path: string): Promise<string | null> {
const r = await this.runRaw(["show", `:${stage}:${path}`]);
if (r.code !== 0) return null;
return r.stdout;
}
/**
* Pin the repo's symbolic `HEAD` to `main` WITHOUT touching the working tree or
* index (`git symbolic-ref HEAD refs/heads/main`). The smart-HTTP host advertises
* whatever `HEAD` resolves to as the clone's default branch, so a clone that
* races a cycle mid-pull (when the engine has transiently checked out the
* read-only `docmost` mirror) would otherwise default to `docmost`. Pinning HEAD
* back to the canonical writable branch makes the advertised symref deterministic.
*
* symbolic-ref only rewrites `.git/HEAD`; it does NOT move the working tree, so
* it must only ever run when the working tree is ALREADY on `main` (between
* cycles / under the per-space lock with no cycle in flight) — otherwise HEAD and
* the index would desync. Callers serialize this with the engine via the lock.
*/
async pinHeadToMain(): Promise<void> {
await this.run(["symbolic-ref", "HEAD", `refs/heads/${DEFAULT_BRANCH}`]);
}
} }
/** /**

View File

@@ -65,6 +65,26 @@ function relToAbs(vaultRoot: string, relPath: string): string {
return [vaultRoot, ...relPath.split("/")].join("/"); return [vaultRoot, ...relPath.split("/")].join("/");
} }
/**
* Canonicalize a file's TRAILING whitespace: drop any trailing blank /
* whitespace-only lines (and trailing spaces on the last line) and end with
* exactly one newline; an empty body becomes a single "\n". This matches
* `serializePageFile`'s trailing form (`body.trim()` + a single "\n").
*
* Why (SPEC §9 spurious-conflict fix): the engine writes pages in their
* normalize-on-write form (one trailing newline), but a user can push a `.md` to
* `main` with EXTRA trailing/empty lines (e.g. a double-blank-line append). When
* the docmost mirror (normalized) and `main` (raw) both change near end-of-file,
* git's line-based 3-way merge reports a CONFLICT even though the only difference
* is trailing blank lines. Normalizing BOTH sides before comparing collapses that
* difference to nothing, so the pull cycle can recognize the conflict as SPURIOUS
* and resolve it cleanly instead of committing raw conflict markers onto `main`.
*/
function normalizeTrailingWhitespace(text: string): string {
const body = text.replace(/[\s]+$/, "");
return body.length > 0 ? `${body}\n` : "\n";
}
/** Convert an absolute/relative segment list under the vault to a relPath. */ /** Convert an absolute/relative segment list under the vault to a relPath. */
function segmentsToRelPath(segments: string[], stem: string): string { function segmentsToRelPath(segments: string[], stem: string): string {
return [...segments, `${stem}.md`].join("/"); return [...segments, `${stem}.md`].join("/");
@@ -226,6 +246,7 @@ export interface ApplyPullActionsDeps {
| "merge" | "merge"
| "listUnmergedPaths" | "listUnmergedPaths"
| "commitMerge" | "commitMerge"
| "showStage"
>; >;
/** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */ /** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */
writeFile: (absPath: string, text: string) => Promise<void>; writeFile: (absPath: string, text: string) => Promise<void>;
@@ -249,10 +270,13 @@ export interface ApplyResult {
committed: boolean; committed: boolean;
merge: { ok: boolean; conflict: boolean; output: string }; merge: { ok: boolean; conflict: boolean; output: string };
/** /**
* Vault-relative paths of the page(s) that CONFLICTED in the docmost -> main * Vault-relative paths of the page(s) that had a GENUINE same-block conflict in
* merge and were committed WITH conflict markers (so the rest of the space * the docmost -> main merge and were AUTO-RESOLVED to the git/main side (git
* keeps syncing — SPEC §9 wedge fix). Empty on a clean merge. The push side * wins, SPEC §9) — committed CLEAN, never with raw conflict markers. Empty on a
* isolates these (per-page failure when `autoMergeConflicts` is off). * clean merge AND when the only conflicts were spurious trailing-whitespace
* differences (those are normalized, not reported). Surfaced for logging /
* /status visibility; the docmost-side content stays recoverable via the
* `docmost` branch + page history.
*/ */
conflictedPaths: string[]; conflictedPaths: string[];
} }
@@ -422,32 +446,88 @@ export async function applyPullActions(
// Merge docmost -> main. A CONFLICT must NOT wedge the whole space (the // Merge docmost -> main. A CONFLICT must NOT wedge the whole space (the
// reported bug: ONE same-line conflict on ONE page froze sync for EVERY page // reported bug: ONE same-line conflict on ONE page froze sync for EVERY page
// in both directions because the next cycle's `isMergeInProgress` check kept // in both directions because the next cycle's `isMergeInProgress` check kept
// skipping the entire space). So instead of leaving the vault mid-merge, we // skipping the entire space). It must ALSO never commit raw `<<<<<<<`/`>>>>>>>`
// COMMIT the conflicted merge with markers in place (SPEC §9 wedge fix): the // markers onto the published `main` (round-1 round-2: external clones would see
// cleanly-merged pages land, the conflicted page carries its markers on `main` // the markers AND the body re-conflicts every cycle while git and Docmost
// and is isolated by the push side (a per-page failure when `autoMergeConflicts` // silently diverge). So on a conflict we RESOLVE each conflicted file to a
// is off — the markers never reach Docmost), and the NEXT cycle is NOT wedged. // clean, marker-free form and commit that (SPEC §9):
// Recovery: resolve the markers in git; the next push then sends the clean body. //
// - SPURIOUS conflict — the ROOT CAUSE of the leak: the two sides differ ONLY
// in trailing/empty-line normalization (the engine writes one trailing
// newline; a user pushed extra blank lines). Once both sides are
// `normalizeTrailingWhitespace`d they are IDENTICAL, so this is no real
// conflict at all: write the normalized form. Content stays in sync; git
// and the page never diverge.
// - GENUINE same-block conflict: resolve to OURS (the `main`/git side), so git
// wins the published branch — mirroring the live-doc 3-way "git wins" rule.
// The docmost-side content is preserved on the `docmost` branch and remains
// recoverable via page history; the next push carries git's body to Docmost,
// so both sides converge. No markers ever reach `main`.
await git.checkout(DEFAULT_BRANCH); await git.checkout(DEFAULT_BRANCH);
const merge = await git.merge(DOCMOST_BRANCH); const merge = await git.merge(DOCMOST_BRANCH);
let conflictedPaths: string[] = []; let conflictedPaths: string[] = [];
let mergeResult = merge;
if (merge.conflict) { if (merge.conflict) {
conflictedPaths = await git.listUnmergedPaths(); const unmerged = await git.listUnmergedPaths();
const genuine: string[] = [];
for (const rel of unmerged) {
const ours = await git.showStage(2, rel); // main side
const theirs = await git.showStage(3, rel); // docmost side
if (
ours !== null &&
theirs !== null &&
normalizeTrailingWhitespace(ours) === normalizeTrailingWhitespace(theirs)
) {
// SPURIOUS: identical once trailing/empty-line normalization is applied.
// Commit the canonical (normalized) form — no conflict, no markers.
await deps.writeFile(
relToAbs(vaultRoot, rel),
normalizeTrailingWhitespace(theirs),
);
} else {
// GENUINE conflict: resolve to the non-null side (OURS preferred so git
// wins the published branch; THEIRS kept when OURS is absent — e.g. a
// modify/delete conflict — to avoid dropping the remaining content). If
// BOTH are null (delete/delete) leave it; commitMerge's `git add -A`
// stages the deletion.
genuine.push(rel);
const resolved = ours ?? theirs;
if (resolved !== null) {
await deps.writeFile(relToAbs(vaultRoot, rel), resolved);
}
}
}
conflictedPaths = genuine;
await git.commitMerge( await git.commitMerge(
`docmost: sync with unresolved conflict in ${conflictedPaths.length} page(s)`, genuine.length > 0
? `docmost: sync, ${genuine.length} page(s) auto-resolved (git wins, SPEC §9)`
: `docmost: sync (trailing-whitespace conflicts normalized, SPEC §9)`,
{ {
authorName: BOT_AUTHOR_NAME, authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL, authorEmail: BOT_AUTHOR_EMAIL,
trailers: [SOURCE_TRAILER], trailers: [SOURCE_TRAILER],
}, },
); );
log( // The committed tree is CLEAN (every conflicted file was overwritten with a
`pull: merge of docmost -> main CONFLICTED on ${conflictedPaths.length} ` + // marker-free resolution). `conflict` now reflects only the GENUINE conflicts
`page(s): ${conflictedPaths.join(", ")}. Committed the merge WITH ` + // that were auto-resolved (git won); a merge that conflicted ONLY on trailing
`conflict markers so the rest of the space keeps syncing (SPEC §9). The ` + // whitespace is reported as clean so /status does not cry wolf.
`conflicted page(s) are isolated on push (markers never reach Docmost); ` + mergeResult = { ok: true, conflict: genuine.length > 0, output: merge.output };
`resolve the markers in git to recover.`, if (genuine.length > 0) {
); log(
`pull: merge of docmost -> main had ${genuine.length} GENUINE conflict(s) ` +
`auto-resolved to the git/main side (git wins, SPEC §9): ` +
`${genuine.join(", ")}. NO conflict markers were written to main; the ` +
`docmost-side content is on the 'docmost' branch and recoverable via ` +
`page history, and the next push reconciles Docmost to the git body.`,
);
} else {
log(
`pull: merge of docmost -> main conflicted ONLY on trailing/empty-line ` +
`normalization (${unmerged.length} file(s)) — auto-normalized, no ` +
`markers, content stays in sync (SPEC §9 spurious-conflict fix).`,
);
}
} else if (!merge.ok) { } else if (!merge.ok) {
log(`pull: merge of docmost -> main failed: ${merge.output}`); log(`pull: merge of docmost -> main failed: ${merge.output}`);
} }
@@ -459,7 +539,7 @@ export async function applyPullActions(
deleted, deleted,
failed, failed,
committed, committed,
merge, merge: mergeResult,
conflictedPaths, conflictedPaths,
}; };
} }

View File

@@ -59,12 +59,43 @@ function getStyleProperty(element: HTMLElement, propertyName: string): string |
* `[!note]` / `[!default]` callout authored in the editor would come back as * `[!note]` / `[!default]` callout authored in the editor would come back as
* `[!info]` after a git sync (the QA "callout type -> [!info]" fidelity loss). * `[!info]` after a git sync (the QA "callout type -> [!info]" fidelity loss).
* `note` and `default` were previously absent and so were being flattened. * `note` and `default` were previously absent and so were being flattened.
*
* The editor SCHEMA genuinely only supports these six banner types — there is no
* `tip`/`caution`/`important`/`question` callout node. So those are NOT first-
* class types we can round-trip literally; they are INPUT ALIASES (GitHub/Obsidian
* alert syntax). The editor's own paste/import path maps them onto the supported
* set (see `GITHUB_ALERT_TYPE_MAP` in
* `@docmost/editor-ext` markdown/utils/github-callout.marked.ts:
* tip -> success, caution -> danger, important -> info). We mirror that aliasing
* here so an ingested `> [!tip]` / `> [!caution]` lands on the closest real banner
* (success / danger) instead of flatly collapsing to `info` — matching exactly how
* the editor itself would interpret the same alias. A schema type always maps to
* itself first (idempotent round-trip); the alias map only rewrites NON-schema
* names; anything still unknown falls back to `info`.
*/ */
const CALLOUT_TYPES = ["default", "info", "note", "success", "warning", "danger"]; const CALLOUT_TYPES = ["default", "info", "note", "success", "warning", "danger"];
export const clampCalloutType = (value: string | null | undefined): string => /**
value && CALLOUT_TYPES.includes(value.toLowerCase()) * NON-schema callout aliases -> their closest supported banner. Mirrors the
? value.toLowerCase() * editor's `GITHUB_ALERT_TYPE_MAP` for the names that are NOT already schema
: "info"; * types (a schema type is preserved as-is and never consulted here). Keeping
* these in lockstep means git-sync ingest and an editor paste interpret the same
* `> [!alias]` identically.
*/
const CALLOUT_TYPE_ALIASES: Record<string, string> = {
tip: "success",
caution: "danger",
important: "info",
};
export const clampCalloutType = (value: string | null | undefined): string => {
if (!value) return "info";
const lower = value.toLowerCase();
// A real schema type round-trips to itself (idempotent).
if (CALLOUT_TYPES.includes(lower)) return lower;
// A known GitHub/Obsidian alias maps to the editor's closest banner.
if (CALLOUT_TYPE_ALIASES[lower]) return CALLOUT_TYPE_ALIASES[lower];
// Anything else is collapsed to the safe default (matches the editor).
return "info";
};
/** /**
* Allowlist guard for CSS color values imported from HTML. * Allowlist guard for CSS color values imported from HTML.

View File

@@ -44,12 +44,24 @@ function makeClient(opts?: { failFor?: Set<string> }) {
} }
/** A git fake recording the order of ops; merge result is configurable. */ /** A git fake recording the order of ops; merge result is configurable. */
function makeGit(merge: { ok: boolean; conflict: boolean; output?: string } = { function makeGit(
ok: true, merge: { ok: boolean; conflict: boolean; output?: string } = {
conflict: false, ok: true,
}) { conflict: false,
},
conflictStages?: {
unmerged?: string[];
/** path -> { ours, theirs } blob content for showStage(2|3, path). */
stages?: Record<string, { ours: string | null; theirs: string | null }>;
},
) {
const order: string[] = []; const order: string[] = [];
let committedSubject: string | undefined; let committedSubject: string | undefined;
const unmerged = conflictStages?.unmerged ?? ['Conflicted.md'];
// Default stages: genuinely-different ours/theirs (a real same-block conflict).
const stages = conflictStages?.stages ?? {
'Conflicted.md': { ours: 'git side\n', theirs: 'docmost side\n' },
};
const git = { const git = {
stageAll: vi.fn(async () => { stageAll: vi.fn(async () => {
order.push('stageAll'); order.push('stageAll');
@@ -66,7 +78,12 @@ function makeGit(merge: { ok: boolean; conflict: boolean; output?: string } = {
order.push('merge'); order.push('merge');
return { ok: merge.ok, conflict: merge.conflict, output: merge.output ?? '' }; return { ok: merge.ok, conflict: merge.conflict, output: merge.output ?? '' };
}), }),
listUnmergedPaths: vi.fn(async () => ['Conflicted.md']), listUnmergedPaths: vi.fn(async () => unmerged),
showStage: vi.fn(async (stage: 1 | 2 | 3, path: string) => {
const s = stages[path];
if (!s) return null;
return stage === 2 ? s.ours : stage === 3 ? s.theirs : null;
}),
commitMerge: vi.fn(async (subject: string) => { commitMerge: vi.fn(async (subject: string) => {
order.push(`commitMerge:${subject}`); order.push(`commitMerge:${subject}`);
}), }),
@@ -407,13 +424,22 @@ describe('applyPullActions — commit subject reflects ACTUAL counts', () => {
}); });
describe('applyPullActions — merge result is surfaced, not swallowed', () => { describe('applyPullActions — merge result is surfaced, not swallowed', () => {
it('COMMITS a conflicting merge with markers (no wedge) and surfaces conflictedPaths', async () => { it('GENUINE conflict: auto-resolves to OURS (git wins), no markers, surfaces conflictedPaths', async () => {
// Regression for the WEDGE bug (QA #119): a conflicting docmost -> main merge // QA #119 round-2: a genuine same-block docmost -> main conflict must NOT be
// must NOT be left mid-merge (which wedged the whole space). It is committed // committed with raw markers onto `main` (external clones would see them and
// WITH markers so the rest of the space keeps syncing; the conflicted page is // the body re-conflicts forever). It is auto-resolved to the git/main side
// surfaced in `conflictedPaths` and isolated by the push side. // (git wins, SPEC §9), the conflicted page is surfaced in `conflictedPaths`,
// and the merge is committed CLEAN (no wedge).
const { client } = makeClient(); const { client } = makeClient();
const g = makeGit({ ok: false, conflict: true, output: 'CONFLICT' }); const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT' },
{
unmerged: ['Conflicted.md'],
stages: {
'Conflicted.md': { ours: 'git wins body\n', theirs: 'docmost body\n' },
},
},
);
const fs = makeFs(); const fs = makeFs();
const res = await applyPullActions( const res = await applyPullActions(
@@ -421,14 +447,55 @@ describe('applyPullActions — merge result is surfaced, not swallowed', () => {
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }), actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
VAULT, VAULT,
); );
// A genuine conflict was detected and auto-resolved (git won): reported as a
// (now-clean) committed merge with the conflicting page surfaced.
expect(res.merge.conflict).toBe(true); expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(false); expect(res.merge.ok).toBe(true);
// The merge was COMMITTED (vault no longer mid-merge) and the bad page named.
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
expect(res.conflictedPaths).toEqual(['Conflicted.md']); expect(res.conflictedPaths).toEqual(['Conflicted.md']);
// The conflicted file was rewritten with OURS (git side) — NO markers.
const resolved = fs.writes.find((w) => w.abs === '/vault/Conflicted.md');
expect(resolved?.text).toBe('git wins body\n');
expect(resolved?.text).not.toContain('<<<<<<<');
expect(resolved?.text).not.toContain('>>>>>>>');
// The merge was COMMITTED (vault no longer mid-merge).
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
expect(g.order.some((o) => o.startsWith('commitMerge:'))).toBe(true); expect(g.order.some((o) => o.startsWith('commitMerge:'))).toBe(true);
}); });
it('SPURIOUS conflict (trailing-blank only): normalizes clean, NOT reported as a conflict', async () => {
// Root-cause fix: when the two sides differ ONLY in trailing/empty lines (the
// normalize-on-write form vs a user's blank-line append), the conflict is
// spurious — both normalize to the same text. It is resolved to the normalized
// form (no markers) and NOT counted as a conflict (so /status does not cry wolf).
const { client } = makeClient();
const g = makeGit(
{ ok: false, conflict: true, output: 'CONFLICT' },
{
unmerged: ['Trailing.md'],
stages: {
// Same content; OURS has a double-blank-line append, THEIRS is normalized.
'Trailing.md': { ours: 'Hello world\n\n\n', theirs: 'Hello world\n' },
},
},
);
const fs = makeFs();
const res = await applyPullActions(
deps(client, g.git, fs),
actions({ toWrite: [{ pageId: 'p1', relPath: 'A.md' }] }),
VAULT,
);
// No GENUINE conflict — reported clean.
expect(res.merge.conflict).toBe(false);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([]);
// The file was rewritten to the canonical normalized form (single trailing \n).
const resolved = fs.writes.find((w) => w.abs === '/vault/Trailing.md');
expect(resolved?.text).toBe('Hello world\n');
// Still committed (clears the merge), but as a clean merge.
expect(g.git.commitMerge).toHaveBeenCalledTimes(1);
});
it('returns ok:false conflict:false on a non-conflict merge failure', async () => { it('returns ok:false conflict:false on a non-conflict merge failure', async () => {
const { client } = makeClient(); const { client } = makeClient();
const g = makeGit({ ok: false, conflict: false, output: 'some error' }); const g = makeGit({ ok: false, conflict: false, output: 'some error' });

View File

@@ -77,8 +77,20 @@ describe('clampCalloutType', () => {
expect(clampCalloutType('success')).toBe('success'); expect(clampCalloutType('success')).toBe('success');
}); });
it('maps GitHub/Obsidian alert ALIASES to the editor banner (not flatly info)', () => {
// The editor schema has no tip/caution/important callout node — they are input
// aliases the editor's own paste path maps onto the supported set
// (GITHUB_ALERT_TYPE_MAP in editor-ext). git-sync mirrors that aliasing so an
// ingested `> [!tip]` / `> [!caution]` lands on the closest real banner instead
// of collapsing everything to `info`.
expect(clampCalloutType('tip')).toBe('success');
expect(clampCalloutType('TIP')).toBe('success');
expect(clampCalloutType('caution')).toBe('danger');
expect(clampCalloutType('important')).toBe('info');
});
it('falls back to "info" for genuinely unknown types', () => { it('falls back to "info" for genuinely unknown types', () => {
expect(clampCalloutType('tip')).toBe('info'); expect(clampCalloutType('question')).toBe('info');
expect(clampCalloutType('banana')).toBe('info'); expect(clampCalloutType('banana')).toBe('info');
}); });

View File

@@ -0,0 +1,97 @@
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');
});
});

View File

@@ -307,23 +307,33 @@ describe('import: highlight/textStyle color sanitization (parseHTML)', () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Spec 2. Importing an unsupported callout fence clamps the type to 'info'. // Spec 2. Importing a non-schema callout fence resolves the type via the editor's
// alias map (known GitHub/Obsidian aliases) or clamps to 'info' (unknown).
// //
// preprocessCallouts emits div[data-type=callout][data-callout-type=tip]; the // preprocessCallouts emits div[data-type=callout][data-callout-type=<type>]; the
// schema's Callout.type parseHTML pipes 'tip' through clampCalloutType, which // schema's Callout.type parseHTML pipes it through clampCalloutType. A known alias
// maps the unknown type to the 'info' default. End-to-end import-side clamp. // (`tip`) maps to the editor's banner (`success`); a genuinely unknown type
// (`banana`) clamps to the 'info' default. End-to-end import-side resolution.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('import: unsupported callout fence clamps type to info', () => { describe('import: non-schema callout fence resolves via alias map / clamps to info', () => {
it("imports ':::tip' as a callout whose attrs.type === 'info'", async () => { it("imports ':::tip' as a callout whose attrs.type === 'success' (alias)", async () => {
const doc = await markdownToProseMirror(':::tip\nhello\n:::'); const doc = await markdownToProseMirror(':::tip\nhello\n:::');
const callouts = findAll(doc, 'callout'); const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1); expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('info'); expect(callouts[0].attrs.type).toBe('success');
// The body paragraph survived inside the callout. // The body paragraph survived inside the callout.
expect(allText(callouts[0])).toContain('hello'); expect(allText(callouts[0])).toContain('hello');
const paras = findAll(callouts[0], 'paragraph'); const paras = findAll(callouts[0], 'paragraph');
expect(paras.length).toBeGreaterThanOrEqual(1); expect(paras.length).toBeGreaterThanOrEqual(1);
}); });
it("imports ':::banana' (unknown) as a callout whose attrs.type === 'info'", async () => {
const doc = await markdownToProseMirror(':::banana\nhello\n:::');
const callouts = findAll(doc, 'callout');
expect(callouts).toHaveLength(1);
expect(callouts[0].attrs.type).toBe('info');
expect(allText(callouts[0])).toContain('hello');
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,199 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile, mkdir } 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';
import { applyPullActions, type PullActions } from '../src/engine/pull';
/**
* QA #119 round-2 — the docmost -> main merge must NEVER commit raw conflict
* markers onto the published `main` (external clones would see them and the body
* re-conflicts every cycle while git and the DB silently diverge). These run
* against a REAL temp git repo:
*
* 1. SPURIOUS conflict (the root cause): two sides that differ ONLY in
* trailing/empty lines (normalize-on-write vs a user's blank-line append)
* must NOT conflict — they auto-normalize, no markers, and stay in sync over
* repeated cycles.
* 2. GENUINE same-block conflict: still must not leak raw markers into `main`
* (auto-resolved to the git/main side; the docmost side stays recoverable on
* the `docmost` branch).
*
* 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;
}
}
/** PullActions with everything empty except the given overrides. */
function actions(partial: Partial<PullActions> = {}): PullActions {
return {
toWrite: [],
moved: [],
toDelete: [],
deletionDecision: { apply: true },
existingCount: 0,
plannedDeleteCount: 0,
...partial,
};
}
/** Real-fs/real-git deps for applyPullActions (no client calls when toWrite empty). */
function realDeps(git: VaultGit) {
return {
client: {
getPageJson: async () => {
throw new Error('getPageJson should not be called in these tests');
},
},
git,
writeFile: async (abs: string, text: string) => {
await writeFile(abs, text, 'utf8');
},
mkdir: async (abs: string) => {
await mkdir(abs, { recursive: true });
},
rm: async (abs: string) => {
await rm(abs, { force: true });
},
log: () => {},
};
}
const PAGE = (body: string) => `---\ngitmost_id: p1\n---\n\n${body}`;
describe('pull merge — spurious vs genuine conflict (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 commitOn(git: VaultGit, subject: string): Promise<void> {
await git.stageAll();
await git.commit(subject, {
authorName: BOT_AUTHOR_NAME,
authorEmail: BOT_AUTHOR_EMAIL,
});
}
/**
* Build a repo where `main` and `docmost` have DIVERGED from a shared base on
* the SAME file, so `applyPullActions`'s docmost -> main merge does a real
* 3-way merge. `ours`/`theirs`/`base` are the file BODIES for main/docmost/base.
*/
async function divergedRepo(opts: {
base: string;
ours: string;
theirs: string;
}): Promise<{ vault: string; git: VaultGit; file: string }> {
dir = await mkdtemp(join(tmpdir(), 'docmost-conflict-'));
const git = new VaultGit(dir);
await git.ensureRepo();
await git.ensureBranch('docmost', 'main');
const file = 'Doc.md';
// base commit on main, then re-fork docmost from it (merge-base = base).
await writeFile(join(dir, file), PAGE(opts.base), 'utf8');
await commitOn(git, 'base');
await execFileAsync('git', ['branch', '-f', 'docmost', 'main'], { cwd: dir });
// docmost side.
await git.checkout('docmost');
await writeFile(join(dir, file), PAGE(opts.theirs), 'utf8');
await commitOn(git, 'docmost: change');
// main side (diverges from base too -> a real 3-way merge, not a ff).
await git.checkout('main');
await writeFile(join(dir, file), PAGE(opts.ours), 'utf8');
await commitOn(git, 'local: change');
// The cycle calls applyPullActions while on `docmost`.
await git.checkout('docmost');
return { vault: dir, git, file };
}
it('SPURIOUS: a trailing-blank-only diff does NOT conflict, no markers, stays in sync', async () => {
if (!available) return;
// base ends "World\n\n", main appends another blank, docmost normalizes to one.
const { vault, git, file } = await divergedRepo({
base: 'World\n\n',
ours: 'World\n\n\n',
theirs: 'World\n',
});
const res = await applyPullActions(realDeps(git), actions(), vault);
// No GENUINE conflict reported.
expect(res.merge.conflict).toBe(false);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([]);
// The vault is not wedged mid-merge.
expect(await git.isMergeInProgress()).toBe(false);
// `main` carries the clean normalized body — NO conflict markers.
const onMain = await readFile(join(vault, file), 'utf8');
expect(onMain).not.toContain('<<<<<<<');
expect(onMain).not.toContain('=======');
expect(onMain).not.toContain('>>>>>>>');
expect(onMain).toContain('World');
// A SECOND identical pull cycle is a clean no-op (git and content stay in
// sync — no re-conflict, no churn). docmost is now an ancestor of main.
await git.checkout('docmost');
const res2 = await applyPullActions(realDeps(git), actions(), vault);
expect(res2.merge.conflict).toBe(false);
expect(res2.conflictedPaths).toEqual([]);
const onMain2 = await readFile(join(vault, file), 'utf8');
expect(onMain2).not.toContain('<<<<<<<');
});
it('GENUINE: a same-block content conflict does NOT leak raw markers into main', async () => {
if (!available) return;
const { vault, git, file } = await divergedRepo({
base: 'Original line\n',
ours: 'Edited by GIT\n',
theirs: 'Edited by DOCMOST\n',
});
const res = await applyPullActions(realDeps(git), actions(), vault);
// A genuine conflict is detected + auto-resolved (git wins) — reported, clean.
expect(res.merge.conflict).toBe(true);
expect(res.merge.ok).toBe(true);
expect(res.conflictedPaths).toEqual([file]);
expect(await git.isMergeInProgress()).toBe(false);
const onMain = await readFile(join(vault, file), 'utf8');
// CARDINAL invariant: no raw conflict markers ever on the published main.
expect(onMain).not.toContain('<<<<<<<');
expect(onMain).not.toContain('=======');
expect(onMain).not.toContain('>>>>>>>');
// Git/main side won the published branch.
expect(onMain).toContain('Edited by GIT');
expect(onMain).not.toContain('Edited by DOCMOST');
// The docmost side stays recoverable on the `docmost` branch.
const onDocmost = await git.showFileAtRef('docmost', file);
expect(onDocmost).toContain('Edited by DOCMOST');
});
});