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 6e8d24175e
commit 6debcb354a
12 changed files with 1655 additions and 14 deletions
+37
View File
@@ -15,6 +15,7 @@ import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers';
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
import { GitHttpService } from './integrations/git-sync/http/git-http.service';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -99,6 +100,23 @@ async function bootstrap() {
},
);
// git smart-HTTP POST bodies use these media types. Register PASSTHROUGH
// content-type parsers so Fastify does NOT buffer/parse them (it would
// otherwise reject the unknown type with 415); the /git handler streams the
// raw Node request (request.raw) to `git http-backend` stdin instead. A
// passthrough parser also bypasses the bodyLimit, so large pushes are not
// truncated (the bytes are never buffered by Fastify).
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser(
[
'application/x-git-upload-pack-request',
'application/x-git-receive-pack-request',
],
(_req, payload, done) => done(null, payload),
);
app
.getHttpAdapter()
.getInstance()
@@ -146,6 +164,25 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
app.enableShutdownHooks();
// git smart-HTTP host (the /git/<spaceId>.git/... subtree). Registered as a
// RAW Fastify route — NOT a Nest controller under the global '/api' prefix —
// so it lives at the ROOT and a single wildcard reliably captures the whole
// multi-segment subtree (avoiding the path-to-regexp v8 wildcard / global-
// prefix-exclude ambiguity in NestJS v11). The handler is resolved from the
// Nest container so all auth/authz/gating still runs. NOTE: Nest middleware
// (DomainMiddleware) does NOT run for this raw root route — it is bound to the
// Nest router under the global '/api' prefix — so request.raw.workspaceId is
// NOT populated here; GitHttpService resolves the workspace itself (mirroring
// DomainMiddleware). The Fastify wildcard '/git/*' captures the multi-segment
// subpath; the handler re-parses req.url itself.
const gitHttpService = app.get(GitHttpService);
app
.getHttpAdapter()
.getInstance()
.all('/git/*', async (request, reply) => {
await gitHttpService.handle(request as any, reply as any);
});
const logger = new Logger('NestApplication');
process.on('unhandledRejection', (reason, promise) => {