feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)

Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 19:55:25 +03:00
committed by claude code agent 227
parent 906c8c6d89
commit 59a6f65f77
12 changed files with 1655 additions and 14 deletions

View File

@@ -0,0 +1,87 @@
// Unit tests for the pure CGI-response helpers used by GitHttpBackendService.
// The header/body split MUST treat the body as binary (Buffer) and never
// stringify it; the Status: header sets the HTTP status (default 200).
import {
parseCgiResponse,
splitCgiBuffer,
} from './git-http-backend.service';
describe('parseCgiResponse', () => {
it('defaults to status 200 with no Status header', () => {
const r = parseCgiResponse('Content-Type: application/x-git-upload-pack-result');
expect(r.statusCode).toBe(200);
expect(r.headers).toEqual([
['Content-Type', 'application/x-git-upload-pack-result'],
]);
});
it('honors a Status header and does not forward it', () => {
const r = parseCgiResponse('Status: 404 Not Found\nContent-Type: text/plain');
expect(r.statusCode).toBe(404);
expect(r.headers).toEqual([['Content-Type', 'text/plain']]);
});
it('parses multiple headers and trims whitespace', () => {
const r = parseCgiResponse(
'Status: 403 Forbidden\r\nContent-Type: text/plain \r\nX-Foo: bar ',
);
expect(r.statusCode).toBe(403);
expect(r.headers).toEqual([
['Content-Type', 'text/plain'],
['X-Foo', 'bar'],
]);
});
it('ignores malformed (colon-less) lines defensively', () => {
const r = parseCgiResponse('Content-Type: text/plain\ngarbage-line\nX-A: b');
expect(r.statusCode).toBe(200);
expect(r.headers).toEqual([
['Content-Type', 'text/plain'],
['X-A', 'b'],
]);
});
it('ignores an out-of-range Status code and keeps the default', () => {
const r = parseCgiResponse('Status: not-a-number\nContent-Type: text/plain');
expect(r.statusCode).toBe(200);
});
it('treats the Status header case-insensitively', () => {
const r = parseCgiResponse('status: 500 Boom');
expect(r.statusCode).toBe(500);
expect(r.headers).toEqual([]);
});
});
describe('splitCgiBuffer', () => {
it('splits on a CRLF blank line and keeps the body as bytes', () => {
const buf = Buffer.concat([
Buffer.from('Status: 200 OK\r\nContent-Type: text/plain\r\n\r\n', 'utf8'),
Buffer.from([0x00, 0x01, 0x02, 0xff]),
]);
const split = splitCgiBuffer(buf);
expect(split).not.toBeNull();
expect(split!.headerText).toBe('Status: 200 OK\r\nContent-Type: text/plain');
expect(Array.from(split!.body)).toEqual([0x00, 0x01, 0x02, 0xff]);
});
it('splits on a bare LF blank line', () => {
const buf = Buffer.from('Content-Type: text/plain\n\nhello', 'utf8');
const split = splitCgiBuffer(buf);
expect(split).not.toBeNull();
expect(split!.headerText).toBe('Content-Type: text/plain');
expect(split!.body.toString('utf8')).toBe('hello');
});
it('returns an empty body when nothing follows the separator', () => {
const buf = Buffer.from('Content-Type: text/plain\r\n\r\n', 'utf8');
const split = splitCgiBuffer(buf);
expect(split).not.toBeNull();
expect(split!.body.length).toBe(0);
});
it('returns null when there is no blank-line separator yet', () => {
const buf = Buffer.from('Content-Type: text/plain\r\nincomplete', 'utf8');
expect(splitCgiBuffer(buf)).toBeNull();
});
});