nugitea/rust_nugitea
Philip Peterson 1ae5a8987f Format existing code with cargo fmt and Prettier
Mechanical: applies the formatter configs from the previous commit, with
one adjustment to rustfmt.toml — use_small_heuristics = "Max", which
matches this crate's existing long one-liner style far better (about a
third of the churn of the default heuristics at the same width). Also
keeps Prettier off the generated web/schema.graphql, so the schema
staleness test stays green. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 04:13:03 -08:00
..
scripts Give repos owners, including nested orgs 2026-09-27 04:09:22 -08:00
src Format existing code with cargo fmt and Prettier 2026-09-27 04:13:03 -08:00
web Format existing code with cargo fmt and Prettier 2026-09-27 04:13:03 -08:00
.dockerignore Replace Leptos webui with a GraphQL API + Nuxt frontend 2026-09-20 22:50:10 -08:00
.gitignore Add type checking, formatting, and editor tooling 2026-09-27 04:11:58 -08:00
Cargo.lock Replace Leptos webui with a GraphQL API + Nuxt frontend 2026-09-20 22:50:10 -08:00
Cargo.toml Replace Leptos webui with a GraphQL API + Nuxt frontend 2026-09-20 22:50:10 -08:00
docker-compose.yml Give repos owners, including nested orgs 2026-09-27 04:09:22 -08:00
Dockerfile Replace Leptos webui with a GraphQL API + Nuxt frontend 2026-09-20 22:50:10 -08:00
README.md Add type checking, formatting, and editor tooling 2026-09-27 04:11:58 -08:00
rustfmt.toml Format existing code with cargo fmt and Prettier 2026-09-27 04:13:03 -08:00

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) and sshgit.rs (SSH) are thin reverse proxies — resolve the repo path against the accounts directory (case-insensitively, like GitHub), check Authorizer, forward to the storage tier. graphql.rs exposes 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 — see accounts/) and mirrors.json (mirror schedule bookkeeping). The mirror scheduler (mirror/) lives here too, while the actual git fetch/git push --mirror calls are proxied through storage_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-rpc upload-pack/receive-pack, repo create/list/exists, tree/blob/branch/file-list reads for the GraphQL layer, and the generic run_git used by mirror sync.
    • a raw TCP relay (git_exec_tcp.rs) just for the SSH transport's interactive git protocol. SSH-driven git upload-pack/receive-pack write 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.
  • web (Nuxt frontend): pages under web/app/pages/ — a repo-root redirect to the default branch, a tree browser, a blob viewer — plus a SearchBox.vue component (press / to open, fetches repository.files(ref) once, filters client-side, no server round-trip per keystroke). No GraphQL client library (Apollo/urql); just $fetch posting raw query strings via web/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 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 by rustfmt.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.graphql is the app tier's schema as SDL, checked in so editor tooling can autocomplete and validate the /* GraphQL */-tagged query strings in web/app/ (config in web/graphql.config.yml). Regenerate it after changing graphql.rs with cargo run --bin nugitea -- graphql-schema > web/schema.graphql; a cargo test fails 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.