fabrica

hanna/fabrica

feat(mirror): background sync scheduler and post-receive trigger

b63d883 · hanna committed on 2026-07-26

Add the `mirror` crate: sync_one runs one mirror (fetch for pull, push
with a branch-filter refspec for push) and records the outcome;
run_scheduler is the 60s background loop over due mirrors; and
trigger_push_mirrors propagates a received push to sync-on-push mirrors.
The server spawns the scheduler alongside web/ssh, and the post-receive
hook calls the trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +206 −1UnifiedSplit
Cargo.lock +14 −0
@@ -777,6 +777,7 @@ dependencies = [
777777 "config",
778778 "git",
779779 "mail",
780+ "mirror",
780781 "model",
781782 "rpassword",
782783 "serde",
@@ -1673,6 +1674,7 @@ dependencies = [
16731674 "cli",
16741675 "config",
16751676 "git",
1677+ "mirror",
16761678 "ssh",
16771679 "store",
16781680 "tokio",
@@ -2929,6 +2931,17 @@ dependencies = [
29292931 ]
29302932
29312933 [[package]]
2934+name = "mirror"
2935+version = "0.1.0"
2936+dependencies = [
2937+ "config",
2938+ "git",
2939+ "store",
2940+ "tokio",
2941+ "tracing",
2942+]
2943+
2944+[[package]]
29322945 name = "ml-kem"
29332946 version = "0.3.2"
29342947 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5715,6 +5728,7 @@ dependencies = [
57155728 "mail",
57165729 "maud",
57175730 "mime_guess",
5731+ "mirror",
57185732 "model",
57195733 "reqwest",
57205734 "rust-embed",
Cargo.toml +2 −0
@@ -21,6 +21,7 @@ web = { path = "crates/web" }
2121 ssh = { path = "crates/ssh" }
2222 config = { path = "crates/config" }
2323 store = { path = "crates/store" }
24+mirror = { path = "crates/mirror" }
2425 # Direct, optional dep only to expose git's `vendored` feature at the binary
2526 # (feature unification then applies it to the transitive `git` too).
2627 git = { path = "crates/git", optional = true }
@@ -62,6 +63,7 @@ ssh = { path = "crates/ssh" }
6263 web = { path = "crates/web" }
6364 api = { path = "crates/api" }
6465 cli = { path = "crates/cli" }
66+mirror = { path = "crates/mirror" }
6567
6668 # External dependencies. Versions are centralised here; crates opt in with
6769 # `<dep> = { workspace = true }` so the whole workspace stays on one version.
crates/cli/Cargo.toml +1 −0
@@ -14,6 +14,7 @@ config = { workspace = true }
1414 store = { workspace = true }
1515 auth = { workspace = true }
1616 git = { workspace = true }
17+mirror = { workspace = true }
1718 mail = { workspace = true }
1819 model = { workspace = true }
1920 anyhow = { workspace = true }
crates/cli/src/hook_cmd.rs +3 −1
@@ -65,8 +65,10 @@ fn post_receive() -> anyhow::Result<()> {
6565 let size = dir_size(&git_dir);
6666
6767 block_on(async move {
68- let (_config, store) = open_store(config_path.as_deref()).await?;
68+ let (config, store) = open_store(config_path.as_deref()).await?;
6969 store.record_push(&repo_id, size).await?;
70+ // Propagate the push to any "sync on push" mirrors (best-effort).
71+ mirror::trigger_push_mirrors(&store, &config, &repo_id).await;
7072 anyhow::Ok(())
7173 })
7274 }
crates/mirror/Cargo.toml +20 −0
@@ -0,0 +1,20 @@
1+[package]
2+name = "mirror"
3+description = "Repository mirror sync: periodic push/pull to and from remotes."
4+version.workspace = true
5+edition.workspace = true
6+rust-version.workspace = true
7+license.workspace = true
8+authors.workspace = true
9+repository.workspace = true
10+publish.workspace = true
11+
12+[dependencies]
13+config = { workspace = true }
14+store = { workspace = true }
15+git = { workspace = true }
16+tokio = { workspace = true }
17+tracing = { workspace = true }
18+
19+[lints]
20+workspace = true
crates/mirror/src/lib.rs +161 −0
@@ -0,0 +1,161 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Repository mirror sync.
6+//!
7+//! [`sync_one`] runs a single mirror (push out or pull in) and records the
8+//! outcome. [`run_scheduler`] is the background loop the server spawns; it wakes
9+//! periodically and syncs every mirror whose interval has elapsed.
10+//! [`trigger_push_mirrors`] is called from the post-receive hook so a received
11+//! push propagates immediately to any "sync on push" mirrors.
12+
13+use std::sync::Arc;
14+use std::time::Duration;
15+
16+use config::Config;
17+use git::mirror::RemoteCred;
18+use store::{Mirror, MirrorDirection, Store};
19+
20+/// How often the scheduler wakes to look for due mirrors.
21+const TICK: Duration = Duration::from_secs(60);
22+
23+/// The push refspecs for a branch filter. Empty/absent mirrors all branches;
24+/// otherwise each comma- or whitespace-separated pattern becomes a heads
25+/// refspec. Tags are always included.
26+fn push_refspecs(branch_filter: Option<&str>) -> Vec<String> {
27+ let patterns: Vec<&str> = branch_filter
28+ .unwrap_or("")
29+ .split([',', ' ', '\t', '\n'])
30+ .map(str::trim)
31+ .filter(|p| !p.is_empty())
32+ .collect();
33+ if patterns.is_empty() {
34+ return git::mirror::all_refs();
35+ }
36+ let mut specs: Vec<String> = patterns
37+ .iter()
38+ .map(|p| format!("+refs/heads/{p}:refs/heads/{p}"))
39+ .collect();
40+ specs.push("+refs/tags/*:refs/tags/*".to_string());
41+ specs
42+}
43+
44+/// Run one mirror's sync and record its outcome on the mirror row.
45+///
46+/// # Errors
47+///
48+/// Returns the git error text (also stored as the mirror's `last_error`), or a
49+/// message if the repository or its on-disk path could not be resolved.
50+pub async fn sync_one(store: &Store, config: &Config, mirror: &Mirror) -> Result<(), String> {
51+ let repo = store
52+ .repo_by_id(&mirror.repo_id)
53+ .await
54+ .map_err(|e| e.to_string())?
55+ .ok_or_else(|| "repository no longer exists".to_string())?;
56+ let repo_dir = git::repo_path(&config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?;
57+ let home = config.storage.data_dir.join("tmp");
58+ let cred = RemoteCred {
59+ username: mirror.username.as_deref(),
60+ secret: mirror.secret.as_deref(),
61+ };
62+
63+ let result = match mirror.direction {
64+ MirrorDirection::Pull => {
65+ git::mirror::fetch(
66+ &config.git.binary,
67+ &repo_dir,
68+ &mirror.remote_url,
69+ cred,
70+ &home,
71+ )
72+ .await
73+ }
74+ MirrorDirection::Push => {
75+ let refspecs = push_refspecs(mirror.branch_filter.as_deref());
76+ git::mirror::push(
77+ &config.git.binary,
78+ &repo_dir,
79+ &mirror.remote_url,
80+ cred,
81+ &refspecs,
82+ &home,
83+ )
84+ .await
85+ }
86+ };
87+ if let Err(err) = &result {
88+ tracing::warn!(mirror.id = %mirror.id, error = %err, "mirror sync failed");
89+ }
90+ let _ = store
91+ .mirror_synced(&mirror.id, result.as_ref().err().map(String::as_str))
92+ .await;
93+ result
94+}
95+
96+/// Sync every push mirror on `repo_id` that is configured to sync after a push.
97+/// Best-effort: failures are recorded on the mirror, never propagated.
98+pub async fn trigger_push_mirrors(store: &Store, config: &Config, repo_id: &str) {
99+ let mirrors = match store.push_mirrors_on_push(repo_id).await {
100+ Ok(m) => m,
101+ Err(e) => {
102+ tracing::warn!(error = %e, "could not load push mirrors");
103+ return;
104+ }
105+ };
106+ for mirror in &mirrors {
107+ let _ = sync_one(store, config, mirror).await;
108+ }
109+}
110+
111+/// The background scheduler loop: every [`TICK`], sync each mirror whose interval
112+/// has elapsed. Runs until the process ends.
113+pub async fn run_scheduler(store: Store, config: Arc<Config>) {
114+ let mut ticker = tokio::time::interval(TICK);
115+ loop {
116+ ticker.tick().await;
117+ let now = now_ms();
118+ let due = match store.due_mirrors(now).await {
119+ Ok(d) => d,
120+ Err(e) => {
121+ tracing::warn!(error = %e, "could not query due mirrors");
122+ continue;
123+ }
124+ };
125+ for mirror in &due {
126+ let _ = sync_one(&store, &config, mirror).await;
127+ }
128+ }
129+}
130+
131+/// The current time in Unix milliseconds.
132+fn now_ms() -> i64 {
133+ use std::time::{SystemTime, UNIX_EPOCH};
134+ SystemTime::now()
135+ .duration_since(UNIX_EPOCH)
136+ .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+
143+ #[test]
144+ fn push_refspecs_default_is_all_refs() {
145+ assert_eq!(push_refspecs(None), git::mirror::all_refs());
146+ assert_eq!(push_refspecs(Some(" ")), git::mirror::all_refs());
147+ }
148+
149+ #[test]
150+ fn push_refspecs_honours_a_branch_filter() {
151+ let specs = push_refspecs(Some("main, release/*"));
152+ assert_eq!(
153+ specs,
154+ vec![
155+ "+refs/heads/main:refs/heads/main".to_string(),
156+ "+refs/heads/release/*:refs/heads/release/*".to_string(),
157+ "+refs/tags/*:refs/tags/*".to_string(),
158+ ]
159+ );
160+ }
161+}
crates/web/Cargo.toml +1 −0
@@ -16,6 +16,7 @@ model = { workspace = true }
1616 auth = { workspace = true }
1717 mail = { workspace = true }
1818 git = { workspace = true }
19+mirror = { workspace = true }
1920 api = { workspace = true }
2021 highlight = { workspace = true }
2122 axum = { workspace = true }
src/serve.rs +4 −0
@@ -68,11 +68,15 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> {
6868 }
6969 });
7070
71+ // The mirror scheduler syncs due push/pull mirrors in the background.
72+ let mirror_task = tokio::spawn(mirror::run_scheduler(store.clone(), Arc::clone(&config)));
73+
7174 let state = web::AppState::build(config, store, secret)?;
7275 // Load admin config overrides from the database before serving.
7376 state.reload_settings().await;
7477 let result = web::serve(state, addr).await;
7578 ssh_task.abort();
79+ mirror_task.abort();
7680 result?;
7781 Ok(ExitCode::SUCCESS)
7882 })