From 1eead36179bdf4b900c14f6146b643e9bee2c57e Mon Sep 17 00:00:00 2001 From: Alpha Chen Date: Sat, 25 Apr 2026 06:47:49 -0700 Subject: [PATCH] Add repo management subcommands (new, list, rm) New `quire repo` subcommand group with new/list/rm. Name validation rejects traversal, deep nesting, missing .git suffix, and reserved segments (.git). The exec allowlist dispatches `quire repo` to the binary. Post-receive hook is configured system-wide via gitconfig instead of per-repo. Assisted-by: GLM-5.1 via pi --- Dockerfile | 3 ++ src/bin/quire/commands/exec.rs | 43 ++++++++++++---- src/bin/quire/commands/mod.rs | 1 + src/bin/quire/commands/repo/list.rs | 49 +++++++++++++++++++ src/bin/quire/commands/repo/mod.rs | 3 ++ src/bin/quire/commands/repo/new.rs | 34 +++++++++++++ src/bin/quire/commands/repo/rm.rs | 25 ++++++++++ src/bin/quire/main.rs | 29 +++++++++++ src/lib.rs | 1 + src/repo.rs | 76 +++++++++++++++++++++++++++++ 10 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 src/bin/quire/commands/repo/list.rs create mode 100644 src/bin/quire/commands/repo/mod.rs create mode 100644 src/bin/quire/commands/repo/new.rs create mode 100644 src/bin/quire/commands/repo/rm.rs create mode 100644 src/repo.rs diff --git a/Dockerfile b/Dockerfile index 9cb8cf1..c2ee439 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,9 @@ COPY --from=git-builder /usr/local/bin/git /usr/local/bin/git 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. diff --git a/src/bin/quire/commands/exec.rs b/src/bin/quire/commands/exec.rs index 85ad17c..b1219d5 100644 --- a/src/bin/quire/commands/exec.rs +++ b/src/bin/quire/commands/exec.rs @@ -5,7 +5,12 @@ use miette::{Context, IntoDiagnostic, Result, bail, ensure}; 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) -> Result<()> { let input = if command.len() == 1 { @@ -23,27 +28,45 @@ pub async fn run(config: &Config, command: Vec) -> Result<()> { 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} '', 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}") } diff --git a/src/bin/quire/commands/mod.rs b/src/bin/quire/commands/mod.rs index 68ed5a0..59b49fd 100644 --- a/src/bin/quire/commands/mod.rs +++ b/src/bin/quire/commands/mod.rs @@ -1,3 +1,4 @@ pub mod exec; pub mod hook; +pub mod repo; pub mod serve; diff --git a/src/bin/quire/commands/repo/list.rs b/src/bin/quire/commands/repo/list.rs new file mode 100644 index 0000000..da56be2 --- /dev/null +++ b/src/bin/quire/commands/repo/list.rs @@ -0,0 +1,49 @@ +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 = 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(()) +} diff --git a/src/bin/quire/commands/repo/mod.rs b/src/bin/quire/commands/repo/mod.rs new file mode 100644 index 0000000..ebd962f --- /dev/null +++ b/src/bin/quire/commands/repo/mod.rs @@ -0,0 +1,3 @@ +pub mod list; +pub mod new; +pub mod rm; diff --git a/src/bin/quire/commands/repo/new.rs b/src/bin/quire/commands/repo/new.rs new file mode 100644 index 0000000..ed03fa9 --- /dev/null +++ b/src/bin/quire/commands/repo/new.rs @@ -0,0 +1,34 @@ +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(()) +} diff --git a/src/bin/quire/commands/repo/rm.rs b/src/bin/quire/commands/repo/rm.rs new file mode 100644 index 0000000..00e6c4e --- /dev/null +++ b/src/bin/quire/commands/repo/rm.rs @@ -0,0 +1,25 @@ +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(()) +} diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs index 4a53d44..bb19d0b 100644 --- a/src/bin/quire/main.rs +++ b/src/bin/quire/main.rs @@ -43,6 +43,30 @@ enum Commands { /// 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] @@ -70,6 +94,11 @@ async fn main() -> Result<()> { 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(()) diff --git a/src/lib.rs b/src/lib.rs index 94e1a3a..bc0f5cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ mod config; mod error; +pub mod repo; pub use config::Config; pub use error::Error; diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..da4e8e5 --- /dev/null +++ b/src/repo.rs @@ -0,0 +1,76 @@ +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 { + 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::>(); + 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()); + } +} -- 2.54.0