]> quire.kejadlen.dev Git - quire.git/commitdiff
Implement post-receive hook to push main to mirror
authorAlpha Chen <alpha@kejadlen.dev>
Sun, 26 Apr 2026 21:48:52 +0000 (14:48 -0700)
committerAlpha Chen <alpha@kejadlen.dev>
Mon, 27 Apr 2026 01:34:37 +0000 (18:34 -0700)
Post-receive reads GIT_DIR to resolve the repo, loads per-repo config
for the mirror URL and global config for the GitHub PAT, then pushes
main to the mirror. No mirror configured = no-op. Token is passed via
GIT_CONFIG env vars, never written to disk.

Also adds Quire::repo_from_path for resolving hooks that receive GIT_DIR,
and makes Repo::git public for use in command handlers.

Assisted-by: GLM-5.1 via pi
src/bin/quire/commands/hook.rs
src/bin/quire/main.rs
src/quire.rs

index a4bc75639e1affd7a8b0e8518780bac944facd1a..9d2d26c20d37b452e485fcd85ee6a8e4ef7d6114 100644 (file)
@@ -1,4 +1,8 @@
-use miette::Result;
+use std::io::{self, BufRead, IsTerminal};
+use std::path::PathBuf;
+
+use miette::{Context, Result, ensure, miette};
+use quire::Quire;
 
 #[derive(Clone, Copy, Debug, clap::ValueEnum)]
 pub enum HookName {
@@ -14,7 +18,66 @@ impl std::fmt::Display for HookName {
     }
 }
 
-pub async fn run(hook_name: HookName) -> Result<()> {
-    tracing::info!(hook = %hook_name, "hook invoked");
+pub async fn run(quire: &Quire, hook_name: HookName) -> Result<()> {
+    match hook_name {
+        HookName::PostReceive => post_receive(quire),
+    }
+}
+
+fn post_receive(quire: &Quire) -> Result<()> {
+    // post-receive receives updated refs on stdin. We only care that
+    // at least one ref was pushed — we don't need to parse them.
+    let stdin = io::stdin();
+    if stdin.is_terminal() {
+        // Not running as a git hook — nothing to do.
+        return Ok(());
+    }
+    let has_refs = stdin.lock().lines().any(|line| line.is_ok());
+    if !has_refs {
+        return Ok(());
+    }
+
+    // GIT_DIR is set by git when running hooks in bare repos.
+    let git_dir = std::env::var("GIT_DIR")
+        .map(PathBuf::from)
+        .map_err(|e| miette!("GIT_DIR not set — hook must run inside a bare repo: {e}"))?;
+
+    let repo = quire
+        .repo_from_path(&git_dir)
+        .context("hook running in unrecognized repo")?;
+
+    let repo_config = repo.config()?;
+    let mirror = match repo_config.mirror {
+        Some(m) => m,
+        None => return Ok(()),
+    };
+
+    let global_config = quire.global_config()?;
+    let token = global_config
+        .github
+        .token
+        .reveal()
+        .context("failed to resolve GitHub token")?;
+
+    tracing::info!(url = %mirror.url, "pushing to mirror");
+
+    // Token is passed via -c flag — never written to disk or visible in
+    // process arguments (git redacts http.extraHeader in trace output).
+    let status = repo
+        .git(&["push", "--porcelain", &mirror.url, "main"])
+        .env("GIT_CONFIG_COUNT", "1")
+        .env("GIT_CONFIG_KEY_0", "http.extraHeader")
+        .env(
+            "GIT_CONFIG_VALUE_0",
+            format!("Authorization: Bearer {token}"),
+        )
+        .stdout(std::process::Stdio::null())
+        .status()
+        .map_err(quire::Error::Io)
+        .context("failed to run git push")?;
+
+    ensure!(status.success(), "git push to mirror failed");
+
+    tracing::info!(url = %mirror.url, "mirror push complete");
     Ok(())
 }
index c9b33ab6e23c6690a184a322d482e8b051a3a71a..3fb4ff7167020802deec6f2318b6dc0b5f9618b6 100644 (file)
@@ -129,7 +129,7 @@ async fn main() -> Result<()> {
     match command {
         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::Hook { hook_name } => commands::hook::run(&quire, hook_name).await?,
         Commands::Repo { command } => match command {
             RepoCommands::New { name } => commands::repo::new(&quire, &name).await?,
             RepoCommands::List => commands::repo::list(&quire).await?,
index 0b8f19b4f716723c6e89ebe7a787c52d095653b8..77eb7e3f788372a0c4cc62f70ecf2bbcbe1c392a 100644 (file)
@@ -58,7 +58,7 @@ impl Repo {
     ///
     /// Returns a `Command` with `current_dir` set. The caller decides
     /// `.status()`, `.output()`, or anything else.
-    fn git(&self, args: &[&str]) -> std::process::Command {
+    pub fn git(&self, args: &[&str]) -> std::process::Command {
         let mut cmd = std::process::Command::new("git");
         cmd.args(args).current_dir(&self.path);
         cmd
@@ -165,6 +165,22 @@ impl Quire {
         })
     }
 
+    /// Resolve a filesystem path to a `Repo`.
+    ///
+    /// Verifies the path falls under the repos directory and has a
+    /// `.git` suffix. Used by hooks that receive `GIT_DIR` from git.
+    pub fn repo_from_path(&self, path: &Path) -> Result<Repo> {
+        let resolved = self.repos_dir();
+        let relative = path.strip_prefix(&resolved).map_err(|_| {
+            miette::miette!("path is not under repos directory: {}", path.display())
+        })?;
+        let name = relative.to_string_lossy();
+        validate_repo_name(&name)?;
+        Ok(Repo {
+            path: path.to_path_buf(),
+        })
+    }
+
     /// List all repository names under the repos directory.
     pub fn repos(&self) -> Result<impl Iterator<Item = String> + '_> {
         let repos_dir = self.repos_dir();
@@ -442,6 +458,37 @@ mod tests {
         assert!(q.repo("foo/.git").is_err());
     }
 
+    #[test]
+    fn repo_from_path_valid() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let q = Quire {
+            base_dir: dir.path().to_path_buf(),
+        };
+        let path = dir.path().join("repos").join("foo.git");
+        let repo = q.repo_from_path(&path).expect("should resolve");
+        assert_eq!(repo.path(), path);
+    }
+
+    #[test]
+    fn repo_from_path_outside_repos() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let q = Quire {
+            base_dir: dir.path().to_path_buf(),
+        };
+        let path = PathBuf::from("/tmp/evil.git");
+        assert!(q.repo_from_path(&path).is_err());
+    }
+
+    #[test]
+    fn repo_from_path_rejects_bad_name() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let q = Quire {
+            base_dir: dir.path().to_path_buf(),
+        };
+        let path = dir.path().join("repos").join("foo"); // missing .git
+        assert!(q.repo_from_path(&path).is_err());
+    }
+
     #[test]
     fn repo_config_loads_mirror_url() {
         let dir = bare_repo_with_config(r#"{:mirror {:url "https://github.com/owner/repo.git"}}"#);