//! Manages the on-disk layout of bare repositories: `//.git`, one directory level per owner segment (so //! `acme/platform/api` lives at `/acme/platform/api.git`), mirroring //! Gitea's `//.git` extended to nested orgs. The //! storage tier knows nothing about accounts — it just trusts that the //! app tier already resolved a repo path against them. //! //! This module is storage-tier only — it's the one place that ever touches //! local git paths. The app tier never sees a PathBuf, only repo names, //! and talks to this tier over HTTP via `storage_client`. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use crate::gitcmd; use crate::names; /// Resolves repo names to on-disk paths under a root directory. pub struct Store { pub root: PathBuf, } impl Store { /// Returns a Store rooted at root, creating the directory if needed. pub fn new(root: impl AsRef) -> Result { let root = root.as_ref(); std::fs::create_dir_all(root).with_context(|| format!("create repo root {root:?}"))?; let root = root.canonicalize().with_context(|| format!("resolve repo root {root:?}"))?; Ok(Store { root }) } /// Returns the absolute path to the bare repo directory for name /// (without requiring it to exist). pub fn path(&self, name: &str) -> Result { names::validate_repo_path(name)?; Ok(self.root.join(format!("{name}.git"))) } /// Reports whether the named repo exists on disk. pub async fn exists(&self, name: &str) -> bool { match self.path(name) { Ok(path) => tokio::fs::try_exists(&path).await.unwrap_or(false), Err(_) => false, } } /// Returns the paths of all repos under the root. Anything that isn't /// a valid repo path — e.g. a flat `/.git` left over from /// before repos had owners — is skipped. pub fn list(&self) -> Result> { let mut names = Vec::new(); list_under(&self.root, "", &mut names)?; names.retain(|n| names::validate_repo_path(n).is_ok()); Ok(names) } /// Creates a new bare repository named `name` (git init --bare, then /// hook installation), mirroring Gitea's /// git.InitRepository + gitrepo.CreateDelegateHooks flow. pub async fn init_bare(&self, name: &str) -> Result { let path = self.path(name)?; if path.exists() { bail!("repo {name:?} already exists"); } tokio::fs::create_dir_all(&path).await?; gitcmd::run(&path, &["init", "--bare", "."]).await?; // The current binary at this point is always nugitea-storaged, // since hooks only ever run on the storage node. let storaged_bin = std::env::current_exe().context("resolve nugitea-storaged binary path")?; install_hooks(&path, &storaged_bin).await?; Ok(path) } } /// Walks owner directories under dir, collecting `` for /// each `.git` found. Doesn't descend into repos themselves. fn list_under(dir: &Path, prefix: &str, out: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { continue }; match file_name.strip_suffix(".git") { Some(name) => out.push(format!("{prefix}{name}")), None => list_under(&entry.path(), &format!("{prefix}{file_name}/"), out)?, } } Ok(()) } /// hook_names are the git server-side hooks nugitea installs a delegator /// for. Each delegator just execs `nugitea-storaged hook ` and passes /// stdin through, mirroring Gitea's hooks/ -> hooks/.d/gitea /// delegation (see modules/gitrepo/hooks.go) but flattened to a single /// script since nugitea has no per-hook custom-script fan-out to support. const HOOK_NAMES: [&str; 3] = ["pre-receive", "update", "post-receive"]; async fn install_hooks(repo_path: &Path, storaged_bin: &Path) -> Result<()> { let hooks_dir = repo_path.join("hooks"); tokio::fs::create_dir_all(&hooks_dir).await?; let bin_quoted = shell_quote_single(&storaged_bin.to_string_lossy()); for name in HOOK_NAMES { let script = format!("#!/bin/sh\n# AUTO GENERATED BY NUGITEA, DO NOT MODIFY\nexec {bin_quoted} hook {name} \"$@\"\n"); let path = hooks_dir.join(name); tokio::fs::write(&path, script).await.with_context(|| format!("write hook {name}"))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?; } } Ok(()) } /// POSIX single-quote escaping, safe for embedding an arbitrary path into a /// generated shell script. fn shell_quote_single(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn nested_layout() { let root = std::env::temp_dir().join(format!("nugitea-storage-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let store = Store::new(&root).unwrap(); assert_eq!(store.path("acme/platform/api").unwrap(), store.root.join("acme/platform/api.git")); assert!(store.path("../x").is_err()); std::fs::create_dir_all(store.root.join("acme/platform/api.git")).unwrap(); std::fs::create_dir_all(store.root.join("alice/dotfiles.git")).unwrap(); std::fs::create_dir_all(store.root.join("legacy.git")).unwrap(); let mut names = store.list().unwrap(); names.sort(); assert_eq!(names, ["acme/platform/api", "alice/dotfiles"]); assert!(store.exists("acme/platform/api").await); std::fs::remove_dir_all(&root).unwrap(); } }