@foss.global/gitmanager

Headless Git manager for the foss.global Git platform.

This package follows the push.rocks Rust-backed package pattern: TypeScript owns the public package API and lifecycle, while Rust owns the Git/storage/protocol engine. TypeScript controls the Rust binary through @push.rocks/smartrust, and builds use @git.zone/tsrust.

The current implementation provides lifecycle IPC, local bare repository and mirror management, bounded read APIs, durable repository bundle transfer, public and trusted-ingress private clone/fetch, trusted-ingress receive-pack, bounded raw Git services for SSH ingress, and revision-fenced protection for exact branch refs. User identity, permissions, SSH/TLS termination, durable protected-ref policy storage, and durable post-push repository storage remain the embedding service's responsibility.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @foss.global/gitmanager

Usage

import { GitManager } from '@foss.global/gitmanager';

const gitManager = new GitManager({
  dataDir: '/var/lib/foss-global/gitmanager',
});

await gitManager.start();
const status = await gitManager.getStatus();
console.log(status.running, status.dataDir);

await gitManager.importMirror({
  org: 'foss.global',
  repo: 'interfaces',
  remoteUrl: 'ssh://git@code.foss.global:29419/foss.global/interfaces.git',
});

const branches = await gitManager.listRefs({
  org: 'foss.global',
  repo: 'interfaces',
  prefixes: ['refs/heads/'],
});
const tree = await gitManager.listTree({ org: 'foss.global', repo: 'interfaces', ref: 'main' });
const readme = await gitManager.readFile({
  org: 'foss.global',
  repo: 'interfaces',
  ref: 'main',
  path: 'readme.md',
});

await gitManager.stop();

Complete ref inventory

listRefs() returns one page of a repository's complete ref inventory as the @foss.global/interfaces TGitManagerRefInventoryResult. Without prefixes every ref under refs/ is listed: branches, tags, notes, pull and keep-around refs, and any other namespace. Each ref carries its object ID and type, the fully peeled object of annotated tags (including nested tags), and the target of symbolic refs. A symbolic ref whose target does not resolve is listed with a null objectId. HEAD is reported separately in head.

import type { IGitManagerRef } from '@foss.global/gitmanager';

const refs: IGitManagerRef[] = [];
let cursor: string | undefined;
while (true) {
  const result = await gitManager.listRefs({ org: 'foss.global', repo: 'interfaces', cursor });
  if (result.status === 'stale') {
    // The selected refs or HEAD changed between pages: start again.
    refs.length = 0;
    cursor = undefined;
    continue;
  }
  refs.push(...result.refs);
  if (result.pagination.status === 'exhausted') break;
  cursor = result.pagination.cursor;
}

// A complete ref name as the only prefix is an exact lookup.
const main = await gitManager.listRefs({
  org: 'foss.global',
  repo: 'interfaces',
  prefixes: ['refs/heads/main'],
});
  • Prefixes are refs/-rooted and match whole path components; overlapping prefixes select their union, and an empty prefixes array is invalid.
  • Pages are sorted by ref name in byte order. limit defaults to 1,000 refs and accepts 1–10,000; a page also ends early before its serialized refs exceed 8 MiB, far below the 50 MiB IPC payload limit.
  • Every page scans the whole selection and carries its inventoryDigest and refCount. The cursor is opaque and bound to the repository, the prefix set, and that digest, so no state is kept between pages and cursors survive an engine restart. A foreign or malformed cursor is rejected.
  • Git output is streamed record by record, so memory is bounded by one page regardless of the repository's ref count. Ref names that are not valid UTF-8 fail the request by name instead of being skipped. The reftable ref storage format is not supported.
  • Measured on 50,000 refs (25,000 annotated tags) with 10,000-ref pages: about 0.9 s for the complete inventory with packed refs and about 9.5 s with loose refs.

Credentialed mirrors and repository size

importMirror() and refreshMirror() take the transfer options of IGitManagerRemoteTransferOptions:

const credential = { kind: 'basic' as const, username: 'mirror-reader', password: accessToken };
await gitManager.importMirror({
  org: 'mirror.org',
  repo: 'interfaces',
  remoteUrl: 'https://forge.example/foss.global/interfaces.git',
  credential,
  caCertificates: [privateCaPem],
  timeoutMs: 30 * 60 * 1000,
});
await gitManager.refreshMirror({ org: 'mirror.org', repo: 'interfaces', credential, caCertificates: [privateCaPem] });

const size = await gitManager.getRepositorySize({ org: 'mirror.org', repo: 'interfaces' });
// { org, repo, objectCount, objectBytes, storageBytes }
  • The credential reaches Git only through the child process environment (GIT_CONFIG_COUNT entries) as an Authorization: Basic header. It never appears in the remote URL, on a command line, in the repository's Git config or in an error: error text replaces the remote URL with <mirror-source> and every form of the credential with <redacted>.
  • A credential requires an https:// remote URL without user information. While it carries one, Git does not follow redirects and reads no global or system Git config, so insteadOf rewrites and credential helpers there cannot take effect.
  • caCertificates replaces Git's CA bundle for this transfer. The PEMs are written to a private file in transferDir that is removed when the transfer ends.
  • timeoutMs is the transfer's wall-clock limit: 120000 by default, at most 21600000 (six hours). When it runs out, the Git process group is ended like an abort: SIGTERM first, so Git removes the ref lock files it holds and the next transfer is not blocked by them, then SIGKILL after five seconds.
  • Both transfers run without credential helpers or hooks, refuse the ext:: transport and check incoming objects.
  • The imported repository's HEAD points at the source's default branch, as git clone --mirror sets it. git fetch never moves HEAD, so refreshMirror() reads the source HEAD with git ls-remote --symref before it fetches and points the mirror's HEAD at the same branch afterwards. A source that advertises no symbolic HEAD (a detached HEAD) leaves the mirror's HEAD unchanged; a source HEAD outside refs/heads/ fails the refresh before anything is fetched. Read the result back from the returned defaultBranch or from listRefs() (head.symbolicTarget).
  • Proxies: a transfer to a loopback http URL (127.0.0.0/8, [::1]) always connects directly. GitManager sets an empty http.proxy for it, so an inherited http_proxy, all_proxy or ALL_PROXY never receives the plain request or its Authorization header. https transfers keep honouring the proxy settings: a proxy only tunnels them (CONNECT), and the header stays inside TLS.
  • getRepositorySize() counts the distinct objects of the repository's own object database (objectCount) and the sum of their uncompressed sizes (objectBytes), which does not depend on packing. storageBytes is the size of every file below objects/. A repository with alternate object stores is refused. Measuring runs for at most 30 minutes.

Cancellation, concurrency and stop

importMirror(), refreshMirror() and pushRefs() take { signal } as their second argument:

const controller = new AbortController();
const refreshing = gitManager.refreshMirror({ org: 'mirror.org', repo: 'interfaces', credential }, { signal: controller.signal });
controller.abort();
await refreshing; // rejects with GitManagerError, code 'transferAborted'
  • Aborting the signal terminates the transfer's Git process (SIGTERM, so Git removes its lock files, then SIGKILL after five seconds) and waits for it to exit before the call rejects with a GitManagerError whose code is transferAborted. A signal that is already aborted rejects before anything reaches the engine. A transfer that completed before the abort reached the engine returns its result. timeoutMs still applies.
  • An aborted importMirror() leaves no repository behind: the partial clone and the CA bundle file are removed, as after any failed import.
  • An aborted refreshMirror() leaves the mirror's refs as they were or, when the abort came after git fetch --atomic committed its ref transaction, fully refreshed; HEAD moves only when the refresh completes. git fetch writes and checks all objects before it updates the refs and never deletes objects. With Git 2.43, --prune deletions are part of the same --atomic ref transaction: when one ref update fails (observed with a foreign lock file on one ref, for packed and for loose refs), no ref is updated and no ref is pruned. The transaction commits by renaming lock files one by one, so an abort landing inside that commit (the same window as a crash) can leave some refs updated or pruned; every ref then points at a complete object, and the next refresh converges.
  • Transfers run as engine tasks next to the management channel: other commands, such as getStatus(), listRepositories() or a command on another repository, complete while a transfer is slow, and several transfers run at once. While an archive is being created, new transfers are refused like every other command, as before.
  • A mirror transfer holds its repository until it ends. Another mirror transfer of it, a write to it (initBareRepository(), restoreRepositoryBundle(), replaceRepository(), setDefaultBranch(), mergeRepository(), receive-pack preparation in openRepositoryService()) and a Smart HTTP push into it are refused at once: the methods throw a GitManagerError with code repositoryBusy, and Smart HTTP answers 503 repository busy with Retry-After. A mirror transfer is refused the same way while a Smart HTTP push into its repository runs. Before 2.4.0 a Smart HTTP push and a refresh of the same repository raced at Git level, each failing on the other's ref locks or overwriting its result. A receive-pack opened through openRepositoryService() (for SSH) holds its repository the same way until it is released; see Raw Git services for SSH ingress. Reads (listRefs(), readFile(), getRepositorySize(), verifyRepository(), bundles, archives, clones and fetches over Smart HTTP) proceed and see the refs before or after the refresh. pushRefs() only reads its repository and holds nothing.
  • stop() does not wait behind transfers: it cancels every running transfer, waits for their Git processes to exit (at most 15 seconds), then stops the engine. Each cancelled transfer rejects with transferAborted. A transfer still running after the 15 seconds is dropped: its Git process group is killed and an import's partial repository is removed as the dropped import ends, so no temporary directory stays behind. Ending a Git process takes at most ten seconds, so this only happens when the host cannot reap it.

Atomic pushes and the default branch

pushRefs() pushes ref updates from a managed repository to a remote as one atomic transaction (IGitManagerAtomicPushRequest from @foss.global/interfaces):

const result = await gitManager.pushRefs({
  org: 'mirror.org',
  repo: 'interfaces',
  remoteUrl: 'https://git.example/foss.global/interfaces.git',
  credential: { kind: 'basic', username: 'importer', password: pushToken },
  caCertificates: [privateCaPem],
  timeoutMs: 30 * 60 * 1000,
  updates: [
    { ref: 'refs/heads/main', expectedOldObjectId: null, newObjectId: mainOid }, // create
    { ref: 'refs/tags/v1.0.0', expectedOldObjectId: oldTagOid, newObjectId: tagOid }, // move
    { ref: 'refs/heads/old', expectedOldObjectId: oldOid, newObjectId: null }, // delete
  ],
});
// { org, repo, status: 'applied' | 'rejected', refs: [{ ref, status: 'ok' | 'rejected' | 'stale', reason?, message? }] }

await gitManager.setDefaultBranch({ org: 'mirror.org', repo: 'interfaces', defaultBranch: 'main' });
  • pushRefs() drives Git's HTTP remote helper (git remote-https, or git remote-http for a loopback remote) over its standard input. It lists the remote's refs, checks every lease against that listing (expectedOldObjectId: null for a create, so the ref must not exist), and sends all updates in one atomic receive-pack request, one <old> <new> <ref> command per ref (a delete has no new object). The remote checks each old object again inside its atomic ref transaction, so a ref that moves between the listing and the push fails the whole push. Command lines keep the same size however many updates a push holds. A lease replaces the fast-forward rule, so a leased update may rewind or move a tag.
  • applied means every ref is ok. rejected means the remote refused the push and changed no ref: a ref whose lease failed is stale, a ref that already holds its newObjectId is ok (up to date, see below), and every other ref is rejected (atomic push failed, or the remote's reason). Any refusal the remote reports per ref returns rejected. pushRefs() throws only when it cannot tell what the remote did: a transport failure, a missing or unreadable ref status, an outcome that is neither, such as a partial update, a remote without the atomic capability, or a push request the remote refused as too large.
  • Every ref whose status is not ok carries a reason; an ok ref never does. Branch on reason, never on message, which is Git's free text:
    • stale-lease: the ref's lease did not match the remote's listing.
    • atomic-collateral: the ref was acceptable but went unapplied because another ref of the push was refused.
    • transaction-failed: the remote's atomic ref transaction failed, typically because a ref moved between the listing and the push. This is a race, not a refusal of the update: read the remote refs to learn their state.
    • hook-declined: a pre-receive or update hook on the remote declined the ref.
    • non-fast-forward: the remote or the local Git refused the ref as not a fast-forward.
    • local-rejected: the local Git refused the ref for another reason, such as an existing ref it would not overwrite.
    • remote-rejected: the remote refused the ref with a reason not listed above, or with none.
  • Git sends nothing for a ref that already holds newObjectId, whatever its lease says, and reports it ok with the message up to date while the push's other refs are sent as usual, so a retried push whose first response was lost reads as applied. Such a ref is not changed, but its lease was not checked either: ok never means the lease matched. Deleting a ref that is already gone is stale.
  • The remote must advertise the atomic receive-pack capability. Without it Git refuses before it sends anything, and pushRefs() throws a GitManagerError with code atomicPushUnsupported; it never falls back to ref-by-ref updates. That holds for Smart HTTP remotes only: against a dumb-HTTP remote Git runs http-push, which updates ref by ref and is not atomic, so a push remote must serve Smart HTTP.
  • Each newObjectId must be in the managed repository; a missing one throws a GitManagerError with code objectNotFound before any connection. A push holds one or more updates of distinct full ref names under refs/; object IDs are full lowercase hexadecimal. GitManager sets no limit on the number of updates and never splits a push. It reads at most 128 MiB of the remote helper's output per push (the remote's ref listing and one status line per listed or pushed ref) and at most 64 KiB per line, and throws when the helper writes more. A remote that refuses the push request as too large (HTTP 413) makes pushRefs() throw a GitManagerError with code remoteLimitExceeded; nothing was pushed.
  • URL policy, enforced by GitManager: https://, or http:// only to a loopback IP literal in its canonical spelling, 127.0.0.0/8 as four decimal octets without leading zeros or exactly [::1]. Other spellings of a loopback address (an uppercase scheme, octal, hexadecimal or single-integer forms, a trailing dot, other IPv6 forms) are refused rather than normalized, so Git receives exactly the URL the policy checked. Host names over http, localhost included, are refused, because a credential sent over plain http to a name would go wherever that name resolves. ssh://, git://, file://, ext::, local paths, scp-like URLs and URLs with user information are refused. The policy runs before any process starts, and its errors never quote the URL.
  • The credential, caCertificates and timeoutMs behave as for mirrors: the credential travels only in the Git process environment as an Authorization header, with no credential helper, no hooks, no redirects and no global or system Git config; error text replaces the remote URL with <push-remote>. The remote URL is never stored in the repository.
  • { signal } aborts a push as described in Cancellation, concurrency and stop. An abort before the updates are sent (the signal already aborted, or an abort during the local object check or the remote ref listing) changes nothing anywhere. An abort after that rejects with transferAborted as well, but the remote may have applied the push: the remote applies an atomic push completely or not at all, and the abort can land before or after it did. Read the remote refs again before retrying.
  • A loopback http push never goes through a proxy, so the credential cannot leave the machine; see the proxy note above.
  • setDefaultBranch() points HEAD at refs/heads/<defaultBranch> and returns the repository info. The branch name is given without refs/heads/; a branch that does not exist is refused and HEAD stays unchanged. A mirror's next refreshMirror() points HEAD at the source's default branch again.
  • With allowUnborn: true the branch may not exist yet: HEAD names it unborn, as in a new repository, until a push creates it, and clones check out nothing until then. receive-pack refuses to delete the branch HEAD names (deletion of the current branch prohibited), which fails a whole atomic push, so a push that renames the default branch (creates the new one and deletes the old one) needs HEAD on the new name first. If that push is refused, HEAD stays unborn; setDefaultBranch() without the flag to an existing branch points it back. The name is validated either way.

Durable repository bundles

GitManager can export a repository as a full Git bundle and atomically restore it under a new managed repository identity. Bundle bytes never pass through JSON IPC: Rust writes or reads a temporary file inside the dedicated transferDir, and the caller streams that file to or from durable object storage.

const gitManager = new GitManager({
  dataDir: '/run/foss-global/gitmanager',
  transferDir: '/run/foss-global/git-transfers',
  maxBundleBytes: 8 * 1024 * 1024 * 1024,
});

const bundle = await gitManager.createRepositoryBundle({
  org: 'foss.global',
  repo: 'interfaces',
});
// Stream bundle.bundlePath to durable object storage, then remove the temporary file.

await gitManager.restoreRepositoryBundle({
  org: 'restored',
  repo: 'interfaces',
  bundlePath: '/run/foss-global/git-transfers/downloaded.bundle',
  defaultBranch: 'main',
});

transferDir defaults to <dataDir>/transfers; both paths must be absolute. maxBundleBytes defaults to 8 GiB and is enforced after export and before restore. Restore accepts only a direct, non-empty .bundle file inside transferDir, restores every bundled ref namespace (not only branches and tags), keeps no remote pointing at the bundle, points HEAD at the declared default branch, runs strict full git fsck, and moves the restored bare repository into place atomically. When the bundle does not contain the default branch, as for a repository whose only refs are other branches or notes, HEAD names it unborn, as in a new repository. Git bundles store resolved refs, so a symbolic ref under refs/ is restored as a direct ref.

A bundle holds every ref of every namespace, so a repository with any ref, even only refs/notes/*, produces one. A repository without any ref has nothing to bundle, and createRepositoryBundle() refuses it with a plain error; persist that it has no refs and recreate it with initBareRepository(). A bundle larger than maxBundleBytes is removed, and createRepositoryBundle() throws a GitManagerError with code bundleLimitExceeded.

replaceRepository() returns an existing repository to a recorded state: the state a bundle in transferDir records, or, without bundlePath, an empty repository whose HEAD names the unborn defaultBranch. Use it when a push changed a repository in a way its durable record cannot follow, such as a bundle over maxBundleBytes.

await gitManager.replaceRepository({
  org: 'foss.global',
  repo: 'interfaces',
  bundlePath: '/run/foss-global/git-transfers/downloaded.bundle', // omit to replace it with an empty repository
  defaultBranch: 'main',
});

The replacement is built and verified beside the live repository, the same way a restore is, and then moved into its place with two renames; a failure leaves the live repository as it was. A process crash between the renames leaves the previous repository hidden at .<repo>.git.retired.<pid>.<nonce> beside its path and no repository in its place. The replacement keeps no remote, so a replaced mirror becomes a native repository. It is a write: a mirror transfer of the repository refuses it with repositoryBusy. Writes do not exclude each other, so serialize it with your own writes to the repository, such as a receive-pack. A clone or fetch that runs while the repositories are swapped may fail.

Exact-commit source archives default to a 128 MiB compressed limit, a 64 GiB extracted regular-file limit, and 1,000,000 filesystem entries. Configure these with maxArchiveBytes, maxArchiveExtractedBytes, and maxArchiveEntries; their hard ceilings are 16 GiB, 64 GiB, and 1,000,000 respectively. CI source consumers apply their negotiated compressed limit, which is at most 8 GiB in runner protocol v2. Before publication, GitManager validates the exact generated gzip/tar stream against the returned extractedSizeBytes and entryCount, including paths, entry types, links, PAX metadata, checksums, padding, and archive terminators.

Pass { signal } as the second argument to createRepositoryArchive() to cancel archive creation. GitManager keeps operation IDs private, terminates and reaps active Git or inspection subprocesses, removes partial staging files, and only then rejects with the caller's abort reason. If final archive publication wins the cancellation race, the method returns the completed archive. stop() is single-flight and uses the same cleanup barrier before confirmed bridge termination.

The transfer directory is disposable staging, not persistence. GitManager holds exclusive ownership of it while running so startup can safely remove interrupted archive temporary files. The caller owns upload/download completion and removal of finished transfer files. Durable repository bytes belong in managed object storage.

Git Smart HTTP

Smart HTTP is disabled unless a smartHttp options object is provided. The safe defaults bind to loopback on an operating-system-assigned port and expose no repositories:

const gitManager = new GitManager({
  dataDir: '/var/lib/foss-global/gitmanager',
  smartHttp: {
    allowedRepositories: [
      { org: 'foss.global', repo: 'interfaces' },
    ],
    privateRead: {
      trustedIngressToken: process.env.GITMANAGER_INGRESS_TOKEN!,
    },
    receivePack: {
      trustedIngressToken: process.env.GITMANAGER_INGRESS_TOKEN!,
    },
  },
});

await gitManager.start();
const status = await gitManager.getStatus();
console.log(status.smartHttp?.baseUrl);

The server implements git-upload-pack for clone and fetch with Git protocol versions 0, 1, and 2. Missing and inaccessible read repositories both return the same not-found response. Configuring privateRead lets an authenticated loopback ingress grant repository-scoped access by injecting the exact X-GitManager-Ingress-Token value after removing any client-supplied copy. Direct private requests still receive the same not-found response.

Configuring receivePack enables streamed push only for requests carrying the trusted ingress token. A push into a repository that an importMirror() or refreshMirror() holds is answered 503 repository busy with Retry-After, and a mirror transfer of a repository is refused with repositoryBusy while a push into it runs; before 2.4.0 the two raced at Git level. See Cancellation, concurrency and stop. Private reads and receive-pack require a loopback bind even when allowUnsafeNonLoopback is set. The trusted ingress must authorize the user and repository; for pushes it must also serialize writes, persist the updated repository, and withhold success if persistence fails.

The transport does not terminate TLS or implement user identity. Keep it behind a bounded ingress. Read-only binding to a non-loopback IP is rejected unless allowUnsafeNonLoopback: true is set explicitly.

Smart HTTP option defaults and accepted ranges:

All numeric options must be integers.

  • enabled: enabled when the smartHttp object is present unless explicitly false.
  • bindAddress: 127.0.0.1; must be an IP address.
  • port: 0, allowing the operating system to select an available port; accepted range 0–65,535.
  • allowUnsafeNonLoopback: false.
  • allowAllRepositories: false.
  • allowedRepositories: [], with at most 100,000 entries.
  • maxConcurrentTransfers: 32; accepted range 1–1,024.
  • maxRequestBodyBytes: 16 MiB; accepted range 1 KiB–64 MiB.
  • maxAdvertisementBytes: 4 MiB; accepted range 1 KiB–64 MiB.
  • maxResponseBytes: 4 GiB; accepted range 1 MiB–16 TiB.
  • processTimeoutMs: 1,800,000; accepted range 1,000–86,400,000.
  • idleTimeoutSeconds: 300; accepted range 1–3,600.
  • gracefulShutdownMs: 10,000; accepted range 100–60,000.
  • privateRead.trustedIngressToken: required when trusted private reads are enabled; 32–512 non-whitespace characters.
  • receivePack.trustedIngressToken: required when receive-pack is enabled; 32–512 non-whitespace characters.
  • receivePack.maxRequestBodyBytes: 4 GiB; accepted range 1 KiB–16 TiB. Request bodies are streamed and counted. A declared Content-Length above the limit is answered with 413 before Git starts. A streamed body that exceeds it is answered with 413 as well: the response status is decided only after the request body has reached Git completely, the Git process tree is killed at once, and the private pre-receive hook admits ref updates only after the transfer supervisor confirmed the complete body within the limit. An over-limit push therefore never updates a ref, even when Git has already read a complete pack before the excess bytes arrive. The rest of a rejected body is read for up to 1 MiB or 2 seconds so the client receives the 413.

While the service is running, access can be replaced atomically:

await gitManager.setSmartHttpRepositoryAccess({
  allowAllRepositories: false,
  allowedRepositories: [
    { org: 'foss.global', repo: 'interfaces' },
    { org: 'foss.global', repo: 'gitmanager' },
  ],
});

setSmartHttpRepositoryAccess() replaces the complete allow-all/allowlist state and resets per-repository visibility revision watermarks. For ordered visibility events, keep allowAllRepositories disabled and update one repository at a time:

await gitManager.setSmartHttpRepositoryVisibility({
  org: 'foss.global',
  repo: 'gitmanager',
  public: true,
  revision: 42,
});

Use monotonic, non-negative safe integers for revision. Stale revisions are ignored, equal revisions are idempotent when their visibility matches, conflicting equal revisions fail, and newer revisions replace the previous visibility state.

Protected refs

Protected refs are configured per repository as one complete, revisioned rule set:

const result = await gitManager.setSmartHttpRepositoryProtectedRefs({
  org: 'foss.global',
  repo: 'gitmanager',
  revision: 1,
  rules: [
    {
      refName: 'refs/heads/main',
      allowCreates: false,
      allowDeletes: false,
      allowForcePushes: false,
    },
  ],
});

console.log(result.status, result.effectiveRevision, result.rulesDigest);

Rules accept canonical, exact refs/heads/... names only; patterns, tags, roles, and bypass identities are not supported. All three permission booleans are required. Unlisted refs are unaffected. GitManager accepts at most 64 unique rules and canonicalizes their order.

Protected creates, deletes, and updates require valid commit objects. When allowForcePushes is false, an existing protected branch can move only through a fast-forward update.

Every start begins with no admitted policy for any repository, so receive-pack fails closed until the embedding service explicitly initializes that repository. Revision 0 is reserved for the empty policy and must use rules: []; the first non-empty policy must use revision 1 or higher. Clearing protection requires a newer revision with rules: [].

The first explicit policy after a start and every higher revision return applied. An equal revision with identical canonical rules returns idempotent. A lower revision returns stale with the effective revision and digest, while conflicting rules at an equal revision fail. Every successful result includes the effective revision and SHA-256 digest of the canonical rules.

Protected-ref state is process-local by design. The embedding service must persist the desired revision and rules, replay them after every start(), verify that the returned effective revision and digest match the desired policy, and admit no pushes before that verification succeeds. Policy mutation and receive-pack admission for the same repository must be serialized. Each receive-pack invocation is bound to one immutable policy snapshot.

Receive-pack uses only GitManager's private, process-specific hook directory; repository-configured hooks are not executed. The managed pre-receive hook rejects the complete push when policy bootstrap, decoding, schema validation, repository selection, object-format validation, commit or ancestry checks, or timeout handling fails.

evaluateProtectedRefUpdates() checks ref updates against protected ref rules with the same function the pre-receive hook enforces, without changing anything. It returns every update the rules refuse, in request order, with the rule it breaks: create, delete, force-push or not-a-commit; an empty array means the rules allow every update. The hook declines a push on the first of them, so a declined push reports every ref hook-declined; the evaluation names the refs that caused it.

const violations = await gitManager.evaluateProtectedRefUpdates({
  org: 'foss.global',
  repo: 'gitmanager-push-source',
  rules: [
    { refName: 'refs/heads/main', allowCreates: false, allowDeletes: false, allowForcePushes: false },
  ],
  updates: [
    { ref: 'refs/heads/main', expectedOldObjectId: oldMain, newObjectId: rewrittenMain },
    { ref: 'refs/heads/feature', expectedOldObjectId: null, newObjectId: rewrittenMain },
  ],
});
// [{ ref: 'refs/heads/main', rule: 'force-push' }]

The rules are passed in, not read from the repository's applied policy, and the repository must hold every object the updates name, for example the repository a push of these updates is sent from. An update whose old object is missing from that repository is judged not an ancestor of the new object, because a repository holds every object reachable from a commit it holds: it is force-push when the rule forbids force pushes and allowed otherwise. This is the one difference from the hook, which evaluates the push target. There every ref's own object is present, so only a client can name a missing old object; receive-pack passes the client's old values to the hook unchecked, and the hook refuses such an update as not a commit. Only updates of protected refs are checked: one Git process reads the object format, one reads the object types of all of them, and each fast-forward check of a rule that forbids force pushes costs one more, so a call runs at most two Git processes plus one per rule, however many updates it is given. On a 2,000-commit history with 500 tags, 1,003 updates of which 64 are protected took 66 Git processes and about 1.0 to 1.3 s; with one protected update, 3 processes and under 20 ms.

When enabled, getStatus().smartHttp reports { running, bindAddress, port, baseUrl }. The server also exposes GET /healthz for a local ingress health check.

Raw Git services for SSH ingress

openRepositoryService() starts a bounded local git upload-pack or protected git receive-pack process for an already authenticated ingress. Rust validates the repository selector, canonical path, Git protocol version, receive-pack availability, and immutable protected-ref policy snapshot before the TypeScript facade starts the process. Pack bytes use Node streams and never pass through JSON IPC.

A receive-pack holds its repository against mirror transfers with a guard ticket, service.guardTicketId: while it is held, importMirror() and refreshMirror() of that repository fail with repositoryBusy, and preparing a receive-pack fails with repositoryBusy while a mirror transfer of the repository runs. The ticket outlives the Git process, so the repository stays held until the push is reconciled. Release it with releaseRepositoryService() in a finally once the process has exited and the push is reconciled; releasing is idempotent, and stop() releases every ticket. Upload-pack takes no ticket. Callers that never run mirror transfers on the repository see no difference.

openRepositoryService() chooses the ticket ID, a UUID, before it asks the engine, and the engine holds the ticket under that ID. When opening a receive-pack fails, including a bridge timeout after the engine already issued the ticket, openRepositoryService() releases the ticket before it rejects, so a failed open never leaves the repository held. If that release fails too, the original error is still the one thrown and carries the release error as its cause; an original error that already has a cause is thrown as an AggregateError of both instead (errors: original, then release error; cause: original).

const service = await gitManager.openRepositoryService({ org: 'foss.global', repo: 'gitmanager', service: 'receive-pack' });
try {
  // pipe the SSH channel, await service.completed, reconcile the refs
} finally {
  await gitManager.releaseRepositoryService(service.guardTicketId!);
}
const service = await gitManager.openRepositoryService({
  org: 'foss.global',
  repo: 'gitmanager',
  service: 'upload-pack',
  protocol: 'version=2',
});

sshChannel.pipe(service.stdin);
service.stdout.pipe(sshChannel);
service.stderr.pipe(sshChannel.stderr);
const result = await service.completed;

The returned service counts request, response, and stderr bytes, runs with an isolated Git environment, has its own process group and deadline, and is terminated when GitManager.stop() runs; close() and stop() release its streams, so they return even when the caller never read its output. It owns the errors of its streams, so piping into stdin never throws in the caller: when a byte limit or the process timeout ends the process, completed resolves with failure set to 'requestLimitExceeded', 'responseLimitExceeded', 'stderrLimitExceeded', 'streamFailed', or 'processTimeout'; otherwise failure is null. The process is killed as soon as a limit is crossed. Unlike Smart HTTP, whose receive gate guarantees that an over-limit request never updates a ref, the interactive SSH protocol has no end of input before the pre-receive hook runs: Git updates refs as soon as it has read a complete pack, and excess bytes that arrive afterwards cannot undo those updates. failure therefore means the transfer was cut, not that the refs are unchanged; callers must reconcile refs from the repository after every receive-pack, whatever failure says. Receive-pack uses the same private managed pre-receive hook and exact policy snapshot as Smart HTTP. The caller must authenticate the SSH key, authorize the repository operation, cap sessions, serialize writes with HTTP pushes and policy changes, inspect refs, persist the repository, record downstream events, and withhold the final receive-pack status until all durable work succeeds.

Bounded readiness

checkReadiness() performs one bounded, read-only probe. Lifecycle states return failure results instead of throwing; only an invalid timeoutMs throws a TypeError. The default timeout is 2000 ms, the maximum is 30000 ms, and an AbortSignal cancels the local wait.

const readiness = await gitManager.checkReadiness({ timeoutMs: 2000 });
if (!readiness.ready) {
  console.error(`GitManager unavailable: ${readiness.reason}`);
}

When Git Smart HTTP is running on a loopback bind, the probe targets the engine's own GET /healthz route out-of-band, so readiness stays available while the serial management channel is busy with a long-running command such as a repository bundle. Remote transfers (importMirror(), refreshMirror(), pushRefs()) do not occupy the channel. Repository archive creation also keeps management status and cancellation responsive while preserving serial admission for every other stateful command. Without a loopback Smart HTTP route, including a service bound to a non-loopback address with allowUnsafeNonLoopback, one shared bounded getStatus round trip per instance is used; that fallback queues behind other in-flight management commands and a Smart HTTP probe failure never falls through to it. Failure reasons are not_running, starting, stopping, aborted, timeout, and unavailable; the result matches the readiness contracts of @push.rocks/smartdata and @push.rocks/smartbucket.

Commit ancestry

isAncestor() answers git merge-base --is-ancestor exactly: whether the ancestor commit is reachable from the descendant commit, where a commit is its own ancestor. Both object IDs must be full lowercase SHA-1 or SHA-256 IDs of commits in the repository; revision expressions, abbreviations, and tag objects are refused, so peel a tag before asking.

// Throws GitManagerError (error.code) unless the answer is exact.
const reachableFromMain = await gitManager.isAncestor({
  org: 'foss.global',
  repo: 'gitmanager',
  ancestorObjectId: taggedCommitId,
  descendantObjectId: mainTipCommitId,
  timeoutMs: 10_000,
});

timeoutMs is the work budget for the object lookup and the history walk together: 1 to 60000 ms, 10000 by default. When it runs out, the Git process is killed and the call throws. It never returns false for anything but an exact answer; the typed codes are invalidObjectId, objectNotFound, objectNotCommit, and ancestryBudgetExceeded. The check runs on the serial management channel like the other read APIs.

Archive inspection

inspectArchive() validates a tar.gz on the receiving side of a transfer, for example a CI artifact or cache upload. It streams the archive, given as a Uint8Array or any async iterable of Uint8Array chunks such as a Node stream or a fetch body, into a dedicated engine process. That process checks every entry against the rules GitManager applies to its own source archives and the @foss.global/ci-spec archive conformance vectors: safe relative paths, no duplicates or late ancestors, only regular files, directories and symlinks whose targets stay inside the root (including through chains), USTAR and supported PAX metadata only, zeroed padding, no trailing data or concatenated gzip members, and the fixed 134,217,728-byte path-metadata ceiling.

const inspection = await gitManager.inspectArchive({
  archive: uploadStream,
  maxCompressedBytes: 16 * 1024 ** 3,
  maxExtractedBytes: 128 * 1024 ** 3,
  maxEntries: 1_000_000,
}, { signal });
// { sizeBytes, sha256, extractedSizeBytes, entryCount, pathMetadataBytes }

The metadata is recomputed from the bytes, so compare it with what the sender declared rather than trusting the declaration. Every limit is inclusive and must be a non-negative safe integer; maxPathMetadataBytes is 1 to 134217728 and defaults to the ceiling. A refused archive throws a GitManagerError with code archiveLimitExceeded (a requested limit) or archiveRejected (a format, path, or link rule), and the inspector stops reading at the first violation and closes the source. Aborting the signal or calling stop() kills the inspector; the call rejects only after the process exited. The inspection does not use the management channel, so several can run at once.

Public API

  • GitManager: TypeScript facade for the Rust engine lifecycle.
  • GitManager.start(): spawns the Rust binary through smartrust and starts the engine.
  • GitManager.stop(): shares one in-flight stop, cancels running remote transfers and active archives and waits for their cleanup, then confirms bridge process termination.
  • GitManager.getStatus(): returns { running, dataDir, uptimeMs, smartHttp? }.
  • GitManager.checkReadiness(): performs a bounded, cancellable read-only readiness probe and returns { ready, status, durationMs } plus a failure reason when unavailable.
  • GitManager.setSmartHttpRepositoryAccess(): atomically replaces the running Smart HTTP allow-all/allowlist state.
  • GitManager.setSmartHttpRepositoryVisibility(): applies one revision-ordered repository visibility update.
  • GitManager.setSmartHttpRepositoryProtectedRefs(): applies a complete revision-fenced set of exact protected-head rules and returns its effective revision and digest.
  • GitManager.evaluateProtectedRefUpdates(): returns the ref updates that given protected ref rules refuse, with the rule each one breaks, using the pre-receive hook's check; see Protected refs.
  • GitManager.initBareRepository(): creates an empty local bare repository.
  • GitManager.importMirror(): imports a remote repository with git clone --mirror. Supported remote forms are https://, http://, ssh://, git://, file://, and SCP-like git@host:org/repo.git URLs.
  • GitManager.refreshMirror(): atomically fetches and verifies the configured mirror source and mirrors its default branch.
  • GitManager.importMirror() and GitManager.refreshMirror() accept a credential, caCertificates and timeoutMs; see Credentialed mirrors and repository size.
  • GitManager.pushRefs(): pushes leased ref updates to an https or loopback-IP http remote as one atomic transaction; see Atomic pushes and the default branch.
  • GitManager.importMirror(request, { signal? }), GitManager.refreshMirror(request, { signal? }) and GitManager.pushRefs(request, { signal? }) run concurrently with other commands, hold mirror repositories against writes (repositoryBusy) and reject with transferAborted when aborted; see Cancellation, concurrency and stop.
  • GitManager.setDefaultBranch(): points a repository's HEAD at an existing branch, or with allowUnborn at one a later push creates.
  • GitManager.getRepositorySize(): returns the exact object count, uncompressed object bytes and on-disk object storage bytes of a repository.
  • GitManager.verifyRepository(): runs a strict full integrity check.
  • GitManager.createRepositoryBundle(): writes a bounded full Git bundle of every ref into transferDir and returns its temporary path and size; an oversize bundle throws bundleLimitExceeded.
  • GitManager.createRepositoryArchive(request, { signal? }): writes a bounded deterministic exact-commit tar.gz archive into transferDir and returns its digest, compressed size, extracted regular-file size, entry count, and temporary path; cancellation settles after Rust process and staging cleanup.
  • GitManager.restoreRepositoryBundle(): validates and atomically restores a bounded bundle from transferDir.
  • GitManager.replaceRepository(): replaces an existing repository with a bundle from transferDir, or with an empty repository; see Durable repository bundles.
  • GitManager.openRepositoryService(): opens a bounded upload-pack or protected receive-pack stream for an authenticated SSH-style ingress; a receive-pack carries a guardTicketId, which the facade chooses and releases itself when opening fails.
  • GitManager.releaseRepositoryService(ticketId): releases a receive-pack's guard ticket; unknown or released tickets are a no-op.
  • GitManager.listRepositories(): lists local repositories managed below the configured data directory.
  • GitManager.listRefs(): lists one coherent page of the complete ref inventory of every namespace, with peeled tags, symbolic refs, HEAD, and a digest-bound cursor.
  • GitManager.listTree(): lists tree entries at a ref and optional path.
  • GitManager.readFile(): reads a blob as base64 after checking the blob size limit.
  • GitManager.listCommits(): lists commit summaries for a ref.
  • GitManager.getCommit(): reads commit detail metadata, parents, author/committer data, and diff stats for a ref.
  • GitManager.getDiff(): returns a structured unified diff between a head ref and its first parent or an explicit base ref.
  • GitManager.listTags(): lists lightweight and annotated tags with target object metadata.
  • GitManager.getTag(): reads one tag by exact name.
  • GitManager.isAncestor(): answers exact commit ancestry within a work budget, with typed errors for anything else.
  • GitManager.inspectArchive(request, { signal? }): streams a receiver-side tar.gz through the engine inspector and returns its recomputed metadata or a typed refusal.
  • GitManager.running: local facade running state.
  • RustGitManagerBridge: lower-level typed bridge around @push.rocks/smartrust.
  • GitManagerError and TGitManagerErrorCode: typed failures with a stable code; gitManagerArchivePathMetadataCeilingBytes is the fixed archive path-metadata ceiling.
  • IGitManagerAncestryRequest, IGitManagerArchiveInspectionRequest, IGitManagerArchiveInspectionOptions, IGitManagerArchiveInspection, IGitManagerOptions, IGitManagerStatus, IGitManagerReadinessOptions, TGitManagerReadinessResult, TGitManagerReadinessFailureReason, IRustGitManagerConfig, IGitManagerTransferControlOptions, IGitManagerProtectedRefRule, IGitManagerSmartHttpRepositoryProtectedRefs, TGitManagerProtectedRefsApplyStatus, IGitManagerProtectedRefsApplyResult, IGitManagerProtectedRefEvaluationRequest, IGitManagerProtectedRefViolation, and TGitManagerProtectedRefViolationRule: exported option, status, readiness, and protected-ref contracts.

Repository/read DTO contracts are re-exported from the published @foss.global/interfaces package.

Read/list APIs and raw-service preparation go through JSON IPC. Git packfile streaming uses controlled git upload-pack and git receive-pack subprocesses behind bounded HTTP or Node streams; packfiles do not pass through JSON IPC.

Read APIs and Smart HTTP use the system git CLI with explicit arguments, non-interactive environment variables, command timeouts, ref-to-object-id resolution, path validation, bounded stdout/stderr capture, ref/tree result caps, and a 1 MiB file read cap. Receive-pack streams bounded requests, replaces repository-configured hooks with GitManager's verified managed pre-receive hook, enables incoming object fsck, and rejects the complete push when policy evaluation cannot complete safely.

Rust Build

The package builds a Rust binary named rustgitmanager from rust/ using @git.zone/tsrust.

Configured targets:

  • linux_amd64
  • linux_arm64

The ARM64 target uses rust/.cargo/config.toml with aarch64-linux-gnu-gcc as linker.

Testing

pnpm test
pnpm run test:rust
pnpm run build

pnpm test runs the pretest hook first, which builds the Rust binaries with tsrust, and then runs lifecycle, bounded readiness, local mirror/read API, raw upload-pack/receive-pack preparation, clone/fetch denial, trusted receive-pack denial, explicit policy initialization, protected create/delete/non-fast-forward enforcement, inherited self-test hardening, restart replay, and real push tests through smartrust. test/test.refinventory.node.ts covers every ref namespace, peeled and nested tags, symbolic and dangling symbolic refs, unborn HEAD, prefix selection and exact lookup, ref names that are not valid UTF-8, pagination of 1,001 refs, stale and foreign cursors, 50,000 loose and packed refs, and a bundle round trip across namespaces. test/test.pushlimits.node.ts covers fixed-length and chunked Smart HTTP pushes over the limit, a complete pack followed by late excess bytes, an over-limit SSH-style receive-pack, an SSH-style receive-pack cut after Git already moved a ref (the update stays), and a bounded stop() while a service's output is never read, the rejected cases proving the refs unchanged, no Git or hook process left, no uncaught stream error, the engine serving, and a later push succeeding. test/test.repositorybundles.node.ts covers bundles of a repository whose only refs are notes and of one without its default branch (restored with HEAD on the unborn default branch), the refusal of a repository without refs, bundleLimitExceeded leaving no file, replaceRepository() with a bundle and without one, and a failed replacement that keeps the live repository and leaves no scratch. test/test.protectedrefs.node.ts covers evaluateProtectedRefUpdates() for every rule, unprotected refs, missing old objects and invalid requests, names the one protected ref among 1,003 updates and counts the engine's Git processes through a PATH wrapper (3 for one protected update, 66 for 64 protected updates on a 2,000-commit history with 500 tags), pins the pre-receive hook's decline messages over Smart HTTP, shows that the hook refuses an update and a deletion naming a missing old object even where the rule allows force pushes and deletions, and renames a default branch in one atomic Smart HTTP push after setDefaultBranch() with allowUnborn (refused while HEAD names the deleted branch, HEAD left unborn by a declined push and recovered without the flag, a missing branch still refused without it). test/test.ancestry.node.ts covers ancestors, the reverse direction, siblings, merges, unrelated histories, the same commit, invalid, missing and non-commit objects, a budget exceeded on a 20,000-commit history next to the exact answer, and a stalled walk whose Git process is killed. test/test.remotetransfer.node.ts serves fixtures through git http-backend over HTTPS with a private CA and over plain HTTP on 127.0.0.1 and [::1], and covers credentialed mirror imports and refreshes, the source default branch on import and refresh, setDefaultBranch(), the push URL policy, atomic creates, updates and deletes, stale leases that change no ref, a remote without the atomic capability, 5,000 creates and 5,000 deletes in one receive-pack request with no command line naming a ref, a stale lease on the last of 1,500 updates that changes no ref, a pre-receive hook that declines a push and an update hook that declines one ref (the others failing as collateral), the reason of every ref that is not ok, and the credential staying out of every command line, error and file. Its fixture can hold or refuse a receive-pack request, which covers a ref that moves between the listing and the push (the remote fails all 1,500 updates) and a 413 answer (remoteLimitExceeded, no ref changed). It can also withhold pack requests to stall a transfer midway, which covers aborting an import (no repository or scratch left), a refresh (refs and HEAD unchanged) and a push, a push aborted before it sends its updates that changes nothing, other commands and transfers completing while transfers stall, repositoryBusy (including replaceRepository()) and the Smart HTTP 503 while a mirror refreshes, an atomic refresh that prunes nothing when one ref update fails, a refresh that times out inside its ref transaction without leaving lock files, a refresh refused during a Smart HTTP push, the receive-pack guard ticket (refused while held, admitted after release, idempotent release, released by stop(), released when its preparation times out on the bridge after the engine issued it), a bounded stop() during a stalled transfer, and a credentialed loopback http push that never reaches the http_proxy, all_proxy and ALL_PROXY set in the engine's environment. test/test.archiveinspection.node.ts runs every ci-spec archive conformance vector through inspectArchive() as bytes and as a chunked stream, the compressed limit at its boundary, an 8 MiB streamed archive, an early refusal that closes the source, and abort, source failure and stop() during an inspection.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.

Bundled Rust binaries include third-party Rust crates under their respective licenses. See thirdparty.md for the packaged notice list.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
3.5 MiB
Languages
Rust 41.6%
HTML 33.5%
TypeScript 24.9%