- web: vue-tsc + TypeScript (pinned to 6.x — TypeScript 7's native compiler drops the JS API vue-tsc needs) behind `npm run typecheck`, and Prettier configured to the existing style (no semicolons, single quotes, width 120) behind `npm run format` / `format:check`. The first typecheck caught an un-narrowed `gitRef` in the catch-all page; split its tree/blob branches so both narrow properly. - GraphQL: `nugitea graphql-schema` prints the schema as SDL; it's checked in as web/schema.graphql, with graphql.config.yml and /* GraphQL */ tags on the frontend's queries so editor tooling autocompletes and validates them. A test fails if the file is stale. - Rust: rustfmt.toml (max_width 120, closest to the existing style); allow clippy's duplicated_attributes false positive on the RepositoryOwner interface derive, leaving clippy clean. - VS Code (repo root): recommended extensions, rust-analyzer pointed at rust_nugitea/Cargo.toml, format on save. - Ignore accounts.json alongside mirrors.json; replace the Nuxt starter README in web/. Formatting the existing code to these configs is left to a separate commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
rust_nugitea
A tokio/Rust rewrite of the sibling Go nugitea implementation, one
directory up — but split into two tiers the way GitLab splits the Rails app
from Gitaly, instead of the single-process design Gitea (and the Go
version) use. That split is the point: it's what lets the app tier scale
out horizontally without needing shared/replicated filesystem storage.
git client --HTTP/SSH--> [nugitea: app tier] --internal API--> [nugitea-storaged: storage tier]
httpgit, sshgit, owns repo-root disk,
GraphQL API, runs git subprocesses,
mirror scheduler, installs hooks
Authorizer (auth seam)
[web: Nuxt frontend] --/graphql--> [nugitea: app tier]
isomorphic SSR + client-side
routing/hydration, no git
protocol knowledge at all
The app tier never touches a local git path or subprocess — it only knows
repo paths (owner/name, or org/sub-org/.../name under nested orgs)
and proxies git protocol bytes to the storage tier. The storage tier
trusts its caller (no auth of its own), the same way Gitaly trusts the
Rails app tier; Authorizer checks happen once, at the app tier, before
a request is ever proxied. Repos have owners — users and (optionally
nested) orgs — but there's still no login, so nothing enforces who can
do what yet: just clone, push, mirror, and a minimal read-only file
browser.
The browsing UI is a third, independent piece: a Nuxt.js app (web/) that
speaks only GraphQL to the app tier's /graphql endpoint. It doesn't know
git exists — it renders whatever the schema gives it, isomorphically
(server-rendered on first load, then hydrated for client-side routing).
Three deployables total, always run separately — there's no embedded single-process mode:
nugitea(app tier):httpgit.rs(smart-HTTP) andsshgit.rs(SSH) are thin reverse proxies — resolve the repo path against the accounts directory (case-insensitively, like GitHub), checkAuthorizer, forward to the storage tier.graphql.rsexposes a GitHub-compatible GraphQL API (repository(owner:, name:),repositoryOwner/user/organization(login:),resource(url:),object(expression: "ref:path") { ... on Tree | ... on Blob }) — see that file's doc comment for the full schema and the one extension (nested orgs). App-tier state lives in--state-dir:accounts.json(users, orgs, repo ownership — seeaccounts/) andmirrors.json(mirror schedule bookkeeping). The mirror scheduler (mirror/) lives here too, while the actualgit fetch/git push --mirrorcalls are proxied throughstorage_client.rs.nugitea-storaged(storage tier): owns the repo-root disk (storage.rs), installs hooks, and exposes two internal listeners:- an HTTP API (
storage_server.rs) for everything that's naturally request-then-response — ref advertisement, the HTTP transport's--stateless-rpcupload-pack/receive-pack, repo create/list/exists, tree/blob/branch/file-list reads for the GraphQL layer, and the genericrun_gitused by mirror sync. - a raw TCP relay (
git_exec_tcp.rs) just for the SSH transport's interactive git protocol. SSH-drivengit upload-pack/receive-packwrite a ref advertisement to stdout before reading anything from stdin, with both directions live simultaneously — a duplex pattern HTTP/1.1 request/response framing doesn't reliably support. A plain TCP socket does, the same shape as talking to a local subprocess's pipes, just across a network hop.
- an HTTP API (
web(Nuxt frontend): pages underweb/app/pages/— a repo-root redirect to the default branch, a tree browser, a blob viewer — plus aSearchBox.vuecomponent (press/to open, fetchesrepository.files(ref)once, filters client-side, no server round-trip per keystroke). No GraphQL client library (Apollo/urql); just$fetchposting raw query strings viaweb/app/composables/graphql.ts, matching this project's general preference for the minimal tool over a heavier abstraction. SSR and post-hydration browser fetches both hit the app tier's/graphql— SSR resolves it container-to-container (NUXT_GRAPHQL_URL), the browser resolves it via whatever address the host can reach (NUXT_PUBLIC_GRAPHQL_URL).
names.rs holds the one shared, pure (no I/O) repo-path validation and
wire-form parsing used by both Rust tiers. On disk, a repo lives at
<repo-root>/<owner path>/<name>.git.
accounts/ models users, orgs (optionally nested, GitLab-style), org
membership and base permissions, nested teams, custom repo roles,
enterprises (including managed/EMU ones), blocks, and role grants — one
model covering how Gitea, GitHub, and GitLab arrange them (see its doc
comment for the comparison). Account and repo ownership are live — every
repo path resolves through it — but memberships and grants aren't
enforced yet, since nothing logs in.
Running it: three processes, always
nugitea-storaged (owns the disk), nugitea (speaks git + serves
GraphQL), and web (Nuxt frontend) are always separate processes, even on
one machine. Two ways to run them:
Docker Compose (recommended for trying it out)
docker compose up --build
This builds the two Rust binaries into one image (Dockerfile) and the
Nuxt app into a separate image (web/Dockerfile), then starts all three
services (docker-compose.yml) wired together on the compose-internal
network. Published ports: web's 3000 (the browsing UI), the app tier's
3080 (HTTP git + GraphQL) and 2222 (SSH), and storaged's 9080
(HTTP API) and 9081 (git-exec TCP relay). storaged's API has no auth of
its own (it trusts its caller — the app tier's Authorizer checks happen
on the other side of the proxy), so publishing it means anyone who can
reach those ports can read or write any repo directly, without going
through the app tier at all. Fine for local or otherwise trusted-network
use; drop the storaged service's ports: block in docker-compose.yml
before exposing this any more widely, so it's only reachable from the
app service over the compose-internal network. Repo data and mirror
state persist in named volumes (nugitea-data, nugitea-state) across
restarts.
Once it's up:
docker compose exec app nugitea user create alice
docker compose exec app nugitea repo create alice/demo
docker compose exec app nugitea org create acme
docker compose exec app nugitea org create acme/platform # a nested org
docker compose exec app nugitea repo create acme/platform/api --visibility internal
git clone http://localhost:3080/alice/demo.git
git clone ssh://localhost:2222/acme/platform/api.git
# push something, then browse it:
open http://localhost:3000/alice/demo # or just visit it in a browser
Admin commands run inside the app container because that's where
accounts.json lives (compose sets NUGITEA_STATE_DIR and
NUGITEA_STORAGE there, so no flags are needed). org create and repo create take --visibility public|internal|private (default public); a
child can't be more visible than its parent.
There are no fixtures baked into the image — a fresh instance starts with
zero repos and an empty /repos page. scripts/seed-fixtures.sh creates
an org demo with a nested org demo/tools and a small repo in each, the
same way a real client would — nugitea org/repo create followed by an
actual git commit/push, not a shortcut that writes to the storage
tier's disk directly — and is safe to re-run (it skips anything that
already exists):
scripts/seed-fixtures.sh http://localhost:3080
open http://localhost:3000/repos
Building and running directly with cargo + npm
cargo build --release
# storage tier — owns the disk
./target/release/nugitea-storaged serve --http 127.0.0.1:9080 --tcp 127.0.0.1:9081 --repo-root ./data
# app tier — speaks git to the outside world and serves GraphQL
./target/release/nugitea serve --http :3080 --ssh :2222 \
--storage http://127.0.0.1:9080 --storage-tcp 127.0.0.1:9081 --state-dir ./state
# admin commands read/write the same state dir as `serve`
export NUGITEA_STATE_DIR=./state NUGITEA_STORAGE=http://127.0.0.1:9080
./target/release/nugitea user create alice
./target/release/nugitea repo create alice/demo
git clone http://localhost:3080/alice/demo.git
git clone ssh://localhost:2222/alice/demo.git
./target/release/nugitea mirror add-pull --interval 5m alice/demo https://example.com/some/repo.git
./target/release/nugitea mirror add-push --interval 5m alice/demo git@example.com:backup/demo.git
# seed demo content (see above)
NUGITEA=./target/release/nugitea scripts/seed-fixtures.sh
# frontend — a separate process, run from web/
cd web && npm install
NUXT_GRAPHQL_URL=http://127.0.0.1:3080/graphql \
NUXT_PUBLIC_GRAPHQL_URL=http://localhost:3080/graphql \
npm run dev
Once something's been pushed, browse it at http://localhost:3000/alice/demo
(or the Nuxt dev server's own port, 3001 by default) — that redirects to
the default branch (main if it exists, else master, else whatever
branch sorts first) and lets you walk the tree, view file contents, and
press / to search the whole repo's file list in real time. URLs follow
GitHub's shape (/owner/repo/tree/<ref>/<path>, /owner/repo/blob/...),
with the owner part growing a segment per nested org; /<owner> lists an
owner's repos and sub-orgs. The GraphQL
API itself has an interactive GraphiQL page at GET http://localhost:3080/graphql.
Env var equivalents for the Rust CLI: NUGITEA_STORAGE /
NUGITEA_STORAGE_TCP (app tier's --storage/--storage-tcp),
NUGITEA_STATE_DIR (app tier's --state-dir), NUGITEA_REPO_ROOT
(storage tier's repo/hook subcommands). nugitea-storaged repo create/list also works directly on the storage node, bypassing the HTTP
API, for operators logged in there — but a repo created that way has no
entry in accounts.json, so the app tier won't serve it until one is
added.
Upgrading from before repos had owners: flat <repo-root>/<name>.git
repos aren't reachable any more (a repo path needs an owner). Move each
one to <repo-root>/<owner>/<name>.git and create the owner and repo
entries with nugitea user/org create; repo create fails if the
directory already exists, so add the accounts.json entry before moving
the repo in place. Durations accept a single s/m/h
suffix (e.g. 30s, 5m, 2h) — a deliberately smaller subset of Go's
duration syntax.
Development tooling
- Rust:
cargo fmt(configured byrustfmt.toml),cargo clippy --all-targets,cargo test. - Frontend (from
web/):npm run typecheck(vue-tsc over the Nuxt app — TypeScript is pinned to 6.x, since 7's native compiler doesn't expose the JS API vue-tsc builds on),npm run format/format:check(Prettier,.prettierrc.json). - GraphQL:
web/schema.graphqlis the app tier's schema as SDL, checked in so editor tooling can autocomplete and validate the/* GraphQL */-tagged query strings inweb/app/(config inweb/graphql.config.yml). Regenerate it after changinggraphql.rswithcargo run --bin nugitea -- graphql-schema > web/schema.graphql; acargo testfails if it's stale. - VS Code: the repo root's
.vscode/recommends rust-analyzer, Vue (Volar), Prettier, and the GraphQL extensions, points rust-analyzer at this crate (it isn't at the workspace root), and formats on save.
Known simplifications from the single-process version
- The SSH transport no longer forwards the git subprocess's raw OS-level
stderr to the client as extended data —
remote: ...messages during push/hooks travel through git's own sideband multiplexing inside stdout, which is unaffected; only git's own rare fatal-error stderr output would be missed. This also makes SSH consistent with HTTP, which never forwarded that channel either. - The exact git subprocess exit code doesn't cross the proxy hop. SSH now reports exit status 0 on a successful proxy round-trip, 1 on failure — approximating "did the request go through," matching how HTTP has never surfaced git's literal exit code to its client, only logged it server-side.