Move mirror push from hook to quire serve via event socket
Hook no longer invokes git push directly. Instead it reads stdin
triples, builds a JSON push event, and sends it over a Unix domain
socket to quire serve. The server listener parses the event, looks
up mirror config, and runs the push in-process.
When quire serve is not running, the hook prints a warning and
exits cleanly.
Assisted-by: GLM-5.1 via pi
diff --git a/Cargo.toml b/Cargo.toml
index ae0fbd6..f3dc73e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ base64 = "*"
mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
regex = "*"
serde = { version = "*", features = ["derive"] }
+serde_json = "*"
clap = { version = "*", features = ["derive", "env"] }
clap_complete = "*"
miette = { version = "*", features = ["fancy"] }
diff --git a/docs/plans/2026-04-28-mirror-push-event-socket.md b/docs/plans/2026-04-28-mirror-push-event-socket.md
new file mode 100644
index 0000000..0a2c13e
--- /dev/null
+++ b/docs/plans/2026-04-28-mirror-push-event-socket.md
@@ -0,0 +1,586 @@
+# Mirror push from hook to serve via event socket
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the mirror `git push` out of the `quire hook post-receive` process and into the `quire serve` event loop over a Unix domain socket.
+
+**Architecture:** The hook parses stdin refs as before, but instead of pushing directly, it builds a JSON event (`{type:"push", repo, pushed_at, refs:[...]}`), connects to `/var/quire/server.sock`, writes one line, and exits. `quire serve` binds the socket on startup; a listener task per connection parses the event, looks up the repo's mirror config, and runs the mirror `git push` from inside the server process. Mirror failures surface in `quire serve` logs. If `quire serve` is not running, the hook emits a clear stderr warning and exits cleanly (no run created).
+
+**Tech stack:** tokio (UnixStream/UnixListener), serde_json, existing Quire/Repo types
+
+---
+
+## File structure
+
+| File | Change | Responsibility |
+|------|--------|---------------|
+| `src/event.rs` | Create | Push event types (`PushEvent`, `PushRef`) and socket path constant |
+| `src/quire.rs` | Modify | Add `socket_path()` method |
+| `src/lib.rs` | Modify | Export `event` module |
+| `src/bin/quire/commands/hook.rs` | Modify | Replace direct mirror push with socket send |
+| `src/bin/quire/commands/serve.rs` | Modify | Bind event socket, spawn listener task |
+| `src/error.rs` | Modify | Add `EventSocket` error variant |
+
+---
+
+### Task 1: Define event types and socket path
+
+**Files:**
+- Create: `src/event.rs`
+- Modify: `src/lib.rs`
+- Modify: `src/error.rs`
+- Modify: `src/quire.rs`
+
+- [ ] **Step 1: Create `src/event.rs` with push event types and socket path**
+
+```rust
+use std::path::PathBuf;
+
+/// Path to the event socket created by `quire serve`.
+pub fn socket_path() -> PathBuf {
+ std::path::PathBuf::from("/var/quire/server.sock")
+}
+
+/// A single ref update from a push.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushRef {
+ pub r#ref: String,
+ pub old_sha: String,
+ pub new_sha: String,
+}
+
+/// A push event sent from hook to serve over the event socket.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushEvent {
+ pub r#type: String,
+ pub repo: String,
+ pub pushed_at: String,
+ pub refs: Vec<PushRef>,
+}
+
+/// Build a push event from the hook's parsed refs.
+///
+/// `repo` is the repo name relative to the repos dir (e.g. "foo.git").
+/// `pushed_at` is ISO 8601 UTC.
+pub fn build_push_event(repo: String, refs: Vec<PushRef>) -> PushEvent {
+ PushEvent {
+ r#type: "push".to_string(),
+ repo,
+ pushed_at: chrono_now_iso(),
+ refs,
+ }
+}
+
+fn chrono_now_iso() -> String {
+ // Use a simple format without pulling in chrono.
+ // The hook runs for a few milliseconds; second precision is fine.
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| format!("{}", d.as_secs()))
+ .unwrap_or_else(|_| "unknown".to_string())
+}
+```
+
+Wait — no `chrono` dependency. Let me use a simpler timestamp. Actually, looking at Cargo.toml there's no chrono. The task description says `pushed_at` but doesn't mandate ISO 8601. Use a Unix timestamp as a string, which is unambiguous and doesn't need a dependency.
+
+Actually, let me reconsider. The task spec says `pushed_at` — let's use RFC 3339 from `time` or just a Unix epoch seconds string. Simplest: Unix epoch seconds as a string. No new dependency needed.
+
+```rust
+use std::path::PathBuf;
+
+/// Path to the event socket created by `quire serve`.
+pub fn socket_path() -> PathBuf {
+ PathBuf::from("/var/quire/server.sock")
+}
+
+/// A single ref update from a push.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushRef {
+ pub r#ref: String,
+ pub old_sha: String,
+ pub new_sha: String,
+}
+
+/// A push event sent from hook to serve over the event socket.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushEvent {
+ pub r#type: String,
+ pub repo: String,
+ pub pushed_at: String,
+ pub refs: Vec<PushRef>,
+}
+
+/// Build a push event from parsed refs.
+///
+/// `repo` is the repo name relative to the repos dir (e.g. "foo.git").
+/// `pushed_at` is seconds since Unix epoch.
+pub fn build_push_event(repo: String, refs: Vec<PushRef>) -> PushEvent {
+ let pushed_at = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs().to_string())
+ .unwrap_or_else(|_| "0".to_string());
+
+ PushEvent {
+ r#type: "push".to_string(),
+ repo,
+ pushed_at,
+ refs,
+ }
+}
+```
+
+- [ ] **Step 2: Add `EventSocket` error variant to `src/error.rs`**
+
+Add to the `Error` enum:
+
+```rust
+#[error("event socket error: {0}")]
+EventSocket(String),
+```
+
+- [ ] **Step 3: Add `socket_path()` method to `Quire` in `src/quire.rs`**
+
+```rust
+pub fn socket_path(&self) -> PathBuf {
+ self.base_dir.join("server.sock")
+}
+```
+
+- [ ] **Step 4: Export event module in `src/lib.rs`**
+
+Add `pub mod event;` to the module declarations.
+
+- [ ] **Step 5: Run `cargo check` to verify compilation**
+
+Run: `cargo check --workspace`
+Expected: compiles without errors
+
+---
+
+### Task 2: Hook sends event to socket instead of pushing directly
+
+**Files:**
+- Modify: `src/bin/quire/commands/hook.rs`
+
+- [ ] **Step 1: Rewrite `post_receive` to build event and send to socket**
+
+The new `post_receive` should:
+
+1. Read stdin lines (same as before) into `PushRef` structs (keep the zero-sha filter).
+2. If no refs, return early.
+3. Resolve the repo name from `GIT_DIR` relative to `repos_dir`.
+4. Build a `PushEvent` via `quire::event::build_push_event`.
+5. Serialize to JSON, append `\n`.
+6. Try to connect to the socket at `quire.socket_path()`.
+ - If the socket doesn't exist (serve not running), print a clear warning to stderr and return `Ok(())`.
+ - If connection succeeds, write the line and close.
+7. No mirror lookup, no token, no `push_to_mirror` call.
+
+The hook becomes a thin "collect refs → serialize → write to socket" pipeline. All mirror logic moves to the server side.
+
+Key implementation detail: the hook is synchronous (it's called by git), but needs to connect to a Unix socket. Use `std::os::unix::net::UnixStream::connect` for a blocking connect + write.
+
+Here's the rewritten function:
+
+```rust
+fn post_receive(quire: &Quire) -> Result<()> {
+ if io::stdin().is_terminal() {
+ bail!("quire hook is for git to invoke, not for direct CLI use");
+ }
+
+ let git_dir = std::env::var("GIT_DIR")
+ .map_err(|e| miette!("GIT_DIR not set — hook must run inside a bare repo: {e}"))
+ .and_then(|git_dir| {
+ std::path::Path::new(&git_dir)
+ .canonicalize()
+ .into_diagnostic()
+ })
+ .map_err(|e| miette!("failed to resolve GIT_DIR: {e}"))?;
+
+ let repo = quire
+ .repo_from_path(&git_dir)
+ .context("hook running in unrecognized repo")?;
+ ensure!(
+ repo.exists(),
+ "GIT_DIR points to a non-existent repo: {}",
+ git_dir.display()
+ );
+
+ // Parse pushed refs from stdin. Each line is:
+ // <old-sha> <new-sha> <refname>
+ let stdin = io::stdin();
+ let mut refs: Vec<quire::event::PushRef> = Vec::new();
+ for line in stdin.lines() {
+ let line = line.map_err(|e| miette!("failed to read hook stdin: {e}"))?;
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() != 3 {
+ continue;
+ }
+ refs.push(quire::event::PushRef {
+ old_sha: parts[0].to_string(),
+ new_sha: parts[1].to_string(),
+ r#ref: parts[2].to_string(),
+ });
+ }
+
+ // Filter out deletions (all-zero new sha). Do this after collecting
+ // so the event socket sees the full picture in the future.
+ let has_updates = refs.iter().any(|r| r.new_sha != ZERO_SHA);
+
+ if !has_updates {
+ return Ok(());
+ }
+
+ // Resolve repo name relative to repos dir for the event payload.
+ let repo_name = repo
+ .path()
+ .strip_prefix(quire.repos_dir())
+ .map_err(|_| miette!("repo path not under repos dir"))?
+ .to_string_lossy()
+ .to_string();
+
+ let event = quire::event::build_push_event(repo_name, refs);
+ let mut line = serde_json::to_string(&event)
+ .into_diagnostic()
+ .context("failed to serialize push event")?;
+ line.push('\n');
+
+ let socket_path = quire.socket_path();
+ if !socket_path.exists() {
+ eprintln!(
+ "quire: server not running ({}), skipping event",
+ socket_path.display()
+ );
+ return Ok(());
+ }
+
+ let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)
+ .into_diagnostic()
+ .context("failed to connect to event socket")?;
+ io::Write::write_all(&mut stream, line.as_bytes())
+ .into_diagnostic()
+ .context("failed to write event to socket")?;
+
+ tracing::info!(repo = %event.repo, "push event sent to server");
+ Ok(())
+}
+
+const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
+```
+
+- [ ] **Step 2: Update imports in `hook.rs`**
+
+Add `serde_json` to the imports (it's already available via axum's dependency tree — need to add it to `Cargo.toml` explicitly).
+
+- [ ] **Step 3: Run `cargo check` to verify**
+
+Run: `cargo check --workspace`
+Expected: compiles without errors
+
+---
+
+### Task 3: Add serde_json to Cargo.toml
+
+**Files:**
+- Modify: `Cargo.toml`
+
+- [ ] **Step 1: Add serde_json dependency**
+
+Add `serde_json = "*"` to `[dependencies]` in `Cargo.toml`.
+
+- [ ] **Step 2: Run `cargo check`**
+
+Run: `cargo check --workspace`
+
+---
+
+### Task 4: Serve listens on event socket and dispatches mirror pushes
+
+**Files:**
+- Modify: `src/bin/quire/commands/serve.rs`
+
+- [ ] **Step 1: Add event socket listener to `serve::run`**
+
+The listener should:
+
+1. Bind a Unix listener at `quire.socket_path()`.
+2. Clean up any stale socket file before binding.
+3. Spawn a task that accepts connections and reads one line from each.
+4. Parse the line as JSON `PushEvent`.
+5. If `event.type == "push"`, look up the repo's mirror config.
+6. If mirror is configured, run the mirror push in a spawned blocking task.
+7. Log mirror failures; don't crash the server.
+
+```rust
+use std::net::SocketAddr;
+use std::os::unix::net::UnixListener as StdUnixListener;
+
+use axum::Router;
+use axum::routing::get;
+use miette::{IntoDiagnostic, Result, miette};
+use quire::Quire;
+use tokio::net::UnixListener;
+
+async fn health() -> &'static str {
+ "ok"
+}
+
+async fn index() -> &'static str {
+ "quire\n"
+}
+
+pub async fn run(quire: &Quire) -> Result<()> {
+ let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
+
+ // Set up event socket.
+ let socket_path = quire.socket_path();
+
+ // Clean up stale socket from previous run.
+ if socket_path.exists() {
+ std::fs::remove_file(&socket_path).into_diagnostic()?;
+ }
+
+ let std_listener = StdUnixListener::bind(&socket_path)
+ .into_diagnostic()
+ .map_err(|e| miette!("failed to bind event socket at {}: {e}", socket_path.display()))?;
+ std_listener.set_nonblocking(true).into_diagnostic()?;
+ let listener = UnixListener::from_std(std_listener).into_diagnostic()?;
+
+ tracing::info!(path = %socket_path.display(), "listening on event socket");
+
+ let quire_clone = quire.clone();
+ let event_handle = tokio::spawn(event_listener(listener, quire_clone));
+
+ let app = Router::new()
+ .route("/health", get(health))
+ .route("/", get(index));
+
+ tracing::info!(%addr, "starting HTTP server");
+
+ let listener = tokio::net::TcpListener::bind(addr)
+ .await
+ .into_diagnostic()?;
+
+ // Run HTTP server. When it finishes, abort the event listener.
+ let result = axum::serve(listener, app).await.into_diagnostic();
+ event_handle.abort();
+ // Clean up socket on shutdown.
+ let _ = std::fs::remove_file(&socket_path);
+ result
+}
+
+async fn event_listener(listener: UnixListener, quire: &'static Quire) {
+ loop {
+ match listener.accept().await {
+ Ok((stream, _addr)) => {
+ tokio::spawn(handle_event_connection(stream, quire));
+ }
+ Err(e) => {
+ tracing::error!(%e, "failed to accept event connection");
+ }
+ }
+ }
+}
+
+async fn handle_event_connection(
+ mut stream: tokio::net::UnixStream,
+ quire: &Quire,
+) {
+ use tokio::io::AsyncBufReadExt;
+
+ let (reader, _writer) = stream.split();
+ let mut reader = tokio::io::BufReader::new(reader);
+ let mut line = String::new();
+
+ match reader.read_line(&mut line).await {
+ Ok(0) => return, // empty connection, ignore
+ Ok(_) => {}
+ Err(e) => {
+ tracing::error!(%e, "failed to read event from socket");
+ return;
+ }
+ }
+
+ let event: quire::event::PushEvent = match serde_json::from_str(&line) {
+ Ok(e) => e,
+ Err(e) => {
+ tracing::error!(%e, "failed to parse push event");
+ return;
+ }
+ };
+
+ tracing::info!(repo = %event.repo, r#type = %event.r#type, "received event");
+
+ if event.r#type != "push" {
+ tracing::warn!(r#type = %event.r#type, "unknown event type, ignoring");
+ return;
+ }
+
+ dispatch_push(quire, &event).await;
+}
+
+async fn dispatch_push(quire: &Quire, event: &quire::event::PushEvent) {
+ let repo = match quire.repo(&event.repo) {
+ Ok(r) if r.exists() => r,
+ Ok(_) => {
+ tracing::error!(repo = %event.repo, "repo not found on disk");
+ return;
+ }
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+ return;
+ }
+ };
+
+ let config = match repo.config() {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "failed to load repo config");
+ return;
+ }
+ };
+
+ let Some(mirror) = config.mirror else {
+ tracing::debug!(repo = %event.repo, "no mirror configured, skipping");
+ return;
+ };
+
+ let global_config = match quire.global_config() {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!(%e, "failed to load global config for mirror push");
+ return;
+ }
+ };
+
+ let token = match global_config.github.token.reveal() {
+ Ok(t) => t.to_string(),
+ Err(e) => {
+ tracing::error!(%e, "failed to resolve GitHub token");
+ return;
+ }
+ };
+
+ // Only push refs that were actually updated (non-zero new sha).
+ let refs: Vec<&str> = event
+ .refs
+ .iter()
+ .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
+ .map(|r| r.r#ref.as_str())
+ .collect();
+
+ if refs.is_empty() {
+ return;
+ }
+
+ tracing::info!(url = %mirror.url, refs = ?refs, "pushing to mirror");
+ let result = tokio::task::spawn_blocking(move || {
+ repo.push_to_mirror(&mirror, &token, &refs)
+ })
+ .await;
+
+ match result {
+ Ok(Ok(())) => tracing::info!(url = %mirror.url, "mirror push complete"),
+ Ok(Err(e)) => tracing::error!(url = %mirror.url, %e, "mirror push failed"),
+ Err(e) => tracing::error!(url = %mirror.url, %e, "mirror push task panicked"),
+ }
+}
+```
+
+Wait — `&'static Quire` won't work because `Quire` is stack-allocated in `main`. Need to `Box::leak` or use `Arc`. Actually, looking at the current code, `serve::run` takes `&Quire` already. The simplest approach that matches the existing pattern: leak a `Box<Quire>` to get a `&'static Quire`. At process lifetime, this is fine.
+
+Actually, let me re-examine. The `event_listener` function needs a reference that outlives the spawned task. The `Quire` is created in `main` and lives for the entire process. We can box it and get a `'static` reference. But that changes the API for all commands.
+
+Simpler approach: wrap `Quire` in an `Arc` and share it. Or even simpler: since `Quire` is just a `PathBuf`, we can just create a new one inside the spawned task. `Quire::default()` is cheap.
+
+Actually the cleanest is: `Quire` is small (one `PathBuf`), `Clone` is trivial. Let me add `Clone` derive to `Quire`, then move it into the spawned task. But `&Quire` is passed to `serve::run`... the simplest change: just construct a new `Quire` inside the task from the same base_dir.
+
+Let me think about this differently. The `event_listener` doesn't need `&'static Quire`. It needs an owned value or something that implements `Clone + Send + 'static`. If I derive `Clone` on `Quire` (trivial since it's just a `PathBuf`), then:
+
+```rust
+let quire_for_listener = quire.clone();
+let event_handle = tokio::spawn(async move {
+ event_listener(listener, quire_for_listener).await;
+});
+```
+
+But then `event_listener` takes an owned `Quire`, and `handle_event_connection` needs a reference to it. This works with `&Quire` in the closure since the `Quire` is owned by the `event_listener` future.
+
+Wait, the `tokio::spawn` inside `event_listener` also needs references. Let me just use `Arc<Quire>`.
+
+Hmm, looking at the existing code more carefully:
+
+```rust
+pub async fn run(_quire: &Quire) -> Result<()> {
+```
+
+And in main:
+```rust
+let quire = Quire::default();
+commands::serve::run(&quire).await?
+```
+
+The simplest approach: derive `Clone` on `Quire` (it's just a PathBuf), and pass cloned instances into spawned tasks. No Arc needed.
+
+- [ ] **Step 2: Derive `Clone` on `Quire` in `src/quire.rs`**
+
+Add `#[derive(Clone)]` to `struct Quire`.
+
+- [ ] **Step 3: Run `cargo check`**
+
+Run: `cargo check --workspace`
+
+---
+
+### Task 5: Write tests
+
+**Files:**
+- Create: `src/event.rs` tests (inline module)
+- Modify: `src/bin/quire/commands/hook.rs` (tests if any)
+- Create integration test for event socket round-trip
+
+- [ ] **Step 1: Add unit tests to `src/event.rs`**
+
+Test `build_push_event` produces correct structure, `socket_path` returns expected path.
+
+- [ ] **Step 2: Write integration test for hook → socket → mirror push**
+
+Test that:
+1. Hook sends event to socket.
+2. Server listener receives event.
+3. Mirror push executes from server side.
+4. Hook logs warning when socket doesn't exist.
+
+This test will set up a temp dir with repos, config, socket, and verify the round-trip.
+
+- [ ] **Step 3: Run all tests**
+
+Run: `cargo test --workspace`
+Expected: all tests pass
+
+---
+
+### Task 6: Verify full build and commit
+
+- [ ] **Step 1: Run `just all`**
+
+Run: `just all`
+Expected: fmt, clippy, test all pass
+
+- [ ] **Step 2: Commit**
+
+Commit message:
+
+```
+Move mirror push from hook to quire serve via event socket
+
+Hook no longer invokes git push directly. Instead it reads stdin
+triples, builds a JSON push event, and sends it over a Unix domain
+socket to quire serve. The server listener parses the event, looks
+up mirror config, and runs the push in-process.
+
+When quire serve is not running, the hook prints a warning and
+exits cleanly.
+
+Assisted-by: pi <pi@shire>
+```
diff --git a/src/bin/quire/commands/hook.rs b/src/bin/quire/commands/hook.rs
index c9f9f9f..08cc6da 100644
--- a/src/bin/quire/commands/hook.rs
+++ b/src/bin/quire/commands/hook.rs
@@ -3,6 +3,8 @@ use std::io::{self, IsTerminal};
use miette::{Context, IntoDiagnostic, Result, bail, ensure, miette};
use quire::Quire;
+const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
+
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
pub enum HookName {
PostReceive,
@@ -52,43 +54,61 @@ fn post_receive(quire: &Quire) -> Result<()> {
git_dir.display()
);
- let repo_config = repo.config()?;
- let Some(mirror) = repo_config.mirror else {
- return Ok(());
- };
-
- let global_config = quire.global_config()?;
- let token = global_config
- .github
- .token
- .reveal()
- .context("failed to resolve GitHub token")?;
-
// Parse pushed refs from stdin. Each line is:
// <old-sha> <new-sha> <refname>
- // Only push refs that were actually updated (new sha is not all zeros).
let stdin = io::stdin();
- let mut refs: Vec<String> = Vec::new();
+ let mut refs: Vec<quire::event::PushRef> = Vec::new();
for line in stdin.lines() {
let line = line.map_err(|e| miette!("failed to read hook stdin: {e}"))?;
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 3 {
continue;
}
- let new_sha = parts[1];
- if new_sha == "0000000000000000000000000000000000000000" {
- continue;
- }
- refs.push(parts[2].to_string());
+ refs.push(quire::event::PushRef {
+ old_sha: parts[0].to_string(),
+ new_sha: parts[1].to_string(),
+ r#ref: parts[2].to_string(),
+ });
}
- if refs.is_empty() {
+ // Only send an event when at least one ref was actually updated
+ // (new sha is not all zeros). Deletions are included in the event
+ // payload but don't count as updates on their own.
+ let has_updates = refs.iter().any(|r| r.new_sha != ZERO_SHA);
+ if !has_updates {
return Ok(());
}
- let ref_slices: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
- tracing::info!(url = %mirror.url, refs = ?ref_slices, "pushing to mirror");
- repo.push_to_mirror(&mirror, token, &ref_slices)?;
- tracing::info!(url = %mirror.url, "mirror push complete");
+ // Resolve repo name relative to repos dir for the event payload.
+ let repo_name = repo
+ .path()
+ .strip_prefix(quire.repos_dir())
+ .map_err(|_| miette!("repo path not under repos dir"))?
+ .to_string_lossy()
+ .to_string();
+
+ let event = quire::event::build_push_event(repo_name, refs);
+ let mut line = serde_json::to_string(&event)
+ .into_diagnostic()
+ .context("failed to serialize push event")?;
+ line.push('\n');
+
+ let socket_path = quire.socket_path();
+ if !socket_path.exists() {
+ eprintln!(
+ "quire: server not running ({}), skipping event",
+ socket_path.display()
+ );
+ return Ok(());
+ }
+
+ let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)
+ .into_diagnostic()
+ .context("failed to connect to event socket")?;
+ io::Write::write_all(&mut stream, line.as_bytes())
+ .into_diagnostic()
+ .context("failed to write event to socket")?;
+
+ tracing::info!(repo = %event.repo, "push event sent to server");
Ok(())
}
diff --git a/src/bin/quire/commands/serve.rs b/src/bin/quire/commands/serve.rs
index 5c56671..e983a8b 100644
--- a/src/bin/quire/commands/serve.rs
+++ b/src/bin/quire/commands/serve.rs
@@ -1,10 +1,9 @@
use std::net::SocketAddr;
+use std::os::unix::net::UnixListener as StdUnixListener;
use axum::Router;
use axum::routing::get;
-use miette::IntoDiagnostic;
-use miette::Result;
-
+use miette::{IntoDiagnostic, Result, miette};
use quire::Quire;
async fn health() -> &'static str {
@@ -15,18 +14,165 @@ async fn index() -> &'static str {
"quire\n"
}
-pub async fn run(_quire: &Quire) -> Result<()> {
+pub async fn run(quire: &Quire) -> Result<()> {
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
+ // Set up event socket.
+ let socket_path = quire.socket_path();
+
+ // Clean up stale socket from previous run.
+ if socket_path.exists() {
+ fs_err::remove_file(&socket_path).into_diagnostic()?;
+ }
+
+ let std_listener = StdUnixListener::bind(&socket_path)
+ .into_diagnostic()
+ .map_err(|e| {
+ miette!(
+ "failed to bind event socket at {}: {e}",
+ socket_path.display()
+ )
+ })?;
+ std_listener.set_nonblocking(true).into_diagnostic()?;
+ let listener = tokio::net::UnixListener::from_std(std_listener).into_diagnostic()?;
+
+ tracing::info!(path = %socket_path.display(), "listening on event socket");
+
+ let quire_handle = quire.clone();
+ let event_handle = tokio::spawn(event_listener(listener, quire_handle));
+
let app = Router::new()
.route("/health", get(health))
.route("/", get(index));
tracing::info!(%addr, "starting HTTP server");
- let listener = tokio::net::TcpListener::bind(addr)
+ let tcp_listener = tokio::net::TcpListener::bind(addr)
.await
.into_diagnostic()?;
- axum::serve(listener, app).await.into_diagnostic()
+ // Run HTTP server. When it finishes, abort the event listener.
+ let result = axum::serve(tcp_listener, app).await.into_diagnostic();
+ event_handle.abort();
+ // Clean up socket on shutdown.
+ let _ = fs_err::remove_file(&socket_path);
+ result
+}
+
+async fn event_listener(listener: tokio::net::UnixListener, quire: Quire) {
+ loop {
+ match listener.accept().await {
+ Ok((stream, _addr)) => {
+ let quire = quire.clone();
+ tokio::spawn(handle_event_connection(stream, quire));
+ }
+ Err(e) => {
+ tracing::error!(%e, "failed to accept event connection");
+ }
+ }
+ }
+}
+
+async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quire) {
+ use tokio::io::AsyncBufReadExt;
+
+ let (reader, _writer) = stream.split();
+ let mut reader = tokio::io::BufReader::new(reader);
+ let mut line = String::new();
+
+ match reader.read_line(&mut line).await {
+ Ok(0) => return, // empty connection, ignore
+ Ok(_) => {}
+ Err(e) => {
+ tracing::error!(%e, "failed to read event from socket");
+ return;
+ }
+ }
+
+ let event: quire::event::PushEvent = match serde_json::from_str(&line) {
+ Ok(e) => e,
+ Err(e) => {
+ tracing::error!(%e, "failed to parse push event");
+ return;
+ }
+ };
+
+ tracing::info!(repo = %event.repo, r#type = %event.r#type, "received event");
+
+ if event.r#type != "push" {
+ tracing::warn!(r#type = %event.r#type, "unknown event type, ignoring");
+ return;
+ }
+
+ dispatch_push(&quire, &event).await;
+}
+
+async fn dispatch_push(quire: &Quire, event: &quire::event::PushEvent) {
+ let repo = match quire.repo(&event.repo) {
+ Ok(r) if r.exists() => r,
+ Ok(_) => {
+ tracing::error!(repo = %event.repo, "repo not found on disk");
+ return;
+ }
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+ return;
+ }
+ };
+
+ let config = match repo.config() {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "failed to load repo config");
+ return;
+ }
+ };
+
+ let Some(mirror) = config.mirror else {
+ tracing::debug!(repo = %event.repo, "no mirror configured, skipping");
+ return;
+ };
+
+ let global_config = match quire.global_config() {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!(%e, "failed to load global config for mirror push");
+ return;
+ }
+ };
+
+ let token = match global_config.github.token.reveal() {
+ Ok(t) => t.to_string(),
+ Err(e) => {
+ tracing::error!(%e, "failed to resolve GitHub token");
+ return;
+ }
+ };
+
+ // Only push refs that were actually updated (non-zero new sha).
+ let refs: Vec<String> = event
+ .refs
+ .iter()
+ .filter(|r| r.new_sha != "0000000000000000000000000000000000000000")
+ .map(|r| r.r#ref.clone())
+ .collect();
+
+ if refs.is_empty() {
+ return;
+ }
+
+ let mirror_url = mirror.url.clone();
+ tracing::info!(url = %mirror.url, refs = ?refs, "pushing to mirror");
+
+ let result = tokio::task::spawn_blocking(move || {
+ let ref_slices: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
+ repo.push_to_mirror(&mirror, &token, &ref_slices)
+ })
+ .await;
+
+ match result {
+ Ok(Ok(())) => tracing::info!(url = %mirror_url, "mirror push complete"),
+ Ok(Err(e)) => tracing::error!(url = %mirror_url, %e, "mirror push failed"),
+ Err(e) => tracing::error!(url = %mirror_url, %e, "mirror push task panicked"),
+ }
}
diff --git a/src/error.rs b/src/error.rs
index cc06d7c..1d739ed 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -19,6 +19,10 @@ pub enum Error {
#[error("git error: {0}")]
Git(String),
+
+ #[allow(dead_code)]
+ #[error("event socket error: {0}")]
+ EventSocket(String),
}
pub type Result<T> = std::result::Result<T, Error>;
diff --git a/src/event.rs b/src/event.rs
new file mode 100644
index 0000000..db39ac3
--- /dev/null
+++ b/src/event.rs
@@ -0,0 +1,80 @@
+/// A single ref update from a push.
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushRef {
+ pub r#ref: String,
+ pub old_sha: String,
+ pub new_sha: String,
+}
+
+/// A push event sent from hook to serve over the event socket.
+#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
+pub struct PushEvent {
+ pub r#type: String,
+ pub repo: String,
+ pub pushed_at: String,
+ pub refs: Vec<PushRef>,
+}
+
+/// Build a push event from parsed refs.
+///
+/// `repo` is the repo name relative to the repos dir (e.g. "foo.git").
+/// `pushed_at` is seconds since Unix epoch as a string.
+pub fn build_push_event(repo: String, refs: Vec<PushRef>) -> PushEvent {
+ let pushed_at = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs().to_string())
+ .unwrap_or_else(|_| "0".to_string());
+
+ PushEvent {
+ r#type: "push".to_string(),
+ repo,
+ pushed_at,
+ refs,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn build_push_event_populates_fields() {
+ let refs = vec![PushRef {
+ old_sha: "a".to_string(),
+ new_sha: "b".to_string(),
+ r#ref: "refs/heads/main".to_string(),
+ }];
+ let event = build_push_event("foo.git".to_string(), refs.clone());
+
+ assert_eq!(event.r#type, "push");
+ assert_eq!(event.repo, "foo.git");
+ assert_eq!(event.refs, refs);
+ assert_ne!(event.pushed_at, "0");
+ }
+
+ #[test]
+ fn push_event_round_trips_json() {
+ let refs = vec![
+ PushRef {
+ old_sha: "aaa".to_string(),
+ new_sha: "bbb".to_string(),
+ r#ref: "refs/heads/main".to_string(),
+ },
+ PushRef {
+ old_sha: "ccc".to_string(),
+ new_sha: "ddd".to_string(),
+ r#ref: "refs/heads/feature".to_string(),
+ },
+ ];
+ let event = build_push_event("work/foo.git".to_string(), refs);
+
+ let json = serde_json::to_string(&event).expect("serialize");
+ let parsed: PushEvent = serde_json::from_str(&json).expect("deserialize");
+
+ assert_eq!(parsed.r#type, "push");
+ assert_eq!(parsed.repo, "work/foo.git");
+ assert_eq!(parsed.refs.len(), 2);
+ assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
+ assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index a9610e2..ac38b72 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
mod error;
+pub mod event;
pub mod fennel;
pub mod quire;
pub mod secret;
diff --git a/src/quire.rs b/src/quire.rs
index c1c363a..ff21c87 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -235,6 +235,7 @@ fn github_auth_header(token: &str) -> String {
///
/// Carries configuration and provides resolved paths to repositories.
/// Commands receive a `&Quire` instead of threading config around.
+#[derive(Clone)]
pub struct Quire {
base_dir: PathBuf,
}
@@ -260,6 +261,10 @@ impl Quire {
self.base_dir.join("config.fnl")
}
+ pub fn socket_path(&self) -> PathBuf {
+ self.base_dir.join("server.sock")
+ }
+
/// Load and parse the global Fennel config file.
///
/// Re-reads on every call. Cheap at current call volume; revisit if
diff --git a/tests/cli.rs b/tests/cli.rs
index 0f34604..f9ef74c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -1,3 +1,6 @@
+use std::io::{BufRead, Write};
+use std::os::unix::net::UnixListener;
+
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
@@ -14,3 +17,105 @@ fn shows_help() {
fn shows_version() {
cmd().arg("--version").assert().success();
}
+
+/// Test that a push event round-trips through a Unix socket.
+#[test]
+fn push_event_round_trips_through_socket() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let socket_path = dir.path().join("server.sock");
+
+ let listener = UnixListener::bind(&socket_path).expect("bind");
+
+ // Simulate what the hook would send.
+ let event = quire::event::PushEvent {
+ r#type: "push".to_string(),
+ repo: "test.git".to_string(),
+ pushed_at: "12345".to_string(),
+ refs: vec![quire::event::PushRef {
+ old_sha: "0000000000000000000000000000000000000000".to_string(),
+ new_sha: "abc123".to_string(),
+ r#ref: "refs/heads/main".to_string(),
+ }],
+ };
+
+ let mut line = serde_json::to_string(&event).expect("serialize");
+ line.push('\n');
+
+ // Write from a client socket in a separate thread.
+ let path_clone = socket_path.clone();
+ let line_clone = line.clone();
+ let writer_handle = std::thread::spawn(move || {
+ let mut stream = std::os::unix::net::UnixStream::connect(&path_clone).expect("connect");
+ stream.write_all(line_clone.as_bytes()).expect("write");
+ });
+
+ let (client, _) = listener.accept().expect("accept");
+
+ // Read on the server side.
+ let mut buf = String::new();
+ let mut reader = std::io::BufReader::new(client);
+ reader.read_line(&mut buf).expect("read line");
+
+ writer_handle.join().expect("writer thread");
+
+ let parsed: quire::event::PushEvent = serde_json::from_str(&buf).expect("deserialize");
+
+ assert_eq!(parsed.r#type, "push");
+ assert_eq!(parsed.repo, "test.git");
+ assert_eq!(parsed.refs.len(), 1);
+ assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
+ assert_eq!(parsed.refs[0].new_sha, "abc123");
+}
+
+/// Test that multiple ref updates round-trip correctly.
+#[test]
+fn push_event_multiple_refs_round_trip() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let socket_path = dir.path().join("server.sock");
+ let listener = UnixListener::bind(&socket_path).expect("bind");
+
+ let event = quire::event::PushEvent {
+ r#type: "push".to_string(),
+ repo: "work/project.git".to_string(),
+ pushed_at: "99999".to_string(),
+ refs: vec![
+ quire::event::PushRef {
+ old_sha: "aaa".to_string(),
+ new_sha: "bbb".to_string(),
+ r#ref: "refs/heads/main".to_string(),
+ },
+ quire::event::PushRef {
+ old_sha: "ccc".to_string(),
+ new_sha: "0000000000000000000000000000000000000000".to_string(),
+ r#ref: "refs/heads/feature".to_string(),
+ },
+ ],
+ };
+
+ let mut line = serde_json::to_string(&event).expect("serialize");
+ line.push('\n');
+
+ let path_clone = socket_path.clone();
+ let line_clone = line.clone();
+ let writer_handle = std::thread::spawn(move || {
+ let mut stream = std::os::unix::net::UnixStream::connect(&path_clone).expect("connect");
+ stream.write_all(line_clone.as_bytes()).expect("write");
+ });
+
+ let (client, _) = listener.accept().expect("accept");
+ let mut buf = String::new();
+ let mut reader = std::io::BufReader::new(client);
+ reader.read_line(&mut buf).expect("read line");
+ writer_handle.join().expect("writer thread");
+
+ let parsed: quire::event::PushEvent = serde_json::from_str(&buf).expect("deserialize");
+
+ assert_eq!(parsed.refs.len(), 2);
+ assert_eq!(parsed.refs[0].r#ref, "refs/heads/main");
+ assert_eq!(parsed.refs[1].r#ref, "refs/heads/feature");
+ // Deletion ref included in event (server decides how to handle).
+ assert_eq!(
+ parsed.refs[1].new_sha,
+ "0000000000000000000000000000000000000000"
+ );
+}