-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 {
}
}
-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(())
}
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?,
///
/// 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
})
}
+ /// 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();
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"}}"#);