diff --git a/apps/server/src/integrations/git-sync/http/git-http.service.spec.ts b/apps/server/src/integrations/git-sync/http/git-http.service.spec.ts index 538fd040..791cc829 100644 --- a/apps/server/src/integrations/git-sync/http/git-http.service.spec.ts +++ b/apps/server/src/integrations/git-sync/http/git-http.service.spec.ts @@ -354,6 +354,32 @@ describe('GitHttpService.handle', () => { expect(workspaceId).toBe('ws-1'); }); + it('GET info/refs?service=git-receive-pack streams the backend WITHOUT a cycle/lock (so the follow-up POST never 503-collides)', async () => { + // A push is a TWO-request exchange: GET info/refs?service=git-receive-pack + // (ref advertisement) then POST git-receive-pack (the pack). The info/refs + // request is write-AUTHORIZED (push perms needed to see those refs) but is + // READ-ONLY — it must NOT run ingestExternalPush (a Docmost cycle under the + // per-space lock), or the immediately-following POST collides with the still- + // running cycle and deterministically 503s. It must just stream the backend. + const built = build({ abilityCan: true }); + const { reply } = fakeReply(); + const req = fakeRequest({ + url: '/git/space-1.git/info/refs?service=git-receive-pack', + method: 'GET', + authorization: basic('dev@example.com', 'pw'), + }); + + await built.service.handle(req, reply); + + // Authorized as a write (Manage), but executed as a plain stream. + expect(built.abilityCan).toHaveBeenCalledWith( + SpaceCaslAction.Manage, + SpaceCaslSubject.Page, + ); + expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled(); + expect(built.backend.run).toHaveBeenCalledTimes(1); + }); + it('a push that loses the lock -> 503 with Retry-After and a busy body (headers not written twice)', async () => { const built = build({ abilityCan: true }); // The lock could not be acquired: the receive-pack closure never ran, so the diff --git a/apps/server/src/integrations/git-sync/http/git-http.service.ts b/apps/server/src/integrations/git-sync/http/git-http.service.ts index 7d58b20d..51971b24 100644 --- a/apps/server/src/integrations/git-sync/http/git-http.service.ts +++ b/apps/server/src/integrations/git-sync/http/git-http.service.ts @@ -251,8 +251,17 @@ export class GitHttpService { // response directly to the socket (mirrors the MCP transport pattern). reply.hijack(); - if (serviceKind === 'read') { - // Fetch/clone: stream http-backend directly, no lock (read-only). + // Only the ACTUAL pack-receiving write (POST git-receive-pack) runs under the + // space lock + a Docmost cycle. Everything else streams the http-backend + // directly with NO lock and NO cycle: a fetch/clone (read), AND the + // write-AUTHORIZED but READ-ONLY ref advertisement + // (GET info/refs?service=git-receive-pack). Running a cycle on info/refs is + // both wasteful and HARMFUL — it holds the per-space lock, so the push's + // immediately-following POST git-receive-pack collides with it and 503s + // (a deterministic push failure). Authz already happened above via the gate. + const isReceivePack = + req.method === 'POST' && parsedPath.subpath === 'git-receive-pack'; + if (serviceKind === 'read' || !isReceivePack) { await this.backend.run(backendRequest, rawReq, rawRes); return; }