use miette::{Context, IntoDiagnostic, Result, bail, ensure};
-use quire::Config;
-use quire::repo::Repo;
+use quire::Quire;
const GIT_COMMANDS: &[&str] = &["git-receive-pack", "git-upload-pack", "git-upload-archive"];
-pub async fn run(config: &Config, command: Vec<String>) -> Result<()> {
+pub async fn run(quire: &Quire, command: Vec<String>) -> Result<()> {
let input = if command.len() == 1 {
// Single argument: the full SSH_ORIGINAL_COMMAND string.
// e.g. git-receive-pack '/foo.git'
let cmd = &words[0];
if GIT_COMMANDS.contains(&cmd.as_str()) {
- dispatch_git(config, cmd, &words[1..])
+ dispatch_git(quire, cmd, &words[1..])
} else if cmd == "quire" {
- dispatch_quire(config, &words[1..])
+ dispatch_quire(&words[1..])
} else {
bail!("unsupported command: {cmd}")
}
}
-fn dispatch_git(config: &Config, git_cmd: &str, args: &[String]) -> Result<()> {
+fn dispatch_git(quire: &Quire, git_cmd: &str, args: &[String]) -> Result<()> {
ensure!(
args.len() == 1,
"expected usage: {git_cmd} '<repo>', got {} arguments",
let path = args[0].trim_start_matches('/');
ensure!(!path.is_empty(), "empty repository path");
- let repo = Repo::from_name(path)?;
- ensure!(
- repo.exists(&config.repos_dir),
- "repository not found: {}",
- repo.name()
- );
-
- let repo_dir = repo.path(&config.repos_dir);
+ let repo_dir = quire.repo(path)?;
+ ensure!(repo_dir.is_dir(), "repository not found: {path}");
- tracing::info!(%git_cmd, name = %repo.name(), "dispatching git command");
+ tracing::info!(%git_cmd, %path, "dispatching git command");
let err = Command::new(git_cmd).arg(".").current_dir(&repo_dir).exec();
bail!("exec failed: {err}")
}
-fn dispatch_quire(_config: &Config, args: &[String]) -> Result<()> {
+fn dispatch_quire(args: &[String]) -> Result<()> {
ensure!(!args.is_empty(), "no quire subcommand provided");
ensure!(args[0] == "repo", "unsupported quire command: {}", args[0]);
use miette::{IntoDiagnostic, Result, ensure};
-use quire::Config;
-use quire::repo::Repo;
+use quire::Quire;
-pub async fn new(config: &Config, name: &str) -> Result<()> {
- let repo = Repo::from_name(name)?;
- ensure!(
- !repo.exists(&config.repos_dir),
- "repository already exists: {}",
- repo.name()
- );
-
- let repo_dir = repo.path(&config.repos_dir);
+pub async fn new(quire: &Quire, name: &str) -> Result<()> {
+ let repo_dir = quire.repo(name)?;
+ ensure!(!repo_dir.is_dir(), "repository already exists: {name}");
// Create parent directory for grouped repos (e.g. work/foo.git).
if let Some(parent) = repo_dir.parent() {
}
let status = Command::new("git")
- .args(["init", "--bare", repo.name()])
- .current_dir(&config.repos_dir)
+ .args(["init", "--bare", name])
+ .current_dir(quire.repos_dir())
.status()
.into_diagnostic()?;
ensure!(status.success(), "git init failed");
- tracing::info!(name = %repo.name(), "created repository");
- println!("{}", repo.name());
+ tracing::info!(%name, "created repository");
+ println!("{name}");
Ok(())
}
-pub async fn list(config: &Config) -> Result<()> {
- let entries = fs_err::read_dir(&config.repos_dir).into_diagnostic()?;
-
- let mut repos: Vec<String> = Vec::new();
- for entry in entries {
- let entry = entry.into_diagnostic()?;
- let path = entry.path();
-
- if !path.is_dir() {
- continue;
- }
-
- let Ok(relative) = path.strip_prefix(&config.repos_dir) else {
- continue;
- };
- let name = relative.to_string_lossy();
-
- // Top-level .git directory.
- if name.ends_with(".git") {
- repos.push(name.to_string());
- continue;
- }
-
- // Group directory — collect .git children.
- let Ok(children) = fs_err::read_dir(&path) else {
- continue;
- };
- for child in children {
- let child = child.into_diagnostic()?;
- let child_name = child.file_name();
- let child_name = child_name.to_string_lossy();
- if child_name.ends_with(".git") && child.path().is_dir() {
- let full = format!("{}/{}", name, child_name);
- repos.push(full);
- }
- }
- }
-
- repos.sort();
- for repo in &repos {
- println!("{repo}");
+pub async fn list(quire: &Quire) -> Result<()> {
+ for name in quire.repos()? {
+ println!("{name}");
}
-
Ok(())
}
-pub async fn rm(config: &Config, name: &str) -> Result<()> {
- let repo = Repo::from_name(name)?;
- ensure!(
- repo.exists(&config.repos_dir),
- "repository not found: {}",
- repo.name()
- );
-
- let repo_dir = repo.path(&config.repos_dir);
+pub async fn rm(quire: &Quire, name: &str) -> Result<()> {
+ let repo_dir = quire.repo(name)?;
+ ensure!(repo_dir.is_dir(), "repository not found: {name}");
fs_err::remove_dir_all(&repo_dir).into_diagnostic()?;
// Clean up empty parent directory for grouped repos.
if let Some(parent) = repo_dir.parent()
- && parent != config.repos_dir
+ && parent != quire.repos_dir()
{
let _ = fs_err::remove_dir(parent);
}
- tracing::info!(name = %repo.name(), "removed repository");
+ tracing::info!(%name, "removed repository");
Ok(())
}
use miette::IntoDiagnostic;
use miette::Result;
-use quire::Config;
+use quire::Quire;
async fn health() -> &'static str {
"ok"
"quire\n"
}
-pub async fn run(_config: &Config) -> Result<()> {
+pub async fn run(_quire: &Quire) -> Result<()> {
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
let app = Router::new()
use miette::IntoDiagnostic;
use miette::Result;
use quire::Config;
+use quire::Quire;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt;
use tracing_subscriber::prelude::*;
return Ok(());
};
- let config = Config::default();
+ let quire = Quire::new(Config::default());
match command {
- Commands::Serve => commands::serve::run(&config).await?,
- Commands::Exec { command } => commands::exec::run(&config, command).await?,
+ Commands::Serve => commands::serve::run(&quire).await?,
+ Commands::Exec { command } => commands::exec::run(&quire, command).await?,
Commands::Hook { hook_name } => commands::hook::run(hook_name).await?,
Commands::Repo { command } => match command {
- RepoCommands::New { name } => commands::repo::new(&config, &name).await?,
- RepoCommands::List => commands::repo::list(&config).await?,
- RepoCommands::Rm { name } => commands::repo::rm(&config, &name).await?,
+ RepoCommands::New { name } => commands::repo::new(&quire, &name).await?,
+ RepoCommands::List => commands::repo::list(&quire).await?,
+ RepoCommands::Rm { name } => commands::repo::rm(&quire, &name).await?,
},
}
mod config;
mod error;
-pub mod repo;
+pub mod quire;
pub use config::Config;
pub use error::Error;
pub use error::Result;
+pub use quire::Quire;
--- /dev/null
+use std::path::{Path, PathBuf};
+
+use miette::{IntoDiagnostic, Result, ensure};
+
+use crate::config::Config;
+
+/// Application runtime context.
+///
+/// Carries configuration and provides resolved paths to repositories.
+/// Commands receive a `&Quire` instead of threading `&Config` around.
+pub struct Quire {
+ config: Config,
+}
+
+impl Quire {
+ pub fn new(config: Config) -> Self {
+ Self { config }
+ }
+
+ pub fn repos_dir(&self) -> &Path {
+ &self.config.repos_dir
+ }
+
+ /// Validate a repository name and return its resolved path.
+ ///
+ /// Rejects path traversal, missing `.git` suffix, empty segments,
+ /// reserved path components, and more than one level of grouping.
+ pub fn repo(&self, name: &str) -> Result<PathBuf> {
+ validate_repo_name(name)?;
+ Ok(self.config.repos_dir.join(name))
+ }
+
+ /// List all repository names under the repos directory.
+ pub fn repos(&self) -> Result<impl Iterator<Item = String> + '_> {
+ let entries = fs_err::read_dir(&self.config.repos_dir).into_diagnostic()?;
+
+ let mut repos: Vec<String> = Vec::new();
+ for entry in entries {
+ let entry = entry.into_diagnostic()?;
+ let path = entry.path();
+
+ if !path.is_dir() {
+ continue;
+ }
+
+ let Ok(relative) = path.strip_prefix(&self.config.repos_dir) else {
+ continue;
+ };
+ let name = relative.to_string_lossy();
+
+ // Top-level .git directory.
+ if name.ends_with(".git") {
+ repos.push(name.to_string());
+ continue;
+ }
+
+ // Group directory — collect .git children.
+ let Ok(children) = fs_err::read_dir(&path) else {
+ continue;
+ };
+ for child in children {
+ let child = child.into_diagnostic()?;
+ let child_name = child.file_name();
+ let child_name = child_name.to_string_lossy();
+ if child_name.ends_with(".git") && child.path().is_dir() {
+ let full = format!("{}/{}", name, child_name);
+ repos.push(full);
+ }
+ }
+ }
+
+ repos.sort();
+ Ok(repos.into_iter())
+ }
+}
+
+/// Validate a repository name.
+///
+/// Allows at most one level of grouping (e.g. `foo.git` or `work/foo.git`).
+/// Rejects path traversal, missing `.git` suffix, empty segments, and
+/// reserved path components.
+fn validate_repo_name(name: &str) -> Result<()> {
+ ensure!(!name.is_empty(), "repository name cannot be empty");
+ ensure!(!name.contains(".."), "invalid repository name: {name}");
+ ensure!(
+ name.ends_with(".git"),
+ "repository name must end in .git: {name}"
+ );
+ ensure!(!name.contains("//"), "invalid repository name: {name}");
+
+ let segments = name.split('/').collect::<Vec<_>>();
+ ensure!(
+ segments.len() <= 2,
+ "repository name allows at most one level of grouping: {name}"
+ );
+
+ for seg in &segments {
+ ensure!(!seg.is_empty(), "invalid repository name: {name}");
+ ensure!(
+ *seg != "." && *seg != ".." && *seg != ".git",
+ "invalid repository name: {name}"
+ );
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn quire() -> Quire {
+ Quire::new(Config::default())
+ }
+
+ #[test]
+ fn repo_valid() {
+ let q = quire();
+ assert!(q.repo("foo.git").is_ok());
+ assert!(q.repo("work/foo.git").is_ok());
+ }
+
+ #[test]
+ fn repo_resolves_path() {
+ let q = quire();
+ assert_eq!(
+ q.repo("foo.git").unwrap(),
+ PathBuf::from("/var/quire/repos/foo.git")
+ );
+ }
+
+ #[test]
+ fn rejects_empty() {
+ let q = quire();
+ assert!(q.repo("").is_err());
+ }
+
+ #[test]
+ fn rejects_traversal() {
+ let q = quire();
+ assert!(q.repo("../foo.git").is_err());
+ assert!(q.repo("foo/../../bar.git").is_err());
+ assert!(q.repo("./foo.git").is_err());
+ }
+
+ #[test]
+ fn rejects_no_git_suffix() {
+ let q = quire();
+ assert!(q.repo("foo").is_err());
+ }
+
+ #[test]
+ fn rejects_deep_nesting() {
+ let q = quire();
+ assert!(q.repo("a/b/c.git").is_err());
+ }
+
+ #[test]
+ fn rejects_double_slash() {
+ let q = quire();
+ assert!(q.repo("foo//bar.git").is_err());
+ }
+
+ #[test]
+ fn rejects_dot_git_segment() {
+ let q = quire();
+ assert!(q.repo("foo/.git").is_err());
+ }
+}
+++ /dev/null
-use std::path::{Path, PathBuf};
-
-use miette::{Result, ensure};
-
-/// A validated repository name relative to the repos directory.
-#[derive(Debug, Clone)]
-pub struct Repo {
- name: String,
-}
-
-impl Repo {
- /// Parse a repository name (e.g. `foo.git`, `work/foo.git`).
- ///
- /// Rejects path traversal, missing `.git` suffix, empty segments,
- /// reserved path components, and more than one level of grouping.
- pub fn from_name(name: &str) -> Result<Self> {
- validate_segments(name)?;
- Ok(Repo {
- name: name.to_string(),
- })
- }
-
- pub fn name(&self) -> &str {
- &self.name
- }
-
- pub fn path(&self, repos_dir: &Path) -> PathBuf {
- repos_dir.join(&self.name)
- }
-
- pub fn exists(&self, repos_dir: &Path) -> bool {
- self.path(repos_dir).is_dir()
- }
-}
-
-/// Validate segments of a repository name.
-///
-/// Allows at most one level of grouping (e.g. `foo.git` or `work/foo.git`).
-/// Rejects path traversal, missing `.git` suffix, empty segments, and
-/// reserved path components.
-fn validate_segments(name: &str) -> Result<String> {
- ensure!(!name.is_empty(), "repository name cannot be empty");
- ensure!(!name.contains(".."), "invalid repository name: {name}");
- ensure!(
- name.ends_with(".git"),
- "repository name must end in .git: {name}"
- );
- ensure!(!name.contains("//"), "invalid repository name: {name}");
-
- let segments = name.split('/').collect::<Vec<_>>();
- ensure!(
- segments.len() <= 2,
- "repository name allows at most one level of grouping: {name}"
- );
-
- for seg in &segments {
- ensure!(!seg.is_empty(), "invalid repository name: {name}");
- ensure!(
- *seg != "." && *seg != ".." && *seg != ".git",
- "invalid repository name: {name}"
- );
- }
-
- Ok(name.to_string())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn from_name_valid() {
- assert_eq!(Repo::from_name("foo.git").unwrap().name(), "foo.git");
- assert_eq!(
- Repo::from_name("work/foo.git").unwrap().name(),
- "work/foo.git"
- );
- }
-
- #[test]
- fn rejects_empty() {
- assert!(Repo::from_name("").is_err());
- }
-
- #[test]
- fn rejects_traversal() {
- assert!(Repo::from_name("../foo.git").is_err());
- assert!(Repo::from_name("foo/../../bar.git").is_err());
- assert!(Repo::from_name("./foo.git").is_err());
- }
-
- #[test]
- fn rejects_no_git_suffix() {
- assert!(Repo::from_name("foo").is_err());
- }
-
- #[test]
- fn rejects_deep_nesting() {
- assert!(Repo::from_name("a/b/c.git").is_err());
- }
-
- #[test]
- fn rejects_double_slash() {
- assert!(Repo::from_name("foo//bar.git").is_err());
- }
-
- #[test]
- fn rejects_dot_git_segment() {
- assert!(Repo::from_name("foo/.git").is_err());
- }
-}