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

@@ -46,7 +46,10 @@ interface Built {
abilityFactory: { createForUser: AnyMock };
abilityCan: AnyMock;
vaultRegistry: { ensureServable: AnyMock };
orchestrator: { ingestExternalPush: AnyMock };
orchestrator: {
ingestExternalPush: AnyMock;
serveReadAdvertisement: AnyMock;
};
backend: { run: AnyMock };
}
@@ -88,7 +91,14 @@ function build(opts: BuildOptions = {}): Built {
};
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 service = new GitHttpService(
@@ -231,6 +241,48 @@ describe('GitHttpService.handle', () => {
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 () => {
const built = build({ selfHosted: false });
const { reply } = fakeReply();