Fast, read-only JSON REST API for browsing bare git repositories under configurable root directories — reads repos directly off disk via gitoxide (pure Rust) and serves search, refs, commits, diffs, trees and blobs, with an in-RAM index for sub-millisecond browsing.
  • Rust 72.4%
  • JavaScript 25%
  • CSS 1.6%
  • Shell 0.5%
  • Dockerfile 0.3%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Anton Farygin a4e3ac0b9f
Some checks failed
pages / test (push) Successful in 56s
pages / deploy (push) Successful in 54s
CI / audit (push) Successful in 2m13s
CI / test (push) Failing after 4m27s
SBOM / sbom (push) Successful in 4m29s
CI / image (push) Successful in 4m50s
CI / coverage (push) Successful in 4m56s
Merge pull request 'build(deps): weekly web asset update' (#60) from ci/web-assets-update into master
Reviewed-on: #60
2026-10-12 08:45:32 +03:00
.forgejo/workflows ci: check cargo-deny's tarball against a pinned sha256 2026-09-25 04:35:13 +05:00
.gear 1.7.0-alt1 2026-09-14 11:43:36 +03:00
benches bench: Root gained alias 2026-09-04 12:14:09 +05:00
dist dist: publish the container port on loopback by default 2026-09-25 02:06:16 +05:00
src api: an allow_links target confines objects like a root 2026-09-28 13:55:11 +05:00
tests api: an allow_links target confines objects like a root 2026-09-28 13:55:11 +05:00
web build(deps): weekly web asset update 2026-10-12 05:31:10 +00:00
.containerignore build(container): keep vendor layers cached across web/ edits 2026-08-21 21:47:34 +05:00
.gitignore chore: untrack ANNOUNCE.md 2026-08-15 11:24:16 +05:00
AUTHORS AUTHORS: add Egor Ignatov 2026-08-28 17:35:16 +05:00
BENCH.md build: trim vendored dependencies 2026-06-17 14:18:54 +00:00
build.rs version: omit commit when the build had none 2026-09-03 14:20:01 +05:00
Cargo.lock deps: cargo update (patch/minor within existing constraints) 2026-10-02 15:30:58 +05:00
Cargo.toml chore(release): 1.8.0 2026-09-26 16:31:16 +05:00
CHANGELOG.md fix(web): make source line numbers real links 2026-10-02 14:54:13 +05:00
compose.yaml dist: publish the container port on loopback by default 2026-09-25 02:06:16 +05:00
Containerfile Containerfile: install make for tikv-jemalloc-sys 2026-08-29 18:40:52 +05:00
CONTRIBUTING.md docs: CONTRIBUTING 2026-07-21 17:39:04 +00:00
deny.toml ci, spec: make for tikv-jemalloc-sys; deny: ignore RUSTSEC-2024-0436 (paste unmaintained) 2026-08-29 19:07:52 +05:00
LICENSE chore: license under AGPL-3.0-or-later 2026-07-21 07:36:47 +00:00
openapi.json api: cap concurrent blame computations 2026-09-12 22:04:47 +05:00
README.md mcp: bound the memory a burst of tool calls can allocate 2026-09-12 21:51:06 +05:00
TODO.md feat(config): upstream "<label>/<subpath>" confinement 2026-07-26 09:56:18 +00:00

gitoskop logo

gitoskop

Web UI: https://rider.altlinux.team/gitoskop · API: https://git.altlinux.org/gitoskop/api/_/openapi.json

A fast, read-only JSON API for browsing bare git repositories laid out under one or more configured root directories. It was built for the ALT Linux git.altlinux.org layout — /packages/{gears,srpms}/<c>/<pkg>.git and /people/<user>/packages/<pkg>.git, tens of thousands of bare repos — as a JSON-API replacement for the gitweb-based interface, but it works with any tree of bare git repositories.

It runs on the storage host and reads the bare repositories directly off disk via gitoxide (pure Rust — no git subprocess per request, no libgit2, no OpenSSL/cmake). Repository structure, refs, commits, diffs, trees and blobs are exposed as JSON; an in-RAM index backs name search and directory browsing.

Highlights

  • Direct on-disk reads through gix, no fork-per-request.
  • Configurable roots with arbitrary nesting and a generic repo detector (*.git or any dir with HEAD + objects/).
  • Parallel scan / browse / preload, bounded by the tokio blocking pool.
  • Two-tier caching: an in-RAM repo-path index for instant search/browse, and a per-repo summary cache invalidated by a cheap mtime change-signal (fresh on push, no periodic re-compute).
  • Read-only and confinement-checked: every resolved path is canonicalized and confined to its root; object access (including objects/info/alternates) is confined too.
  • Single static binary — builds cleanly for offline RPM (hasher) packaging.

MCP (for agents)

The API also speaks MCP at POST /_/mcp — JSON-RPC 2.0, stateless, Streamable HTTP. It exposes twelve tools (repo_find, repo_info, refs, tree, find, git_log, git_show, git_diff, read_file, grep, blame, recipe) over the same read-only data the REST API serves.

Every text result is a bounded window: offset and limit count result lines, a byte ceiling (mcp_max_bytes, default 64 KiB) applies on top, and an incomplete result ends with a machine-readable marker naming the next call — so a multi-megabyte diff never lands in an agent's context by accident.

A last line starting with … always means the result is incomplete. It normally carries Continue with offset=N; when a backing walk was capped and no further offset could be served (only grep reaches this, and only past its 500-matching-file walk cap) it says so instead of naming a call that would fail.

{
  "mcpServers": {
    "gitoskop": { "type": "http", "url": "https://git.example.org/gitoskop/api/_/mcp" }
  }
}

The built-in instructions the server hands a client are deliberately generic — it has no idea what the repositories it serves contain. mcp_about appends that missing half, and is worth filling in: an agent that knows what it is looking at stops guessing at layout.

mcp_about = """
These are ALT Linux source package repositories (gear format): one package per
repo, .gear/ holds the spec and packaging rules, tags are released versions,
branches like sisyphus and p11 are distro branches.
"""

Set mcp_enabled = false to remove the endpoint.

Build

cargo build --release
# binary: target/release/gitoskop

All dependencies are pure-Rust; no OpenSSL or cmake is required.

Fully static binary (run on an older host)

RUSTFLAGS="-C target-feature=+crt-static" \
  cargo build --release --target x86_64-unknown-linux-gnu

Requires the static C library (glibc-devel-static on ALT). The resulting binary is self-contained and runs regardless of the host's glibc version (it binds a numeric address, so static glibc is safe — no NSS/DNS).

Offline / vendored build (hasher)

The build sandbox has no network, so dependencies are vendored:

cargo vendor-filterer --platform x86_64-unknown-linux-gnu vendor   # or: cargo vendor vendor
# ship `vendor/` (or a vendor.tar.zst) with the source; in the spec, point cargo
# at it with .cargo/config.toml ([source.crates-io] replace-with = "vendored-sources")
cargo build --release --offline --locked

cargo-vendor-filterer strips crates that only apply to other platforms (windows/wasm/…), roughly halving the vendored tree.

Container (podman / docker)

A multi-stage Containerfile builds a fully static binary and ships it on scratch (tiny image, nothing but the binary):

podman build -t gitoskop .
podman run --rm -p 1449:1449 \
  -v ./gitoskop.conf:/etc/gitoskop/gitoskop.conf:ro,Z \
  -v /srv/packages:/packages:ro,Z \
  gitoskop

The config's [[root]] paths must match the paths mounted into the container; mount the repository roots read-only.

Or use the bundled compose.yaml (podman-compose / docker compose): copy dist/gitoskop.conf.example to ./gitoskop.conf, adjust the volume mounts, then podman-compose up -d --build.

Configuration

Default config path: /etc/gitoskop/gitoskop.conf (TOML).

Drop-in directory: <config-dir>/conf.d/*.conf — files are merged in sorted order. Scalar keys override the previous value; [[root]] entries accumulate across all files. Override the base file path with -c <path>.

TOML gotcha: all top-level keys (bind, rescan_secs, …) must appear before any [[root]] block. TOML treats anything after a [[root]] header as a field of that table, so a top-level key placed after a [[root]] block is mis-parsed.

Config fields

Key Default Description
bind "0.0.0.0:1449" TCP address to listen on
rescan_secs 300 Seconds between background index rescans
scan_secs 10 Fast watch-tick interval; stats per-root watch dirs and rewalks only changed subtrees (0 = off)
log_level "info" Log level (trace/debug/info/warn/error)
debug false Verbose logs + per-op timings (also enabled by -d)
export_ok_only false If true, serve only repos with a git-daemon-export-ok marker; default serves all readable repos
repo_cache_cap 256 Open repo handles kept in the in-process cache
worker_threads 0 Threads for parallel scan/browse/preload; 0 = auto (CPU count)
max_blob_bytes 10485760 Max blob size served by /blob and /raw (10 MiB); 0 = unlimited. /blob holds ~3x the blob in RAM (raw + text + JSON), so this cap times max_concurrency bounds worst-case memory
max_concurrency 128 Max in-flight requests; excess returns 503; 0 = unlimited
request_timeout_secs 30 Per-request timeout; 0 = no timeout
max_blocking_threads 64 Cap on the tokio blocking pool (bounds concurrent git work)
object_cache_bytes 16777216 Decoded-object cache (bytes) on the repo handle a request uses; lives for that request only (no warming across requests). Speeds up walks that re-read the same trees/commits. Worst-case RAM ≈ max_blocking_threads × this. 0 = disabled. Each handle additionally carries a pack delta-base cache capped at git's default 96 MiB (fills only as the pack is read), so peak RAM under full load is bounded by max_blocking_threads × (96 MiB + this)
search_default_limit 50 Search page size when the request has no limit param
search_max_limit 200 Hard cap on the search limit param
mcp_enabled true Serve the MCP endpoint at POST /_/mcp; false removes the route entirely
mcp_max_concurrency 16 Max concurrent MCP tool calls; excess gets a JSON-RPC -32000 busy error. One call transiently holds a few times its backing JSON body in RAM before the mcp_max_bytes window is cut, so this — not max_concurrency — is the memory bound for agent bursts. 0 = unlimited
mcp_max_bytes 65536 Byte ceiling on one MCP text result (≈16k tokens), applied after offset/limit slicing and always cut on a line boundary
mcp_about (empty) Free text appended to the MCP instructions, describing what these repositories are — the one thing the server cannot infer
snapshot_path (none) Optional on-disk index snapshot (repo list, tip index, meta cache — restart serves immediately, rescan runs in background)
[[root]] One or more roots; each has label (must not start with _) and path; optional clone_base points the WebUI clone hint at the real git endpoint

Per-root optional keys:

Key Default Purpose
pattern (walk all) One glob or a list (pattern = ["*/gears/*/git", "archive/done/*/*/gears/*/git"]); only matching paths are indexed, * = one component
scan true false = never walk/index; repos resolve lazily on direct URL access and directories list straight from disk (no search/preload)
hidden false Omit the root from the / listing; direct access still works
alias none Serve this label as another root's subtree, e.g. alias = "packages/gears"; no path, never scanned or listed
allow_links (none) In-root paths allowed to escape the root via symlink, e.g. allow_links = ["archive"] for girar /tasks/archive → /archive/tasks; the target must be mounted at the same absolute path
upstream (none) Label (optionally with subdir) of a scanned root holding upstream pairs; adds merge_status to summaries

Each root also accepts an optional watch list: glob patterns relative to the root, same component semantics as pattern (* matches exactly one path component). On every scan_secs tick the watched dirs (and the root itself) are mtime-stat'ed; a changed watched dir is point-rewalked and a vanished one has its index entries dropped — repo additions/removals surface in seconds without waiting for the full rescan_secs scan, e.g. watch = ["gears/*", "srpms/*"] for the ALT /packages layout. Roots without watch are only picked up by the full rescan.

Alternatively a root can set watch_max = "archive/done": the tick then stats only the numerically greatest child of that dir (shard names like _99/99) — for archive layouts where only the newest shard receives writes. A changed shard is rewalked; a greater shard appearing (rotation) is rewalked and the retired one dropped from tracking. Roots with watch or watch_max also get the root-mtime check, so new/removed top-level dirs are caught either way.

An annotated example is in dist/gitoskop.conf.example.

Gitweb compatibility mode

The ui and gitweb_compat flags enable drop-in replacement behavior for deployments migrating from gitweb. Set ui = true to serve the embedded web UI (ui_title = "git.example.org" puts that name next to the logo and in the window title instead of "gitoskop"); set gitweb_compat = true to understand gitweb-style query URLs (?p=<repo>;a=<action>).

Page actions (log, tree, commit, blame, etc.) receive a 301 redirect to the UI — either web_base (if set) or the embedded UI (if ui = true). Example: /gears/g/git.git?p=git.git;a=log;h=refs/heads/sisyphus redirects to https://git.example.org/#/gears/g/git.git/-/log/sisyphus.

Content actions (blob_plain, rss, snapshot, patches) are served directly by gitoskop as raw files or tarballs, no redirect. These map onto the plain, plain_oid, rss and patches verbs plus archive?format=, all of which require gitweb_compat = true: with the flag off those verbs answer 404 and archive ignores ?format= (always tgz), since plain in particular serves repository bytes inline — a stance the rest of the API deliberately refuses. The feed, patch and plain archive verbs are gitoskop's own API surface and stay active regardless of the flag.

Note on clone paths: gitoskop does not serve dumb-HTTP clones. Proxy untouched clone paths (/.git/objects, /.git/info/refs) to the filesystem or git-http-backend; see dist/nginx-gitweb-compat.conf.example or dist/apache-gitweb-compat.conf.example for templates.

Running

gitoskop -c /etc/gitoskop/gitoskop.conf      # -d for debug mode

On startup the service loads any index snapshot, runs a full scan of the roots (logged with per-subtree progress), starts serving, and then warms the per-repo summary cache in the background. A dist/gitoskop.service systemd unit is provided (hardened: read-only filesystem, no new privileges, syscall filter, MemoryMax/TasksMax). Create a gitoskop system user before enabling it.

Endpoints

All responses are JSON unless noted. <ref> is a branch, tag or OID and may contain slashes (e.g. old/sisyphus-task79111). Omit it — <repo>/log, <repo>/tree, <repo>/show — and the repository's HEAD is used, so a caller need not learn the branch name first (diff is the exception: a range names both of its endpoints).

Meta

Endpoint Description
GET / List configured roots (label, plus clone_base when set — the WebUI shows git clone <clone_base>/<relpath>; gitoskop itself does not serve smart HTTP)
GET /_/health Health check — returns ok
GET /_/metrics Prometheus metrics (gitoskop_requests_total)
GET /_/openapi.json OpenAPI 3.1 specification of this API
GET /_/search?q=&limit=&offset= Substring search over indexed repo paths; response carries total and matching directory prefixes (dirs); exact-basename hits first, then freshest last_commit (limits configurable via search_default_limit/search_max_limit)
POST /_/mcp MCP endpoint — JSON-RPC 2.0, stateless, eight read-only tools over the same data; see MCP (for agents). GET answers 405; disable with mcp_enabled = false

Browse / repository

GET /<label>/<path> resolves the path under the labeled root. A directory returns a listing of children (each repo child carries inline summary metadata; add ?meta=0 to skip the per-repo summary for an instant structural listing). A git repository returns its summary.

Verb Endpoint Description
summary GET /<label>/<repo> Default branch, last commit, branch/tag counts, on-disk size
refs GET /<label>/<repo>/refs Branches + tags
branches GET /<label>/<repo>/branches Branches with target OIDs
tags GET /<label>/<repo>/tags Tags with target OIDs
recipe GET /<label>/<repo>/recipe/<ref> The build recipe of a tree that is not its own sources: a gear .gear/rules (inputs resolved to commits, stored tags included) or a lone spec beside its own unpacked sources; 404 for a plain tree
log GET /<label>/<repo>/log/<ref>?limit=&offset= Commit log, newest first; ?q= filters by author/committer/subject substring or OID prefix, ?path= limits to commits changing that file/subtree
grep GET /<label>/<repo>/grep/<ref>?q=&path= Case-insensitive substring search over blob contents (≤1 MiB, non-binary); ≤20 lines per file; ?path= confines it to that subtree. A walk cut by its time budget answers with X-Gitoskop-Partial: walk-budget
find GET /<label>/<repo>/find/<ref>?q=&path= Paths containing q (case-insensitive), a directory as dir/; ?path= confines it to that subtree; same partial-walk header as grep
blame GET /<label>/<repo>/blame/<ref>/<path> Line-group blame (introducing commit per consecutive-line hunk); expensive, no cache
archive GET /<label>/<repo>/archive/<ref>[/<path>] tar.gz of the tree (or a subdirectory) at a ref; streamed with bounded memory, capped at 256 MiB
commit GET /<label>/<repo>/commit/<oid> Commit detail + changed files
diff GET /<label>/<repo>/diff/<a>..<b> File changes between two refs/OIDs; <a>...<b> diffs from their merge base
patch GET /<label>/<repo>/patch/<range> Unified diff as plain text; <range> is <a>..<b> or <a>...<b>, or a single committish diffed against its first parent
show GET /<label>/<repo>/show/<rev> Full git show: commit detail + patch (JSON); ?format=patch → mbox for git am
tree GET /<label>/<repo>/tree/<ref>/<path> Directory entries at <path>
blob GET /<label>/<repo>/blob/<ref>/<path> File content (text, or {binary:true})
raw GET /<label>/<repo>/raw/<ref>/<path> Raw file bytes (download)

Example:

curl http://host:1449/pkg/gears/g/git.git
curl http://host:1449/pkg/gears/g/git.git/log/sisyphus
curl http://host:1449/pkg/gears/0/0ad.git/raw/sisyphus/.gear/rules

Fetch a commit as an applyable patch:

curl -s 'https://host/pkg/g/git.git/show/abc123?format=patch' | git am

Notes:

  • Performance tiers. Search, directory browsing, summary and ref listings are served from RAM (warm: sub-millisecond to low-ms). History and content (log, commit, diff, tree, blob, raw) read from disk on the bounded blocking pool. A cold browse of a large directory warms its per-repo summaries on first access and is fast thereafter.
  • Truncation. tree, diff and commit responses include a truncated boolean; large trees/diffs are capped at 50 000 entries.
  • git am-applyable. Patch output (/patch/ and show's ?format=patch) is byte-exact: mode-change lines, "no newline at end of file" markers, and raw non-UTF-8 blob bytes all pass through untouched, so it applies cleanly with git apply / git am. The JSON patch field in show is lossy UTF-8 for display only.
  • /raw safety. Text is served inline as text/plain with X-Content-Type-Options: nosniff; binary content (NUL bytes) is served as application/octet-stream with Content-Disposition: attachment. Never text/html, so untrusted repository bytes cannot be rendered as active content in a browser.
  • Errors. JSON {"error":{"code","message"}}; 404 unknown repo/ref/path, 400 bad parameters, 413 blob over the size cap, 503 over the concurrency limit, 504 request timeout. Internal/git error detail is logged server-side, not returned to clients.

Security model

Read-only by design. Intended to run behind a trusted boundary — there is no authentication, TLS or rate limiting built in; bind to localhost and front it with an authenticating reverse proxy for untrusted networks, or restrict the listen address.

Within those bounds, the service hardens against the repositories themselves being attacker-influenced (maintainers can push arbitrary content):

  • Resolved paths are canonicalized and confined to their configured root; symlinks escaping a root are rejected.
  • Object access is confined: a symlinked objects directory or escaping objects/info/alternates (followed transitively) is rejected, as are symlinked pack/loose object files under objects/.
  • Ref-ish input is restricted to ref-name/OID characters, blocking revspec navigation operators (:/text, @{…}, ~N, …) that would otherwise trigger unbounded history walks.
  • Blob size, tree/diff entry counts, request concurrency, per-request time and the blocking-thread pool are all bounded.

Residual, accepted within this model: object confinement is validated when a repo is opened, not on every object read. A repo owner with write access can swap objects/info/alternates in the window between validation and read (racing the check). And the symlink scan covers only the direct children of objects/ and objects/pack/ — loose-object files inside the objects/xx/ fanout dirs are never scanned, so a symlinked loose object is followed whenever present (a zlib-decode failure on a non-object target limits what that can exfiltrate).

snapshot_path is trusted local state: the file is parsed into the index at startup, and saves write a temp file next to it — keep the file and its directory writable only by the service user.

Observability

  • GET /_/metrics — Prometheus text format.
  • Structured access logs (method, path, status, elapsed_ms) at info.
  • -d / debug = true adds per-operation timing logs (every git read, scan, preload, and index op).

See BENCH.md for load testing with wrk.

License

AGPL-3.0-or-later. See LICENSE. Vendored dependencies under vendor/ keep their own (permissive) licenses.