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:
@@ -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
|
||||
* lives in the engine's `runCycle` (so it can never drift from the engine it
|
||||
|
||||
Reference in New Issue
Block a user