refactor: split git-ents into a core crate so the server can be a subcommand
commit cafc1d4
refactor: split git-ents into a core crate so the server can be a subcommand
git-ents-server already depended on git-ents for the shared domain types
(account/checks/members/etc), so embedding git-ents-server CLI type into
git-ents own Top enum would have made git-ents depend back on
git-ents-server, a manifest-level cycle. Pulling the domain modules out into
their own git-ents-core crate breaks it: both the CLI and the server now
depend on git-ents-core instead of on each other.
refactor: extract account/checks/component/config/issues/members/revocations into a new git-ents-core crate
refactor: convert git-ents-server CLI from clap to figue and expose Args/Command as pub
feat: add git ents server, embedding git-ents-server::Args as a subcommand
chore: drop --generate-man/clap_mangen and the CI man-generation job
Assisted-by: Claude:claude-sonnet-5
crates/git-ents-server/src/checks.rs
@@ -30,7 +30,7 @@
use std::sync::{Arc, Mutex as StdMutex, PoisonError};
use std::time::{Duration, Instant};
-use git_ents::checks::{self, Check, RunOutcome, Status};
+use git_ents_core::checks::{self, Check, RunOutcome, Status};
use gix_hash::ObjectId;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use tokio::sync::Mutex;
@@ -739,7 +739,7 @@
{new} {zero} refs/heads/old\n\
{new} {new} refs/meta/checks\n\
{new} {new} refs/heads/feature\n",
- zero = git_ents::ZERO_OID,
+ zero = git_ents_core::ZERO_OID,
);
let updates = parse_updates(&input);
let refs: Vec<&str> = updates.iter().map(|u| u.ref_name).collect();
crates/git-ents-server/src/main.rs
@@ -1,192 +1,46 @@
-//! Git Ents server — helpful guardians of your git trees.
+//! `git-ents-server` — the standalone binary; see [`git_ents_server`] for the
+//! `pub` `Args`/`Command` also embedded as `git ents server`.
-mod asciidoc;
-mod checks;
-mod http;
-mod markdown;
-mod verify;
-mod web;
-
-use std::net::SocketAddr;
-use std::path::PathBuf;
use std::process::ExitCode;
-use std::sync::Arc;
-use axum::Router;
-use axum::extract::DefaultBodyLimit;
-use axum::routing::get;
-use clap::{CommandFactory, Parser, Subcommand};
-use tokio::sync::Mutex;
+use facet::Facet;
+use figue::FigueBuiltins;
-#[derive(Parser)]
-#[command(
- name = "git-ents-server",
- about = "Helpful guardians of your git trees."
-)]
-struct Args {
- #[command(subcommand)]
- command: Option<Command>,
-
- /// Generate man pages into the given directory.
- #[arg(long, value_name = "DIR")]
- generate_man: Option<PathBuf>,
-
- /// Port to listen on.
- #[arg(long, env = "PORT", default_value = "8080")]
- port: u16,
-
- /// Directory holding the bare repositories served over HTTP.
- #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")]
- data_dir: PathBuf,
-
- /// Secret seed for signed-push nonces. Setting it requires pushes to carry
- /// a signed-push certificate, enabling authentication against the signers.
- #[arg(long, env = "CERT_NONCE_SEED")]
- cert_nonce_seed: Option<String>,
-
- /// Directory of git hooks (a `pre-receive`) applied to every served repo.
- #[arg(long, env = "GIT_ENTS_HOOKS_DIR")]
- hooks_dir: Option<PathBuf>,
-
- /// The server's own SSH private key, used to sign browser-made edits. Its
- /// public half must be a member of any repo edited through the web. Editing
- /// is disabled unless this is set.
- #[arg(long, env = "GIT_ENTS_WEB_SIGNING_KEY")]
- web_signing_key: Option<PathBuf>,
-
- /// Directory where the `post-receive` hook queues pushes for the check
- /// worker to run asynchronously.
- #[arg(
- long,
- env = "GIT_ENTS_CHECKS_QUEUE",
- default_value = "/data/checks-queue"
- )]
- checks_queue: PathBuf,
-}
-
-/// Subcommands that run instead of serving HTTP.
-#[derive(Subcommand)]
-enum Command {
- /// Verify a signed push against the authorized signers (a git `pre-receive`
- /// hook).
- PreReceive,
- /// Run the configured checks against a push in a Sprite (a git
- /// `post-receive` hook).
- PostReceive,
-}
-
-/// Shared handler state: where the bare repositories live, plus a lock that
-/// serializes repository creation so concurrent first pushes cannot race.
-#[derive(Clone)]
-pub(crate) struct AppState {
- pub(crate) data_dir: PathBuf,
- pub(crate) init_lock: Arc<Mutex<()>>,
- /// When set, injected as `receive.certNonceSeed` so the backend demands a
- /// signed-push certificate the `pre-receive` hook can verify.
- pub(crate) cert_nonce_seed: Option<String>,
- /// When set, injected as `core.hooksPath` so every served repo runs the
- /// bundled `pre-receive` verifier.
- pub(crate) hooks_dir: Option<PathBuf>,
- /// Directory the `post-receive` hook queues pushes into and the check
- /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`].
- pub(crate) checks_queue: PathBuf,
- /// In-memory web sessions: a browser's signed-in public key, held for the
- /// life of the process and never persisted.
- pub(crate) sessions: web::Sessions,
- /// Outstanding one-time sign-in challenges awaiting a signature.
- pub(crate) challenges: web::Challenges,
- /// The server's own signing key for browser-made edits; `None` disables
- /// editing.
- pub(crate) web_signing_key: Option<PathBuf>,
- /// Live output for checks the worker currently has running, polled by the
- /// Checks tab's live view.
- pub(crate) live_runs: checks::LiveRegistry,
+#[derive(Facet)]
+struct Cli {
+ #[facet(flatten)]
+ args: git_ents_server::Args,
+ #[facet(flatten)]
+ builtins: FigueBuiltins,
}
fn main() -> ExitCode {
- let args = Args::parse();
-
- if let Some(Command::PreReceive) = args.command {
- return match verify::pre_receive() {
- Ok(()) => ExitCode::SUCCESS,
- Err(reason) => {
- eprintln!("error: {reason}");
- ExitCode::FAILURE
+ let config = match figue::builder::<Cli>() {
+ Ok(builder) => builder,
+ Err(error) => {
+ eprintln!("{error}");
+ return ExitCode::FAILURE;
+ }
+ }
+ .cli(|cli| cli.args(std::env::args().skip(1)))
+ .help(|help| {
+ help.program_name("git-ents-server")
+ .version(env!("CARGO_PKG_VERSION"))
+ })
+ .build();
+ let cli: Cli = match figue::Driver::new(config).run().into_result() {
+ Ok(output) => output.get(),
+ Err(figue::DriverError::Help {
+ text,
+ suggestion: suggestion @ Some(_),
+ }) => {
+ println!("{text}");
+ if let Some(s) = suggestion {
+ println!("{}", s.render_pretty());
}
- };
- }
-
- if let Some(Command::PostReceive) = args.command {
- // A post-receive failure cannot undo the push; report and exit clean so
- // a runner hiccup never looks like a rejected push.
- if let Err(reason) = checks::post_receive() {
- eprintln!("checks: {reason}");
- }
- return ExitCode::SUCCESS;
- }
-
- if let Some(dir) = args.generate_man {
- let cmd = Args::command();
- if let Err(e) = clap_mangen::generate_to(cmd, dir) {
- eprintln!("error: {e}");
- return ExitCode::FAILURE;
- }
- return ExitCode::SUCCESS;
- }
-
- let runtime = match tokio::runtime::Runtime::new() {
- Ok(runtime) => runtime,
- Err(e) => {
- eprintln!("error: failed to start runtime: {e}");
return ExitCode::FAILURE;
}
+ Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(),
};
- runtime.block_on(serve(args))
-}
-
-/// Bind the listener and serve until shutdown.
-async fn serve(args: Args) -> ExitCode {
- let state = AppState {
- data_dir: args.data_dir,
- init_lock: Arc::new(Mutex::new(())),
- cert_nonce_seed: args.cert_nonce_seed,
- hooks_dir: args.hooks_dir,
- checks_queue: args.checks_queue,
- sessions: web::new_sessions(),
- challenges: web::new_challenges(),
- web_signing_key: args.web_signing_key,
- live_runs: checks::new_live_registry(),
- };
-
- // Drain queued pushes and run their checks for the life of the server.
- tokio::spawn(checks::worker(
- state.checks_queue.clone(),
- state.live_runs.clone(),
- ));
-
- // The git smart-HTTP protocol streams whole packfiles through the request
- // body, so the default 2 MiB cap would reject any non-trivial push.
- let app = Router::new()
- .route("/healthz", get(http::health))
- .route("/", get(http::get_request))
- .route("/_debug/{*path}", get(web::handshake))
- .route("/{*path}", get(http::get_request).post(http::post_request))
- .layer(DefaultBodyLimit::disable())
- .with_state(state);
-
- let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
- let listener = match tokio::net::TcpListener::bind(addr).await {
- Ok(listener) => listener,
- Err(e) => {
- eprintln!("error: failed to bind to port {}: {e}", args.port);
- return ExitCode::FAILURE;
- }
- };
-
- if let Err(e) = axum::serve(listener, app).await {
- eprintln!("error: {e}");
- return ExitCode::FAILURE;
- }
-
- ExitCode::SUCCESS
+ git_ents_server::run(cli.args)
}
crates/git-ents-server/src/verify.rs
@@ -14,9 +14,9 @@
use std::path::Path;
use std::process::{Command, Stdio};
-use git_ents::config;
-use git_ents::members::{self, Member};
-use git_ents::revocations;
+use git_ents_core::config;
+use git_ents_core::members::{self, Member};
+use git_ents_core::revocations;
/// Verify the push git is about to apply, returning `Ok(())` to accept it or
/// `Err(reason)` to reject it. The push certificate is read from the
crates/git-ents/src/main.rs
@@ -7,7 +7,7 @@
//! and the client setup that produces the signed pushes the server
//! requires. The member commands read and write a remote's set by fetching the
//! `refs/meta/member/*` refs into the local repository, editing them through
-//! [`git_ents::members`], and pushing them back.
+//! [`git_ents_core::members`], and pushing them back.
mod debug_session;
mod interactive;
@@ -21,11 +21,11 @@
use figue::{self as args, FigueBuiltins};
use git_anchor::{LineRange, Projection};
use git_comment::{COMMENTS_NS, Comment};
-use git_ents::account::{self, Account};
-use git_ents::checks::{self, CHECKS_REF, Check};
-use git_ents::component::{self, Component, MapDocument};
-use git_ents::members::{self, MEMBER_NS, Member, Trust, member_ref};
-use git_ents::revocations::{self, REVOKED_REF, Revocation};
+use git_ents_core::account::{self, Account};
+use git_ents_core::checks::{self, CHECKS_REF, Check};
+use git_ents_core::component::{self, Component, MapDocument};
+use git_ents_core::members::{self, MEMBER_NS, Member, Trust, member_ref};
+use git_ents_core::revocations::{self, REVOKED_REF, Revocation};
/// Helpful guardians of your git trees.
#[derive(Facet)]
@@ -71,6 +71,10 @@
#[facet(args::named)]
key: Option<PathBuf>,
},
+ /// Run the bundled server: serve HTTP, or (via its own subcommand) run
+ /// the `pre-receive`/`post-receive` hooks. Also shipped as the standalone
+ /// `git-ents-server` binary.
+ Server(git_ents_server::Args),
}
#[derive(Facet)]
@@ -284,13 +288,19 @@
Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(),
};
let remote = cli.remote;
- let result = match cli.command {
- Top::Members { action } => run_members(action, &remote),
- Top::Account { action } => run_account(action, &remote),
- Top::Checks { action } => run_checks(action, &remote),
- Top::Comment { action } => run_comment(action, &remote),
- Top::Login { key } => login(&remote, key.as_deref()),
- };
+ match cli.command {
+ Top::Members { action } => exit_code(run_members(action, &remote)),
+ Top::Account { action } => exit_code(run_account(action, &remote)),
+ Top::Checks { action } => exit_code(run_checks(action, &remote)),
+ Top::Comment { action } => exit_code(run_comment(action, &remote)),
+ Top::Login { key } => exit_code(login(&remote, key.as_deref())),
+ Top::Server(args) => git_ents_server::run(args),
+ }
+}
+
+/// Translate a porcelain command's result into a process exit code, printing
+/// an error to stderr on failure.
+fn exit_code(result: Result<(), String>) -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
@@ -1459,7 +1469,7 @@
fn push_signed(remote: &str, refname: &str, expected: Option<&str>) -> Result<(), String> {
let lease = format!(
"--force-with-lease={refname}:{}",
- expected.unwrap_or(git_ents::ZERO_OID)
+ expected.unwrap_or(git_ents_core::ZERO_OID)
);
git_run(&["push", "--force-if-includes", &lease, remote, refname])
}
crates/git-ents-server/src/web/component.rs
@@ -15,13 +15,13 @@
use std::path::Path;
-use git_ents::component::Component;
+use git_ents_core::component::Component;
use maud::{Markup, html};
use super::render::Render;
/// A meta-ref component whose items load off the async runtime. Sync —
-/// `git_ents::*::load`/`list` shell out to git and read the object database
+/// `git_ents_core::*::load`/`list` shell out to git and read the object database
/// synchronously — so callers wrap it in exactly one [`load`].
pub(super) trait Loadable: Send + Sized + 'static {
/// The component's items.
@@ -38,7 +38,7 @@
}
/// A [`Loadable`] component whose items also render as a generic [`card`]:
-/// identity metadata and a [`Render`] impl (both from `git_ents::component`),
+/// identity metadata and a [`Render`] impl (both from `git_ents_core::component`),
/// plus a title and what the card shows when there are no items yet.
pub(super) trait WebComponent: Loadable + Component + Render {
/// The card title.
crates/git-ents-server/src/web/mod.rs
@@ -521,9 +521,9 @@
}
/// Load the repository's `refs/meta/config` document off the async runtime.
-async fn load_config(repo: &Path) -> Option<git_ents::config::Config> {
+async fn load_config(repo: &Path) -> Option<git_ents_core::config::Config> {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents::config::load(&repo))
+ tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo))
.await
.ok()?
.ok()
@@ -532,7 +532,7 @@
/// Count the repository's open issues off the async runtime.
async fn open_issue_count(repo: &Path) -> usize {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents::issues::open_count(&repo))
+ tokio::task::spawn_blocking(move || git_ents_core::issues::open_count(&repo))
.await
.ok()
.and_then(Result::ok)
crates/git-ents-server/src/web/pages.rs
@@ -854,7 +854,7 @@
/// the full history and the raw set, as before.
pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup {
let rel = &meta.rel;
- let checks = component::load::<git_ents::checks::Check>(repo).await;
+ let checks = component::load::<git_ents_core::checks::Check>(repo).await;
let runs = load_runs(repo).await;
let head = git_output(repo, &["rev-parse", "HEAD"])
.await
@@ -932,8 +932,8 @@
fn head_check_row(
rel: &str,
head: &str,
- check: &git_ents::checks::Check,
- head_run: Option<&git_ents::checks::Run>,
+ check: &git_ents_core::checks::Check,
+ head_run: Option<&git_ents_core::checks::Run>,
) -> Markup {
let outcome =
head_run.and_then(|run| run.results.iter().find(|result| result.name == check.name));
@@ -952,7 +952,7 @@
repo: &Path,
commit_oid: ObjectId,
name: &str,
-) -> Option<git_ents::checks::RunOutcome> {
+) -> Option<git_ents_core::checks::RunOutcome> {
load_runs(repo)
.await
.ok()?
@@ -1088,9 +1088,9 @@
}
/// Load the recorded runs off the async runtime, like [`component::load`].
-async fn load_runs(repo: &Path) -> Result<Vec<git_ents::checks::CommitRuns>, String> {
+async fn load_runs(repo: &Path) -> Result<Vec<git_ents_core::checks::CommitRuns>, String> {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents::checks::runs(&repo))
+ tokio::task::spawn_blocking(move || git_ents_core::checks::runs(&repo))
.await
.map_err(|err| err.to_string())?
.map_err(|err| err.to_string())
@@ -1101,7 +1101,7 @@
/// derived from the labels that exist. Issue creation is a write path that does
/// not exist yet, so the "New issue" button stays disabled.
pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup {
- let tpl = match component::load::<git_ents::issues::Issue>(repo).await {
+ let tpl = match component::load::<git_ents_core::issues::Issue>(repo).await {
Err(err) => IssuesTemplate {
icons: Icons,
error: Some(err),
@@ -1111,7 +1111,7 @@
closed_count: 0,
},
Ok(issues) => {
- let open: Vec<&git_ents::issues::Issue> =
+ let open: Vec<&git_ents_core::issues::Issue> =
issues.iter().filter(|issue| issue.is_open()).collect();
let closed = issues.len().saturating_sub(open.len());
let mut labels: Vec<String> = issues
@@ -1159,8 +1159,8 @@
auth: Option<&super::Auth>,
editing: bool,
) -> Markup {
- let members = component::load::<git_ents::members::Member>(repo).await;
- let checks = component::load::<git_ents::checks::Check>(repo).await;
+ let members = component::load::<git_ents_core::members::Member>(repo).await;
+ let checks = component::load::<git_ents_core::checks::Check>(repo).await;
let config = load_repo_config(repo).await;
repo_shell(
meta,
@@ -1223,9 +1223,9 @@
}
/// Load `refs/meta/config` off the async runtime, like [`component::load`].
-async fn load_repo_config(repo: &Path) -> Result<git_ents::config::Config, String> {
+async fn load_repo_config(repo: &Path) -> Result<git_ents_core::config::Config, String> {
let repo = repo.to_owned();
- tokio::task::spawn_blocking(move || git_ents::config::load(&repo))
+ tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo))
.await
.map_err(|err| err.to_string())?
.map_err(|err| err.to_string())
crates/git-ents-server/src/web/write.rs
@@ -185,8 +185,8 @@
/// self-attested member typically has no push key to gate — so the web write
/// path is the enforcement point.
fn require_admin_registered(store: &git_store::Store, username: &str) -> Result<(), String> {
- use git_ents::members::Provenance;
- let member = git_ents::members::load_with(store, username)
+ use git_ents_core::members::Provenance;
+ let member = git_ents_core::members::load_with(store, username)
.map_err(|e| format!("could not read member: {e}"))?
.ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
match member.provenance {
@@ -223,15 +223,15 @@
.ok_or_else(|| "your web key is not a member of this repository".to_owned())?;
require_admin_registered(&store, &username)?;
- let mut config =
- git_ents::config::load_with(&store).map_err(|e| format!("could not read config: {e}"))?;
+ let mut config = git_ents_core::config::load_with(&store)
+ .map_err(|e| format!("could not read config: {e}"))?;
config.description = edit.description.clone();
config.homepage = edit.homepage.clone();
config.topics = edit.topics.clone();
signed_edit(
repo,
- git_ents::config::CONFIG_REF,
+ git_ents_core::config::CONFIG_REF,
&config,
"Update configuration",
&username,
@@ -467,7 +467,7 @@
public_key: &str,
) -> Option<String> {
let wanted = normalize_key(public_key);
- let members = git_ents::members::load_all_with(store).ok()?;
+ let members = git_ents_core::members::load_all_with(store).ok()?;
members.into_iter().find_map(|member| {
member
.keys()
crates/git-ents/src/lib.rs → crates/git-ents-core/src/lib.rs
@@ -1,4 +1,5 @@
-//! Git Ents — helpful guardians of your git trees.
+//! Git Ents core — the shared domain types read and written through
+//! `git_store`, common to the CLI porcelain and the server.
pub mod account;
pub mod checks;
crates/git-ents/tests/cert_authority.rs → crates/git-ents-core/tests/cert_authority.rs
@@ -7,7 +7,7 @@
)]
//! The Phase 3 CA-pin gate: a certificate the pinned CA issued verifies against
-//! the `allowed_signers` file `git_ents::members` renders, and a certificate
+//! the `allowed_signers` file `git_ents_core::members` renders, and a certificate
//! from an unpinned CA does not. The cert-embedded signature is produced the way
//! a real client would — through an `ssh-agent` holding the key and its
//! certificate — since `ssh-keygen -Y sign` only embeds a certificate when the
@@ -17,7 +17,7 @@
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
-use git_ents::members::{Member, allowed_signers};
+use git_ents_core::members::{Member, allowed_signers};
/// The principal the CA certifies and the verifier checks — the pusher identity.
const PRINCIPAL: &str = "tester@example.com";
@@ -60,7 +60,7 @@
std::fs::remove_dir_all(&dir).ok();
}
-/// Write the `allowed_signers` file `git_ents::members` renders for a member
+/// Write the `allowed_signers` file `git_ents_core::members` renders for a member
/// whose trust is the CA `ca`, and return its path.
fn render_ca_allowed_signers(dir: &Path, name: &str, ca: &Key) -> PathBuf {
let ca_pubkey = std::fs::read_to_string(&ca.public)
crates/git-ents-server/src/lib.rs
@@ -1,0 +1,210 @@
+//! Git Ents server — helpful guardians of your git trees.
+//!
+//! [`Args`] is `pub` so `git ents` can embed this server as its own `server`
+//! subcommand, alongside the standalone `git-ents-server` binary.
+
+mod asciidoc;
+mod checks;
+mod http;
+mod markdown;
+mod verify;
+mod web;
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::process::ExitCode;
+use std::sync::Arc;
+
+use axum::Router;
+use axum::extract::DefaultBodyLimit;
+use axum::routing::get;
+use facet::Facet;
+use figue::{self as args};
+use tokio::sync::Mutex;
+
+/// Command-line arguments, layered over the matching environment variables
+/// (the flag always wins) since that is how this server is configured on
+/// Fly.io.
+#[derive(Facet, Debug)]
+pub struct Args {
+ /// Subcommand that runs instead of serving HTTP.
+ #[facet(args::subcommand)]
+ pub command: Option<Command>,
+
+ /// Port to listen on ($PORT, default 8080).
+ #[facet(args::named)]
+ pub port: Option<u16>,
+
+ /// Directory holding the bare repositories served over HTTP
+ /// ($GIT_PROJECT_ROOT, default `/data/repos`).
+ #[facet(args::named)]
+ pub data_dir: Option<PathBuf>,
+
+ /// Secret seed for signed-push nonces ($CERT_NONCE_SEED). Setting it
+ /// requires pushes to carry a signed-push certificate, enabling
+ /// authentication against the signers.
+ #[facet(args::named)]
+ pub cert_nonce_seed: Option<String>,
+
+ /// Directory of git hooks (a `pre-receive`) applied to every served repo
+ /// ($GIT_ENTS_HOOKS_DIR).
+ #[facet(args::named)]
+ pub hooks_dir: Option<PathBuf>,
+
+ /// The server's own SSH private key, used to sign browser-made edits
+ /// ($GIT_ENTS_WEB_SIGNING_KEY). Its public half must be a member of any
+ /// repo edited through the web. Editing is disabled unless this is set.
+ #[facet(args::named)]
+ pub web_signing_key: Option<PathBuf>,
+
+ /// Directory where the `post-receive` hook queues pushes for the check
+ /// worker to run asynchronously ($GIT_ENTS_CHECKS_QUEUE, default
+ /// `/data/checks-queue`).
+ #[facet(args::named)]
+ pub checks_queue: Option<PathBuf>,
+}
+
+/// Subcommands that run instead of serving HTTP.
+#[derive(Facet, Debug)]
+#[repr(u8)]
+pub enum Command {
+ /// Verify a signed push against the authorized signers (a git
+ /// `pre-receive` hook).
+ PreReceive,
+ /// Run the configured checks against a push in a Sprite (a git
+ /// `post-receive` hook).
+ PostReceive,
+}
+
+/// Shared handler state: where the bare repositories live, plus a lock that
+/// serializes repository creation so concurrent first pushes cannot race.
+#[derive(Clone)]
+pub(crate) struct AppState {
+ pub(crate) data_dir: PathBuf,
+ pub(crate) init_lock: Arc<Mutex<()>>,
+ /// When set, injected as `receive.certNonceSeed` so the backend demands a
+ /// signed-push certificate the `pre-receive` hook can verify.
+ pub(crate) cert_nonce_seed: Option<String>,
+ /// When set, injected as `core.hooksPath` so every served repo runs the
+ /// bundled `pre-receive` verifier.
+ pub(crate) hooks_dir: Option<PathBuf>,
+ /// Directory the `post-receive` hook queues pushes into and the check
+ /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`].
+ pub(crate) checks_queue: PathBuf,
+ /// In-memory web sessions: a browser's signed-in public key, held for the
+ /// life of the process and never persisted.
+ pub(crate) sessions: web::Sessions,
+ /// Outstanding one-time sign-in challenges awaiting a signature.
+ pub(crate) challenges: web::Challenges,
+ /// The server's own signing key for browser-made edits; `None` disables
+ /// editing.
+ pub(crate) web_signing_key: Option<PathBuf>,
+ /// Live output for checks the worker currently has running, polled by the
+ /// Checks tab's live view.
+ pub(crate) live_runs: checks::LiveRegistry,
+}
+
+/// The non-empty value of the environment variable `key`, or `None`.
+fn env_var(key: &str) -> Option<String> {
+ std::env::var(key).ok().filter(|value| !value.is_empty())
+}
+
+/// Run the server: dispatch `pre-receive`/`post-receive`, or serve HTTP.
+/// `args`' flags win over their matching environment variable, which in turn
+/// wins over the hardcoded default.
+pub fn run(args: Args) -> ExitCode {
+ if let Some(Command::PreReceive) = args.command {
+ return match verify::pre_receive() {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(reason) => {
+ eprintln!("error: {reason}");
+ ExitCode::FAILURE
+ }
+ };
+ }
+
+ if let Some(Command::PostReceive) = args.command {
+ // A post-receive failure cannot undo the push; report and exit clean so
+ // a runner hiccup never looks like a rejected push.
+ if let Err(reason) = checks::post_receive() {
+ eprintln!("checks: {reason}");
+ }
+ return ExitCode::SUCCESS;
+ }
+
+ let runtime = match tokio::runtime::Runtime::new() {
+ Ok(runtime) => runtime,
+ Err(e) => {
+ eprintln!("error: failed to start runtime: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+ runtime.block_on(serve(args))
+}
+
+/// Bind the listener and serve until shutdown.
+async fn serve(args: Args) -> ExitCode {
+ let port = args
+ .port
+ .or_else(|| env_var("PORT").and_then(|value| value.parse().ok()))
+ .unwrap_or(8080);
+ let data_dir = args
+ .data_dir
+ .or_else(|| env_var("GIT_PROJECT_ROOT").map(PathBuf::from))
+ .unwrap_or_else(|| PathBuf::from("/data/repos"));
+ let checks_queue = args
+ .checks_queue
+ .or_else(|| env_var("GIT_ENTS_CHECKS_QUEUE").map(PathBuf::from))
+ .unwrap_or_else(|| PathBuf::from("/data/checks-queue"));
+ let cert_nonce_seed = args.cert_nonce_seed.or_else(|| env_var("CERT_NONCE_SEED"));
+ let hooks_dir = args
+ .hooks_dir
+ .or_else(|| env_var("GIT_ENTS_HOOKS_DIR").map(PathBuf::from));
+ let web_signing_key = args
+ .web_signing_key
+ .or_else(|| env_var("GIT_ENTS_WEB_SIGNING_KEY").map(PathBuf::from));
+
+ let state = AppState {
+ data_dir,
+ init_lock: Arc::new(Mutex::new(())),
+ cert_nonce_seed,
+ hooks_dir,
+ checks_queue,
+ sessions: web::new_sessions(),
+ challenges: web::new_challenges(),
+ web_signing_key,
+ live_runs: checks::new_live_registry(),
+ };
+
+ // Drain queued pushes and run their checks for the life of the server.
+ tokio::spawn(checks::worker(
+ state.checks_queue.clone(),
+ state.live_runs.clone(),
+ ));
+
+ // The git smart-HTTP protocol streams whole packfiles through the request
+ // body, so the default 2 MiB cap would reject any non-trivial push.
+ let app = Router::new()
+ .route("/healthz", get(http::health))
+ .route("/", get(http::get_request))
+ .route("/_debug/{*path}", get(web::handshake))
+ .route("/{*path}", get(http::get_request).post(http::post_request))
+ .layer(DefaultBodyLimit::disable())
+ .with_state(state);
+
+ let addr = SocketAddr::from(([0, 0, 0, 0], port));
+ let listener = match tokio::net::TcpListener::bind(addr).await {
+ Ok(listener) => listener,
+ Err(e) => {
+ eprintln!("error: failed to bind to port {port}: {e}");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ if let Err(e) = axum::serve(listener, app).await {
+ eprintln!("error: {e}");
+ return ExitCode::FAILURE;
+ }
+
+ ExitCode::SUCCESS
+}