# Openhub A minimal, self-hostable GitHub-style git server: push over SSH, browse code and history, and review pull requests. It is a single Rust binary backed by SQLite. User-facing documentation lives in `README.md`. How the system works (architecture, data model, access control, git transport, pull requests, web interface, security, operations) is documented in `docs/`, indexed at `docs/README.md`. This file covers working on the code. **Scope is deliberate.** Openhub is git hosting only. Do not add issues, CI, wikis, releases, forks or organisations unless asked. Pushing is SSH-only by design (HTTP is read-only), so don't add HTTP push without discussing it first. > The parent directory's `CLAUDE.md` is about Bun and JavaScript projects. Openhub has no JavaScript toolchain: `static/app.js` is hand-written and served as-is. That guidance only applies if a JS build step is ever introduced. ## Commands ```sh cargo build # also compiles every template; template errors are compile errors cargo run -- --data-dir ./data # web on :3000, SSH on :2222 (./data is gitignored) cargo test # unit tests (inline #[cfg(test)] modules) cargo clippy --all-targets # keep this warning-free cargo fmt scripts/smoke-test.sh [path/to/binary] # end-to-end test against a throwaway instance docker compose up -d # production-style run (data in a named volume) scripts/deploy-server.sh user@host # build on a Linux host over ssh and install/upgrade the systemd service ``` - The smoke test needs `git`, `ssh`, `ssh-keygen` and `curl`. It uses ports 38080 and 38022; override them with `HTTP_PORT` and `SSH_PORT`. It creates and deletes its own temp directory. - When running a dev server for experiments, point `--data-dir` at a scratch directory. Don't reuse `./data`, which may hold someone's real instance. - The `git` executable is a runtime dependency. It serves fetches and pushes (`upload-pack`, `receive-pack`) and builds archives. ## Toolchain and dependencies - Edition 2024 with `rust-version = "1.88"`. Cargo's MSRV-aware resolver then picks dependency versions that build on 1.88. Several crates' newest releases need a newer Rust, so to upgrade, bump `rust-version`, the `rust:` image tag in `Dockerfile` and the README together. - `Cargo.lock` is committed (this is a binary), and the Dockerfile builds with `--locked`. - libgit2 and SQLite are compiled from bundled C sources, so a C compiler is required. CMake and OpenSSL are not. git2 is built without default features. - Key crates: axum 0.8, askama 0.16, sqlx 0.8 (SQLite), git2 0.21, russh 0.63, syntect + two-face, comrak + ammonia, clap 4. ## Layout | Path | What lives there | | --- | --- | | `src/main.rs` | Startup: parse config, open DB, run web and SSH servers | | `src/config.rs` | All flags and `OPENHUB_*` env vars (clap). Clone URL and cookie helpers | | `src/state.rs` | `AppState` and the permission model (`AppState::access`) | | `src/db.rs` | Every SQL query and row struct | | `migrations/` | sqlx migrations, embedded at compile time and applied at startup | | `src/git.rs` | libgit2 access: ref resolution, trees, history, diffs, mergeability, merging | | `src/sync.rs` | Bookkeeping after refs change: HEAD/default branch, open pull requests | | `src/ssh.rs` | SSH server: key auth, command parsing, running git, post-push hints | | `src/highlight.rs` | Syntax highlighting (syntect scopes mapped to `tok-*` CSS classes) | | `src/markdown.rs` | Markdown rendering, sanitisation and relative-link rewriting | | `src/validate.rs` | Usernames, repo names, emails, passwords, SSH public keys | | `src/icons.rs` | Inline SVG icons (Lucide, ISC). Add an icon by adding a match arm | | `src/web/mod.rs` | Router and helpers: `render`, `redirect`, `redirect_flash`, `blocking`, `safe_next` | | `src/web/ctx.rs` | Per-request middleware: session user, flash, Origin check, error pages, security headers | | `src/web/error.rs` | `AppError` and how it becomes a response | | `src/web/fmt.rs` | Helpers called from templates (`ago`, `date`, `plural`, `avatar`, `icon`, `urlpath`, …) | | `src/web/{account,home,repo,code,pulls}.rs` | Page handlers | | `src/web/git_http.rs` | Smart HTTP (clone/fetch only) | | `templates/` | askama templates. See `templates/CLAUDE.md` | | `static/` | `style.css`, `app.js`, `favicon.svg`. Compiled into the binary with `include_str!` | | `docs/` | System documentation (start at `docs/README.md`) and the README screenshots in `docs/screenshots/` | | `packaging/linux/` | systemd unit and `install.sh` (snapshot DB, swap binary, health check, roll back). See `docs/operations.md` | | `scripts/` | `smoke-test.sh` (end-to-end test) and `deploy-server.sh` (build and install over ssh) | ## How things fit together **Storage.** Bare repositories live at `/repos/.git` (`Config::repo_path`). They are keyed by database ID, never by name, so renames only touch the DB. Never build filesystem paths from user input. **Permissions.** `AppState::access(repo, user)` returns `Access { read, write, admin }`: - The owner and site admins (`users.is_admin`, the first account registered) get admin. - Collaborators get write. - Anyone gets read on a public repo. Web handlers must load repositories through `web::repo::load_repo`, which returns 404 when the user can't read the repo. Then call `RepoCtx::require_write()` or `require_admin()` as needed. Private repos must look identical to missing ones on every channel: web 404, HTTP git 404, SSH "Repository not found." **Request flow.** `ctx::middleware` runs on every request: - Loads the session user and flash message into a `Ctx` extension. - Rejects cross-origin POSTs, accepting the origin of `--public-url` or one matching the `Host` header. - Adds the CSP and security headers. - Turns any response carrying an `ErrorPage` extension into the full HTML error page. Handlers take `Ctx` as an extractor and return `WebResult` (`Result`). `ctx.require_user()?` redirects anonymous users to sign in. Use `redirect_flash` to show a message after a POST. **Blocking work.** git2 calls are synchronous. Run them inside `web::blocking(move || …)` (spawn_blocking). DB calls are async and happen outside it. The usual handler shape is: load the repo and check access (async), do the git work (blocking), render. **Pull requests.** - State is `open`, `closed` or `merged`. - `head_sha` is the last known head. - `base_sha` is the merge base: it is updated while the PR is open and frozen on close or merge, so the commits and diff can still be shown after the branch moves or is deleted. - Each PR's head is pinned at `refs/pull//head`. `receive.hideRefs=refs/pull` (set in `git::init_bare`) stops clients pushing there. - `sync::refresh_pulls` runs after every push and on every open-PR page view. It tracks new heads and marks PRs as merged when their head is already contained in the base (for example, merged on the command line). - The `pr_events` table is the conversation timeline. `author_id` is NULL for events Openhub detected itself. - Web merges (`git::merge`) are done in memory and update the base branch with a compare-and-swap (`reference_matching`), so a concurrent push makes the merge fail cleanly. **After a push** (SSH `receive-pack` exits 0): `sync::after_push` updates `pushed_at`, points HEAD at an existing branch (and updates `default_branch` if it had to change), then refreshes PRs. `ssh.rs` then prints the "Create a pull request" / "View pull request" hints. ## Security rules - askama escapes everything by default. Use `|safe` only on HTML we generate: `fmt::icon`, `fmt::avatar`, `highlight::*` output and `markdown::render` output. - Any user-supplied Markdown (READMEs, PR bodies, comments) must go through `markdown::render`, which sanitises with ammonia. - Pages send `script-src 'self'`, so there can be no inline `