- Rust 72.4%
- JavaScript 25%
- CSS 1.6%
- Shell 0.5%
- Dockerfile 0.3%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
Reviewed-on: #60 |
||
| .forgejo/workflows | ||
| .gear | ||
| benches | ||
| dist | ||
| src | ||
| tests | ||
| web | ||
| .containerignore | ||
| .gitignore | ||
| AUTHORS | ||
| BENCH.md | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| compose.yaml | ||
| Containerfile | ||
| CONTRIBUTING.md | ||
| deny.toml | ||
| LICENSE | ||
| openapi.json | ||
| README.md | ||
| TODO.md | ||
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
(
*.gitor any dir withHEAD+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,diffandcommitresponses include atruncatedboolean; large trees/diffs are capped at 50 000 entries. git am-applyable. Patch output (/patch/andshow'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 withgit apply/git am. The JSONpatchfield inshowis lossy UTF-8 for display only./rawsafety. Text is served inline astext/plainwithX-Content-Type-Options: nosniff; binary content (NUL bytes) is served asapplication/octet-streamwithContent-Disposition: attachment. Nevertext/html, so untrusted repository bytes cannot be rendered as active content in a browser.- Errors. JSON
{"error":{"code","message"}};404unknown repo/ref/path,400bad parameters,413blob over the size cap,503over the concurrency limit,504request 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
objectsdirectory or escapingobjects/info/alternates(followed transitively) is rejected, as are symlinked pack/loose object files underobjects/. - 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 = trueadds 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.