From 569def6c00b10dc3ccf1190067778e690e491019 Mon Sep 17 00:00:00 2001 From: Alpha Chen Date: Sun, 26 Apr 2026 14:48:52 -0700 Subject: [PATCH] Implement post-receive hook to push main to mirror 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 | 69 ++++++++++++++++++++++++++++++++-- src/bin/quire/main.rs | 2 +- src/quire.rs | 49 +++++++++++++++++++++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/bin/quire/commands/hook.rs b/src/bin/quire/commands/hook.rs index a4bc756..9d2d26c 100644 --- a/src/bin/quire/commands/hook.rs +++ b/src/bin/quire/commands/hook.rs @@ -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(()) } diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs index c9b33ab..3fb4ff7 100644 --- a/src/bin/quire/main.rs +++ b/src/bin/quire/main.rs @@ -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?, diff --git a/src/quire.rs b/src/quire.rs index 0b8f19b..77eb7e3 100644 --- a/src/quire.rs +++ b/src/quire.rs @@ -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 { + 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 + '_> { 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"}}"#); -- 2.54.0