COPY --from=git-builder /usr/local/libexec/git-core/ /usr/local/libexec/git-core/
COPY --from=builder /usr/local/cargo/bin/quire /usr/local/bin/quire
+# Configure git hooks globally so all repos inherit the post-receive dispatch.
+RUN git config --system hook.postReceive.command "quire hook post-receive"
+
# Volume layout per PLAN.md. Ownership is set on the host; the container
# runs as the host uid/gid passed via `docker exec --user`, so no user
# is created in the image.
use quire::Config;
-const GIT_COMMANDS: &[&str] = &["git-receive-pack", "git-upload-pack", "git-upload-archive"];
+const GIT_COMMANDS: &[&str] = &[
+ "git-receive-pack",
+ "git-upload-pack",
+ "git-upload-archive",
+];
+
pub async fn run(config: &Config, command: Vec<String>) -> Result<()> {
let input = if command.len() == 1 {
ensure!(!words.is_empty(), "no command provided");
- let git_cmd = &words[0];
+ let cmd = &words[0];
- ensure!(
- GIT_COMMANDS.contains(&git_cmd.as_str()),
- "unsupported command: {git_cmd}"
- );
+ if GIT_COMMANDS.contains(&cmd.as_str()) {
+ dispatch_git(config, cmd, &words[1..])
+ } else if cmd == "quire" {
+ dispatch_quire(config, &words[1..])
+ } else {
+ bail!("unsupported command: {cmd}")
+ }
+}
+fn dispatch_git(config: &Config, git_cmd: &str, args: &[String]) -> Result<()> {
ensure!(
- words.len() == 2,
+ args.len() == 1,
"expected usage: {git_cmd} '<repo>', got {} arguments",
- words.len() - 1
+ args.len()
);
- let repo = validate_repo_path(&words[1])?;
+ let repo = validate_repo_path(&args[0])?;
let repo_dir = config.repos_dir.join(&repo);
ensure!(repo_dir.is_dir(), "repository not found: {repo}");
tracing::info!(%git_cmd, %repo, "dispatching git command");
- let err = Command::new(git_cmd).arg(".").current_dir(&repo_dir).exec();
+ let err = Command::new(git_cmd)
+ .arg(".")
+ .current_dir(&repo_dir)
+ .exec();
+
+ bail!("exec failed: {err}")
+}
+
+fn dispatch_quire(_config: &Config, args: &[String]) -> Result<()> {
+ ensure!(!args.is_empty(), "no quire subcommand provided");
+
+ ensure!(args[0] == "repo", "unsupported quire command: {}", args[0]);
+ tracing::info!(subcmd = "repo", "dispatching quire command");
+ let err = Command::new("quire").arg("repo").args(&args[1..]).exec();
bail!("exec failed: {err}")
}
pub mod exec;
pub mod hook;
+pub mod repo;
pub mod serve;
--- /dev/null
+use miette::{IntoDiagnostic, Result};
+
+use quire::Config;
+
+pub async fn run(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}");
+ }
+
+ Ok(())
+}
--- /dev/null
+pub mod list;
+pub mod new;
+pub mod rm;
--- /dev/null
+use std::process::Command;
+
+use miette::{IntoDiagnostic, Result, ensure};
+
+use quire::Config;
+use quire::repo::validate_name;
+
+pub async fn run(config: &Config, name: &str) -> Result<()> {
+ let name = validate_name(name)?;
+ let repo_dir = config.repos_dir.join(&name);
+
+ ensure!(
+ !repo_dir.exists(),
+ "repository already exists: {name}"
+ );
+
+ // Create parent directory for grouped repos (e.g. work/foo.git).
+ if let Some(parent) = repo_dir.parent() {
+ fs_err::create_dir_all(parent).into_diagnostic()?;
+ }
+
+ let status = Command::new("git")
+ .args(["init", "--bare", &name])
+ .current_dir(&config.repos_dir)
+ .status()
+ .into_diagnostic()?;
+
+ ensure!(status.success(), "git init failed");
+
+ tracing::info!(%name, "created repository");
+ println!("{name}");
+
+ Ok(())
+}
--- /dev/null
+use miette::{IntoDiagnostic, Result, ensure};
+
+use quire::Config;
+use quire::repo::validate_name;
+
+pub async fn run(config: &Config, name: &str) -> Result<()> {
+ let name = validate_name(name)?;
+ let repo_dir = config.repos_dir.join(&name);
+
+ ensure!(repo_dir.exists(), "repository not found: {name}");
+ ensure!(repo_dir.is_dir(), "not a directory: {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
+ {
+ let _ = fs_err::remove_dir(parent);
+ }
+
+ tracing::info!(%name, "removed repository");
+
+ Ok(())
+}
/// The hook name (e.g. post-receive).
hook_name: crate::commands::hook::HookName,
},
+
+ /// Manage repositories.
+ Repo {
+ #[command(subcommand)]
+ command: RepoCommands,
+ },
+}
+
+#[derive(Subcommand)]
+enum RepoCommands {
+ /// Create a new bare repository.
+ New {
+ /// Repository name (e.g. foo.git or work/foo.git).
+ name: String,
+ },
+
+ /// List all repositories.
+ List,
+
+ /// Delete a repository.
+ Rm {
+ /// Repository name (e.g. foo.git or work/foo.git).
+ name: String,
+ },
}
#[tokio::main]
Commands::Serve => commands::serve::run(&config).await?,
Commands::Exec { command } => commands::exec::run(&config, command).await?,
Commands::Hook { hook_name } => commands::hook::run(hook_name).await?,
+ Commands::Repo { command } => match command {
+ RepoCommands::New { name } => commands::repo::new::run(&config, &name).await?,
+ RepoCommands::List => commands::repo::list::run(&config).await?,
+ RepoCommands::Rm { name } => commands::repo::rm::run(&config, &name).await?,
+ },
}
Ok(())
mod config;
mod error;
+pub mod repo;
pub use config::Config;
pub use error::Error;
--- /dev/null
+use miette::{Result, ensure};
+
+/// Validate a repository name for creation.
+///
+/// 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.
+pub fn validate_name(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 valid_names() {
+ assert_eq!(validate_name("foo.git").unwrap(), "foo.git");
+ assert_eq!(validate_name("work/foo.git").unwrap(), "work/foo.git");
+ }
+
+ #[test]
+ fn rejects_empty() {
+ assert!(validate_name("").is_err());
+ }
+
+ #[test]
+ fn rejects_traversal() {
+ assert!(validate_name("../foo.git").is_err());
+ assert!(validate_name("foo/../../bar.git").is_err());
+ assert!(validate_name("./foo.git").is_err());
+ }
+
+ #[test]
+ fn rejects_no_git_suffix() {
+ assert!(validate_name("foo").is_err());
+ }
+
+ #[test]
+ fn rejects_deep_nesting() {
+ assert!(validate_name("a/b/c.git").is_err());
+ }
+
+ #[test]
+ fn rejects_double_slash() {
+ assert!(validate_name("foo//bar.git").is_err());
+ }
+
+ #[test]
+ fn rejects_empty_segment() {
+ assert!(validate_name("/foo.git").is_err());
+ assert!(validate_name("foo//bar.git").is_err());
+ }
+}