]> quire.kejadlen.dev Git - quire.git/commitdiff
Add repo management subcommands (new, list, rm)
authorAlpha Chen <alpha@kejadlen.dev>
Sat, 25 Apr 2026 13:47:49 +0000 (06:47 -0700)
committerAlpha Chen <alpha@kejadlen.dev>
Sat, 25 Apr 2026 14:05:35 +0000 (07:05 -0700)
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
src/bin/quire/commands/exec.rs
src/bin/quire/commands/mod.rs
src/bin/quire/commands/repo/list.rs [new file with mode: 0644]
src/bin/quire/commands/repo/mod.rs [new file with mode: 0644]
src/bin/quire/commands/repo/new.rs [new file with mode: 0644]
src/bin/quire/commands/repo/rm.rs [new file with mode: 0644]
src/bin/quire/main.rs
src/lib.rs
src/repo.rs [new file with mode: 0644]

index 9cb8cf16be4a4cf5532fc0f871396633a7f1cdf9..c2ee439da96628d5ec2ee8ce2810506c6e79b4c8 100644 (file)
@@ -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.
index 85ad17c737d765d4fdf68d07e19e51cff3354896..b1219d5dc6e450410ebf19cc066b84d666c04b6b 100644 (file)
@@ -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<String>) -> Result<()> {
     let input = if command.len() == 1 {
@@ -23,27 +28,45 @@ pub async fn run(config: &Config, command: Vec<String>) -> 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} '<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}")
 }
 
index 68ed5a047587d23f156a817a380bc83340fcd3fc..59b49fd7060ef7eae93dd81507ca41f235701ad4 100644 (file)
@@ -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 (file)
index 0000000..da56be2
--- /dev/null
@@ -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<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(())
+}
diff --git a/src/bin/quire/commands/repo/mod.rs b/src/bin/quire/commands/repo/mod.rs
new file mode 100644 (file)
index 0000000..ebd962f
--- /dev/null
@@ -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 (file)
index 0000000..ed03fa9
--- /dev/null
@@ -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 (file)
index 0000000..00e6c4e
--- /dev/null
@@ -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(())
+}
index 4a53d44d6879d9cd543b6fcd3e5d9f86c4b39cd4..bb19d0bf327279d9c885dd969521d9ebb8964cfd 100644 (file)
@@ -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(())
index 94e1a3af9284e8265a6e722650adabef5f991c2e..bc0f5cf7d6b6987ce330cfa109a9bb6de5a90934 100644 (file)
@@ -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 (file)
index 0000000..da4e8e5
--- /dev/null
@@ -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<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());
+    }
+}