# rust_nugiteaA tokio/Rust rewrite of the sibling Go `nugitea` implementation, onedirectory up — but split into two tiers the way GitLab splits the Rails appfrom Gitaly, instead of the single-process design Gitea (and the Goversion) use. That split is the point: it's what lets the app tier scaleout 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 hooksAuthorizer (auth seam)[web: Nuxt frontend] --/graphql--> [nugitea: app tier]isomorphic SSR + client-siderouting/hydration, no gitprotocol knowledge at all```The app tier never touches a local git path or subprocess — it only knowsrepo *paths* (`owner/name`, or `org/sub-org/.../name` under nested orgs)and proxies git protocol bytes to the storage tier. The storage tiertrusts its caller (no auth of its own), the same way Gitaly trusts theRails app tier; `Authorizer` checks happen once, at the app tier, beforea request is ever proxied. Repos have owners — users and (optionallynested) orgs — but there's still no login, so nothing enforces who cando what yet: just clone, push, mirror, and a minimal read-only filebrowser.The browsing UI is a third, independent piece: a Nuxt.js app (`web/`) thatspeaks only GraphQL to the app tier's `/graphql` endpoint. It doesn't knowgit 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 embeddedsingle-process mode:- **`nugitea` (app tier)**: `httpgit.rs` (smart-HTTP) and `sshgit.rs` (SSH)are thin reverse proxies — resolve the repo path against the accountsdirectory (case-insensitively, like GitHub), check `Authorizer`, forwardto 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 | ... onBlob }`) — see that file's doc comment for the full schema and the oneextension (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 naturallyrequest-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'sinteractive git protocol. SSH-driven `git upload-pack`/`receive-pack`write a ref advertisement to stdout *before* reading anything fromstdin, with both directions live simultaneously — a duplex patternHTTP/1.1 request/response framing doesn't reliably support. A plainTCP socket does, the same shape as talking to a local subprocess'spipes, just across a network hop.- **`web` (Nuxt frontend)**: pages under `web/app/pages/` — a repo-rootredirect 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-tripper keystroke). No GraphQL client library (Apollo/urql); just `$fetch`posting raw query strings via `web/app/composables/graphql.ts`, matchingthis project's general preference for the minimal tool over a heavierabstraction. SSR and post-hydration browser fetches both hit the apptier's `/graphql` — SSR resolves it container-to-container(`NUXT_GRAPHQL_URL`), the browser resolves it via whatever address thehost can reach (`NUXT_PUBLIC_GRAPHQL_URL`).`names.rs` holds the one shared, pure (no I/O) repo-path validation andwire-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), orgmembership and base permissions, nested teams, custom repo roles,enterprises (including managed/EMU ones), blocks, and role grants — onemodel covering how Gitea, GitHub, and GitLab arrange them (see its doccomment for the comparison). Account and repo ownership are live — everyrepo path resolves through it — but memberships and grants aren'tenforced yet, since nothing logs in.## Running it: three processes, always`nugitea-storaged` (owns the disk), `nugitea` (speaks git + servesGraphQL), and `web` (Nuxt frontend) are always separate processes, even onone machine. Two ways to run them:### Docker Compose (recommended for trying it out)```shdocker compose up --build```This builds the two Rust binaries into one image (`Dockerfile`) and theNuxt app into a separate image (`web/Dockerfile`), then starts all threeservices (`docker-compose.yml`) wired together on the compose-internalnetwork. 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 ofits own (it trusts its caller — the app tier's `Authorizer` checks happenon the other side of the proxy), so publishing it means anyone who canreach those ports can read or write any repo directly, without goingthrough the app tier at all. Fine for local or otherwise trusted-networkuse; 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 mirrorstate persist in named volumes (`nugitea-data`, `nugitea-state`) acrossrestarts.Once it's up:```shdocker compose exec app nugitea user create alicedocker compose exec app nugitea repo create alice/demodocker compose exec app nugitea org create acmedocker compose exec app nugitea org create acme/platform # a nested orgdocker compose exec app nugitea repo create acme/platform/api --visibility internalgit clone http://localhost:3080/alice/demo.gitgit 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 `repocreate` take `--visibility public|internal|private` (default `public`); achild can't be more visible than its parent.There are no fixtures baked into the image — a fresh instance starts withzero repos and an empty `/repos` page. `scripts/seed-fixtures.sh` createsan org `demo` with a nested org `demo/tools` and a small repo in each, thesame way a real client would — `nugitea org/repo create` followed by anactual `git commit`/push, not a shortcut that writes to the storagetier's disk directly — and is safe to re-run (it skips anything thatalready exists):```shscripts/seed-fixtures.sh http://localhost:3080open http://localhost:3000/repos```### Building and running directly with cargo + npm```shcargo 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/demogit clone http://localhost:3080/alice/demo.gitgit 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 installNUXT_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 tothe default branch (`main` if it exists, else `master`, else whateverbranch sorts first) and lets you walk the tree, view file contents, andpress `/` to search the whole repo's file list in real time. URLs followGitHub's shape (`/owner/repo/tree/<ref>/<path>`, `/owner/repo/blob/...`),with the owner part growing a segment per nested org; `/<owner>` lists anowner's repos and sub-orgs. The GraphQLAPI 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 repocreate/list` also works directly on the storage node, bypassing the HTTPAPI, for operators logged in there — but a repo created that way has noentry in `accounts.json`, so the app tier won't serve it until one isadded.Upgrading from before repos had owners: flat `<repo-root>/<name>.git`repos aren't reachable any more (a repo path needs an owner). Move eachone to `<repo-root>/<owner>/<name>.git` and create the owner and repoentries with `nugitea user/org create`; `repo create` fails if thedirectory already exists, so add the `accounts.json` entry before movingthe repo in place. Durations accept a single `s`/`m`/`h`suffix (e.g. `30s`, `5m`, `2h`) — a deliberately smaller subset of Go'sduration 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 Nuxtapp — TypeScript is pinned to 6.x, since 7's native compiler doesn'texpose 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, pointsrust-analyzer at this crate (it isn't at the workspace root), andformats on save.## Known simplifications from the single-process version- The SSH transport no longer forwards the git subprocess's raw OS-levelstderr to the client as extended data — `remote: ...` messages duringpush/hooks travel through git's own sideband multiplexing *inside*stdout, which is unaffected; only git's own rare fatal-error stderroutput would be missed. This also makes SSH consistent with HTTP, whichnever forwarded that channel either.- The exact git subprocess exit code doesn't cross the proxy hop. SSH nowreports exit status 0 on a successful proxy round-trip, 1 on failure —approximating "did the request go through," matching how HTTP has neversurfaced git's literal exit code to its client, only logged itserver-side.