anvilsign in

collin/anvil · cbf2877d

feat: pure-gitoxide push mirroring — in-process send-pack client

Collin Richards · 2026-06-10 10:18 UTC · cbf2877db1e86b00a92d823f25f5ca60541d7d61 · parent d6e72c47 · browse files

modifiedCLAUDE.md+10 −0
⋯ 1 unchanged line
22
33 A minimal, self-hosted git forge in Rust, built on gix (gitoxide).
44
5+## Design rules
6+
7+- **Pure gitoxide — never the git binary.** The product (server, CI broker,
8+ mirroring, deploy image) must not invoke or depend on a `git` executable.
9+ When gix lacks a capability, implement the protocol on gix plumbing instead
10+ of shelling out — e.g. gix can't push yet, so `anvil-git/src/push.rs` speaks
11+ the send-pack wire format itself (with `gitserver-core` covering the server
12+ side). Tests may use the git CLI, but only as a fixture/interop check, never
13+ on the code path under test.
14+
515 Dev setup: `git config core.hooksPath .githooks` — the pre-commit hook runs
616 `cargo +nightly fmt` (nightly, for the unstable options in rustfmt.toml),
717 `cargo sort-derives` (`cargo install cargo-sort-derives`), and
⋯ 6 unchanged lines
modifiedCargo.lock+77 −0
⋯ 177 unchanged lines
178178 "anvil-core",
179179 "gitserver-core",
180180 "gix",
181+ "reqwest",
182+ "rustls",
181183 "tempfile",
182184 "thiserror",
183185 "toasty",
⋯ 2132 unchanged lines
23162318 ]
23172319
23182320 [[package]]
2321+name = "hyper-rustls"
2322+version = "0.27.9"
2323+source = "registry+https://github.com/rust-lang/crates.io-index"
2324+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
2325+dependencies = [
2326+ "http",
2327+ "hyper",
2328+ "hyper-util",
2329+ "rustls",
2330+ "tokio",
2331+ "tokio-rustls",
2332+ "tower-service",
2333+ "webpki-roots",
2334+]
2335+
2336+[[package]]
23192337 name = "hyper-util"
23202338 version = "0.1.20"
23212339 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1104 unchanged lines
34263444 "http-body",
34273445 "http-body-util",
34283446 "hyper",
3447+ "hyper-rustls",
34293448 "hyper-util",
34303449 "js-sys",
34313450 "log",
34323451 "percent-encoding",
34333452 "pin-project-lite",
3453+ "rustls",
3454+ "rustls-pki-types",
34343455 "serde",
34353456 "serde_json",
34363457 "serde_urlencoded",
34373458 "sync_wrapper",
34383459 "tokio",
3460+ "tokio-rustls",
34393461 "tower",
34403462 "tower-http",
34413463 "tower-service",
⋯ 1 unchanged line
34433465 "wasm-bindgen",
34443466 "wasm-bindgen-futures",
34453467 "web-sys",
3468+ "webpki-roots",
34463469 ]
34473470
34483471 [[package]]
⋯ 183 unchanged lines
36323655 ]
36333656
36343657 [[package]]
3658+name = "rustls"
3659+version = "0.23.40"
3660+source = "registry+https://github.com/rust-lang/crates.io-index"
3661+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
3662+dependencies = [
3663+ "log",
3664+ "once_cell",
3665+ "ring",
3666+ "rustls-pki-types",
3667+ "rustls-webpki",
3668+ "subtle",
3669+ "zeroize",
3670+]
3671+
3672+[[package]]
3673+name = "rustls-pki-types"
3674+version = "1.14.1"
3675+source = "registry+https://github.com/rust-lang/crates.io-index"
3676+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
3677+dependencies = [
3678+ "zeroize",
3679+]
3680+
3681+[[package]]
3682+name = "rustls-webpki"
3683+version = "0.103.13"
3684+source = "registry+https://github.com/rust-lang/crates.io-index"
3685+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
3686+dependencies = [
3687+ "ring",
3688+ "rustls-pki-types",
3689+ "untrusted",
3690+]
3691+
3692+[[package]]
36353693 name = "rustversion"
36363694 version = "1.0.22"
36373695 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 732 unchanged lines
43704428 ]
43714429
43724430 [[package]]
4431+name = "tokio-rustls"
4432+version = "0.26.4"
4433+source = "registry+https://github.com/rust-lang/crates.io-index"
4434+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
4435+dependencies = [
4436+ "rustls",
4437+ "tokio",
4438+]
4439+
4440+[[package]]
43734441 name = "tokio-stream"
43744442 version = "0.1.18"
43754443 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 449 unchanged lines
48254893 ]
48264894
48274895 [[package]]
4896+name = "webpki-roots"
4897+version = "1.0.7"
4898+source = "registry+https://github.com/rust-lang/crates.io-index"
4899+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
4900+dependencies = [
4901+ "rustls-pki-types",
4902+]
4903+
4904+[[package]]
48284905 name = "winapi"
48294906 version = "0.3.9"
48304907 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 441 unchanged lines
modifiedCargo.toml+7 −1
⋯ 39 unchanged lines
4040 # HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy
4141 # receiver is host-local plaintext HTTP, and enabling rustls would drag in
4242 # aws-lc-rs and break the musl cross-compile (see the russh note below).
43-reqwest = { version = "0.12", default-features = false, features = ["json"] }
43+# TLS via rustls with the *ring* provider only (`-no-provider` + an explicit
44+# rustls/ring dep): aws-lc-rs needs cmake and breaks the zig musl
45+# cross-compile, ring does not. anvil-git installs the provider at use time.
46+# Used for the CD deploy webhook (anvil-ci) and HTTPS push mirroring
47+# (anvil-git).
48+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots-no-provider"] }
49+rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
4450 # Used directly only for idempotent schema shims on existing databases; the
4551 # version tracks what toasty-driver-sqlite already pulls in.
4652 rusqlite = "0.39"
⋯ 28 unchanged lines
modifiedDockerfile+3 −3
⋯ 7 unchanged lines
88
99 FROM debian:bookworm-slim
1010
11-# git is needed at runtime only for push mirroring (`git push --mirror`);
12-# everything else speaks gix in-process.
11+# No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the
12+# push-mirroring client.
1313 RUN apt-get update \
14- && apt-get install -y --no-install-recommends ca-certificates git \
14+ && apt-get install -y --no-install-recommends ca-certificates \
1515 && rm -rf /var/lib/apt/lists/* \
1616 && useradd --system --user-group --home-dir /data anvil \
1717 && mkdir -p /data /etc/anvil \
⋯ 12 unchanged lines
modifiedcrates/anvil-git/Cargo.toml+2 −0
⋯ 10 unchanged lines
1111 anvil-core.workspace = true
1212 gix.workspace = true
1313 gitserver-core.workspace = true
14+reqwest.workspace = true
15+rustls.workspace = true
1416 toasty.workspace = true
1517 tokio.workspace = true
1618 thiserror.workspace = true
⋯ 4 unchanged lines
modifiedcrates/anvil-git/src/lib.rs+1 −0
⋯ 15 unchanged lines
1616 pub mod browse;
1717 pub mod error;
1818 pub mod mirror;
19+pub mod push;
1920 pub mod smart_http;
2021 pub mod ssh;
2122 pub mod trigger;
⋯ 9 unchanged lines
modifiedcrates/anvil-git/src/mirror.rs+23 −95
1-//! Push mirroring: after a successful push to an anvil repo, forward all refs
2-//! to a configured remote (e.g. a GitHub repo) with `git push --mirror`.
1+//! Push mirroring: after a successful push to an anvil repo, mirror all refs
2+//! to a configured remote (e.g. a GitHub repo).
33 //!
4-//! This shells out to the `git` CLI — gitoxide can't push yet. The runtime
5-//! image installs git for exactly this. Mirroring is best-effort and runs in
6-//! the background: a failure is logged (with credentials stripped) and never
7-//! affects the push that triggered it.
4+//! The actual protocol work is the pure-gitoxide send-pack client in
5+//! [`crate::push`] — no `git` binary involved (a design rule; see CLAUDE.md).
6+//! Mirroring is best-effort and runs in the background: a failure is logged
7+//! (with credentials stripped) and never affects the push that triggered it.
88 //!
99 //! For GitHub over HTTPS, use a token URL:
1010 //! `https://x-access-token:<token>@github.com/you/repo.git`. The URL is
⋯ 1 unchanged line
1212
1313 use std::path::PathBuf;
1414
15-/// Spawn a background `git push --mirror <url>` for `repo_path`. Returns
15+/// Spawn a background mirror push of `repo_path` to `url`. Returns
1616 /// immediately; the result is only logged.
1717 pub fn spawn_push(repo_path: PathBuf, url: String) {
1818 tokio::spawn(async move {
1919 let shown = redact(&url);
20- match push(&repo_path, &url).await {
21- Ok(()) => tracing::info!("mirror: pushed {} to {shown}", repo_path.display()),
20+ match crate::push::mirror(&repo_path, &url).await {
21+ Ok(outcome) if outcome.up_to_date => {
22+ tracing::info!(
23+ "mirror: {} already up to date at {shown}",
24+ repo_path.display()
25+ )
26+ }
27+ Ok(outcome) => tracing::info!(
28+ "mirror: pushed {} to {shown} ({} updated, {} deleted)",
29+ repo_path.display(),
30+ outcome.updated,
31+ outcome.deleted
32+ ),
2233 Err(e) => tracing::error!(
23- "mirror: push of {} to {shown} failed: {e}",
24- repo_path.display()
34+ "mirror: push of {} to {shown} failed: {}",
35+ repo_path.display(),
36+ redact(&e)
2537 ),
2638 }
2739 });
2840 }
2941
30-async fn push(repo_path: &std::path::Path, url: &str) -> Result<(), String> {
31- let output = tokio::process::Command::new("git")
32- .arg("-C")
33- .arg(repo_path)
34- .args(["push", "--mirror", url])
35- // Never block on a credential prompt; fail instead.
36- .env("GIT_TERMINAL_PROMPT", "0")
37- .output()
38- .await
39- .map_err(|e| format!("running git: {e} (is git installed?)"))?;
40- if output.status.success() {
41- Ok(())
42- } else {
43- Err(redact(&String::from_utf8_lossy(&output.stderr))
44- .lines()
45- .collect::<Vec<_>>()
46- .join(" / "))
47- }
48-}
49-
5042 /// Strip the userinfo (`user:token@`) out of anything URL-shaped so secrets
5143 /// never reach the log.
5244 fn redact(text: &str) -> String {
⋯ 31 unchanged lines
8476 "error: https://github.com/a/b.git denied"
8577 );
8678 assert_eq!(redact("no urls here"), "no urls here");
87- }
88-
89- /// End-to-end against a local bare "remote": a mirror push transfers
90- /// branches and removes deleted ones.
91- #[tokio::test]
92- async fn mirror_push_to_local_remote() {
93- let tmp = tempfile::tempdir().unwrap();
94- let src = tmp.path().join("src.git");
95- let dst = tmp.path().join("dst.git");
96- let work = tmp.path().join("w");
97-
98- let git = |args: &[&str], dir: &std::path::Path| {
99- let out = std::process::Command::new("git")
100- .args(args)
101- .current_dir(dir)
102- .env("GIT_AUTHOR_NAME", "t")
103- .env("GIT_AUTHOR_EMAIL", "t@example.com")
104- .env("GIT_COMMITTER_NAME", "t")
105- .env("GIT_COMMITTER_EMAIL", "t@example.com")
106- .output()
107- .expect("run git");
108- assert!(out.status.success(), "git {args:?}: {out:?}");
109- };
110-
111- git(&["init", "-q", "--bare", src.to_str().unwrap()], tmp.path());
112- git(&["init", "-q", "--bare", dst.to_str().unwrap()], tmp.path());
113- git(
114- &["init", "-q", "-b", "main", work.to_str().unwrap()],
115- tmp.path(),
116- );
117- std::fs::write(work.join("f"), "x").unwrap();
118- git(&["add", "."], &work);
119- git(&["commit", "-qm", "c1"], &work);
120- git(
121- &["push", "-q", src.to_str().unwrap(), "main", "main:extra"],
122- &work,
123- );
124-
125- push(&src, dst.to_str().unwrap()).await.unwrap();
126- let heads = std::process::Command::new("git")
127- .args([
128- "-C",
129- dst.to_str().unwrap(),
130- "branch",
131- "--format=%(refname:short)",
132- ])
133- .output()
134- .unwrap();
135- let heads = String::from_utf8_lossy(&heads.stdout);
136- assert!(heads.contains("main") && heads.contains("extra"));
137-
138- // Deleting a branch upstream propagates on the next mirror push.
139- git(&["push", "-q", src.to_str().unwrap(), ":extra"], &work);
140- push(&src, dst.to_str().unwrap()).await.unwrap();
141- let heads = std::process::Command::new("git")
142- .args([
143- "-C",
144- dst.to_str().unwrap(),
145- "branch",
146- "--format=%(refname:short)",
147- ])
148- .output()
149- .unwrap();
150- assert!(!String::from_utf8_lossy(&heads.stdout).contains("extra"));
15179 }
15280 }
addedcrates/anvil-git/src/push.rs+490 −0
1+//! Client side of `git push` (the send-pack protocol), built on gix plumbing.
2+//!
3+//! anvil is pure gitoxide by design — no `git` binary anywhere in the product
4+//! (see CLAUDE.md). gix doesn't implement push yet, so this module speaks
5+//! receive-pack's wire format directly: parse the ref advertisement, compute
6+//! mirror update commands, send them with a pack from
7+//! [`gitserver_core::pack::build_raw_pack`], and read the report-status
8+//! reply. The serving counterparts live in `gitserver-core`, which makes the
9+//! whole path round-trippable in-process for tests.
10+//!
11+//! Two transports:
12+//! - smart HTTP(S) via reqwest (rustls/ring — see the workspace manifest),
13+//! with Basic credentials taken from the URL's userinfo
14+//! (`https://x-access-token:<token>@github.com/you/repo.git`);
15+//! - a local filesystem path, served by `gitserver-core` in-process.
16+
17+use std::{
18+ collections::BTreeMap,
19+ path::{
20+ Path,
21+ PathBuf,
22+ },
23+};
24+
25+use gitserver_core::{
26+ pack,
27+ pktline,
28+ receive_pack,
29+};
30+
31+const ZERO_OID: &str = "0000000000000000000000000000000000000000";
32+
33+/// What a mirror push did. `up_to_date` means no commands were needed.
34+#[derive(Debug, Default)]
35+pub struct MirrorOutcome {
36+ pub updated: usize,
37+ pub deleted: usize,
38+ /// Deletions the remote should have seen but doesn't support
39+ /// (no `delete-refs` capability).
40+ pub skipped_deletes: usize,
41+ pub up_to_date: bool,
42+}
43+
44+/// Mirror all local `refs/heads/*` and `refs/tags/*` to `remote`: create or
45+/// force-update every local ref, delete remote heads/tags with no local
46+/// counterpart. (Unlike `git push --mirror`, other remote namespaces — e.g.
47+/// GitHub's read-only `refs/pull/*` — are left alone instead of generating
48+/// rejected deletes.)
49+pub async fn mirror(repo_path: &Path, remote: &str) -> Result<MirrorOutcome, String> {
50+ let remote = Remote::parse(remote);
51+
52+ let advertisement = remote.advertise(repo_path).await?;
53+ let (remote_refs, capabilities) = parse_advertisement(&advertisement)?;
54+ let can_delete = capabilities.iter().any(|c| c == "delete-refs");
55+ let local_refs = local_refs(repo_path)?;
56+
57+ // Mirror semantics over heads + tags.
58+ let mirrored = |name: &str| name.starts_with("refs/heads/") || name.starts_with("refs/tags/");
59+ let mut commands: Vec<(String, String, String)> = Vec::new(); // (old, new, name)
60+ let mut skipped_deletes = 0;
61+ for (name, new) in &local_refs {
62+ match remote_refs.get(name) {
63+ Some(old) if old == new => {}
64+ Some(old) => commands.push((old.clone(), new.clone(), name.clone())),
65+ None => commands.push((ZERO_OID.to_string(), new.clone(), name.clone())),
66+ }
67+ }
68+ for (name, old) in remote_refs.iter().filter(|(name, _)| mirrored(name)) {
69+ if !local_refs.contains_key(name) {
70+ if can_delete {
71+ commands.push((old.clone(), ZERO_OID.to_string(), name.clone()));
72+ } else {
73+ skipped_deletes += 1;
74+ }
75+ }
76+ }
77+ if commands.is_empty() {
78+ return Ok(MirrorOutcome {
79+ up_to_date: true,
80+ skipped_deletes,
81+ ..Default::default()
82+ });
83+ }
84+
85+ // The pack covers what the remote is missing: wants are the new tips,
86+ // haves are whatever advertised tips exist in our object database.
87+ let repo = gix::open(repo_path).map_err(|e| format!("open {}: {e}", repo_path.display()))?;
88+ let parse_oid =
89+ |hex: &str| gix::ObjectId::from_hex(hex.as_bytes()).map_err(|e| format!("bad oid: {e}"));
90+ let mut wants = Vec::new();
91+ for (_, new, _) in &commands {
92+ if new != ZERO_OID {
93+ wants.push(parse_oid(new)?);
94+ }
95+ }
96+ let mut haves = Vec::new();
97+ for old in remote_refs.values() {
98+ let oid = parse_oid(old)?;
99+ if repo.try_find_object(oid).ok().flatten().is_some() {
100+ haves.push(oid);
101+ }
102+ }
103+
104+ // Request body: command pkt-lines (capabilities ride on the first one),
105+ // flush, then the pack — omitted when every command is a delete.
106+ let mut body = Vec::new();
107+ for (i, (old, new, name)) in commands.iter().enumerate() {
108+ let line = if i == 0 {
109+ format!("{old} {new} {name}\0report-status agent=anvil\n")
110+ } else {
111+ format!("{old} {new} {name}\n")
112+ };
113+ body.extend_from_slice(&pktline::encode(line.as_bytes()));
114+ }
115+ body.extend_from_slice(pktline::flush());
116+ if !wants.is_empty() {
117+ body.extend_from_slice(
118+ &pack::build_raw_pack(repo_path, &wants, &haves).map_err(|e| e.to_string())?,
119+ );
120+ }
121+
122+ let response = remote.send(repo_path, body).await?;
123+ let (updated, deleted) = commands.iter().fold((0, 0), |(u, d), (_, new, _)| {
124+ if new == ZERO_OID {
125+ (u, d + 1)
126+ } else {
127+ (u + 1, d)
128+ }
129+ });
130+ check_report_status(&response)?;
131+ Ok(MirrorOutcome {
132+ updated,
133+ deleted,
134+ skipped_deletes,
135+ up_to_date: false,
136+ })
137+}
138+
139+/// Local refs to mirror: full name → target id (tag refs keep the tag
140+/// object's id, matching what git pushes for `refs/tags/*`).
141+fn local_refs(repo_path: &Path) -> Result<BTreeMap<String, String>, String> {
142+ let repo = gix::open(repo_path).map_err(|e| format!("open {}: {e}", repo_path.display()))?;
143+ let platform = repo.references().map_err(|e| e.to_string())?;
144+ let mut out = BTreeMap::new();
145+ for r in platform.all().map_err(|e| e.to_string())?.flatten() {
146+ let name = r.name().as_bstr().to_string();
147+ if !(name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) {
148+ continue;
149+ }
150+ if let Some(id) = r.try_id() {
151+ out.insert(name, id.to_string());
152+ }
153+ }
154+ Ok(out)
155+}
156+
157+/// Parse a receive-pack ref advertisement (v0, with or without the smart-HTTP
158+/// `# service=` preamble) into (ref name → oid hex, server capabilities).
159+#[allow(clippy::type_complexity)]
160+fn parse_advertisement(bytes: &[u8]) -> Result<(BTreeMap<String, String>, Vec<String>), String> {
161+ let mut refs = BTreeMap::new();
162+ let mut capabilities = Vec::new();
163+ for line in pkt_lines(bytes)? {
164+ let line = String::from_utf8_lossy(&line);
165+ let line = line.trim_end_matches('\n');
166+ if line.starts_with('#') {
167+ continue; // smart-HTTP service preamble
168+ }
169+ // The first ref line carries capabilities after a NUL.
170+ let (line, caps) = match line.split_once('\0') {
171+ Some((line, caps)) => (line, Some(caps)),
172+ None => (line, None),
173+ };
174+ if let Some(caps) = caps {
175+ capabilities.extend(caps.split_whitespace().map(str::to_string));
176+ }
177+ let Some((oid, name)) = line.split_once(' ') else {
178+ continue;
179+ };
180+ // An empty repo advertises `<zero-oid> capabilities^{}`.
181+ if oid == ZERO_OID && name == "capabilities^{}" {
182+ continue;
183+ }
184+ // Peeled-tag annotations are informational.
185+ if name.ends_with("^{}") {
186+ continue;
187+ }
188+ refs.insert(name.to_string(), oid.to_string());
189+ }
190+ Ok((refs, capabilities))
191+}
192+
193+/// The data pkt-lines of a buffer (flush/delim packets skipped).
194+fn pkt_lines(mut bytes: &[u8]) -> Result<Vec<Vec<u8>>, String> {
195+ let mut out = Vec::new();
196+ while bytes.len() >= 4 {
197+ let len = usize::from_str_radix(
198+ std::str::from_utf8(&bytes[..4]).map_err(|_| "bad pkt length")?,
199+ 16,
200+ )
201+ .map_err(|_| "bad pkt length")?;
202+ if len < 4 {
203+ bytes = &bytes[4..]; // flush (0000) / delim (0001) / response-end
204+ continue;
205+ }
206+ if len > bytes.len() {
207+ return Err("truncated pkt-line".into());
208+ }
209+ out.push(bytes[4..len].to_vec());
210+ bytes = &bytes[len..];
211+ }
212+ Ok(out)
213+}
214+
215+/// Fail on any `unpack`/per-ref error in a report-status reply.
216+fn check_report_status(bytes: &[u8]) -> Result<(), String> {
217+ let mut errors = Vec::new();
218+ for line in pkt_lines(bytes)? {
219+ let line = String::from_utf8_lossy(&line);
220+ let line = line.trim_end();
221+ if let Some(rest) = line.strip_prefix("unpack ") {
222+ if rest != "ok" {
223+ errors.push(format!("unpack failed: {rest}"));
224+ }
225+ } else if let Some(rest) = line.strip_prefix("ng ") {
226+ errors.push(format!("ref rejected: {rest}"));
227+ }
228+ }
229+ if errors.is_empty() {
230+ Ok(())
231+ } else {
232+ Err(errors.join(" / "))
233+ }
234+}
235+
236+/// Where a push goes: a smart-HTTP(S) remote, or a bare repository on the
237+/// local filesystem (served by `gitserver-core` in-process).
238+enum Remote {
239+ Http(String),
240+ Local(PathBuf),
241+}
242+
243+impl Remote {
244+ fn parse(remote: &str) -> Self {
245+ if remote.starts_with("http://") || remote.starts_with("https://") {
246+ Remote::Http(remote.trim_end_matches('/').to_string())
247+ } else {
248+ Remote::Local(PathBuf::from(
249+ remote.strip_prefix("file://").unwrap_or(remote),
250+ ))
251+ }
252+ }
253+
254+ async fn advertise(&self, _local: &Path) -> Result<Vec<u8>, String> {
255+ match self {
256+ Remote::Local(path) => {
257+ receive_pack::advertise_receive_refs(path).map_err(|e| e.to_string())
258+ }
259+ Remote::Http(url) => {
260+ let (client, url, auth) = http_parts(url)?;
261+ let mut req = client.get(format!("{url}/info/refs?service=git-receive-pack"));
262+ if let Some((user, pass)) = &auth {
263+ req = req.basic_auth(user, pass.as_deref());
264+ }
265+ let resp = req.send().await.map_err(|e| e.to_string())?;
266+ if !resp.status().is_success() {
267+ return Err(format!("advertisement request: HTTP {}", resp.status()));
268+ }
269+ Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
270+ }
271+ }
272+ }
273+
274+ async fn send(&self, _local: &Path, body: Vec<u8>) -> Result<Vec<u8>, String> {
275+ match self {
276+ Remote::Local(path) => {
277+ let path = path.clone();
278+ // receive_pack applies the pack synchronously; keep the
279+ // executor unblocked.
280+ tokio::task::spawn_blocking(move || {
281+ receive_pack::receive_pack(&path, std::io::Cursor::new(body))
282+ .map_err(|e| e.to_string())
283+ })
284+ .await
285+ .map_err(|e| e.to_string())?
286+ }
287+ Remote::Http(url) => {
288+ let (client, url, auth) = http_parts(url)?;
289+ let mut req = client
290+ .post(format!("{url}/git-receive-pack"))
291+ .header("Content-Type", "application/x-git-receive-pack-request")
292+ .body(body);
293+ if let Some((user, pass)) = &auth {
294+ req = req.basic_auth(user, pass.as_deref());
295+ }
296+ let resp = req.send().await.map_err(|e| e.to_string())?;
297+ if !resp.status().is_success() {
298+ return Err(format!("receive-pack request: HTTP {}", resp.status()));
299+ }
300+ Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
301+ }
302+ }
303+ }
304+}
305+
306+/// Basic credentials lifted from a URL's userinfo.
307+type UrlAuth = Option<(String, Option<String>)>;
308+
309+/// Split userinfo credentials out of `url` and build a client. The rustls
310+/// *ring* provider is installed process-wide on first use (the workspace
311+/// deliberately has no default provider — aws-lc-rs breaks the musl build).
312+fn http_parts(url: &str) -> Result<(reqwest::Client, String, UrlAuth), String> {
313+ static TLS_PROVIDER: std::sync::Once = std::sync::Once::new();
314+ TLS_PROVIDER.call_once(|| {
315+ let _ = rustls::crypto::ring::default_provider().install_default();
316+ });
317+
318+ let mut parsed = reqwest::Url::parse(url).map_err(|e| format!("bad mirror url: {e}"))?;
319+ let auth = (!parsed.username().is_empty() || parsed.password().is_some()).then(|| {
320+ (
321+ parsed.username().to_string(),
322+ parsed.password().map(str::to_string),
323+ )
324+ });
325+ parsed
326+ .set_username("")
327+ .and_then(|()| parsed.set_password(None))
328+ .map_err(|()| "bad mirror url".to_string())?;
329+ let client = reqwest::Client::builder()
330+ .user_agent("anvil")
331+ .build()
332+ .map_err(|e| e.to_string())?;
333+ Ok((
334+ client,
335+ parsed.to_string().trim_end_matches('/').to_string(),
336+ auth,
337+ ))
338+}
339+
340+#[cfg(test)]
341+mod tests {
342+ use super::*;
343+
344+ /// Pure-Rust round trip: build commits with the git CLI as a fixture,
345+ /// then mirror them through this client into a bare repo served by
346+ /// gitserver-core — no git binary anywhere on the push path.
347+ #[tokio::test]
348+ async fn mirror_creates_updates_and_deletes() {
349+ let tmp = tempfile::tempdir().unwrap();
350+ let src = tmp.path().join("src.git");
351+ let dst = tmp.path().join("dst.git");
352+ let work = tmp.path().join("w");
353+ gix::init_bare(&dst).unwrap();
354+
355+ let git = |args: &[&str], dir: &Path| {
356+ let out = std::process::Command::new("git")
357+ .args(args)
358+ .current_dir(dir)
359+ .env("GIT_AUTHOR_NAME", "t")
360+ .env("GIT_AUTHOR_EMAIL", "t@example.com")
361+ .env("GIT_COMMITTER_NAME", "t")
362+ .env("GIT_COMMITTER_EMAIL", "t@example.com")
363+ .output()
364+ .expect("run git");
365+ assert!(out.status.success(), "git {args:?}: {out:?}");
366+ };
367+ git(&["init", "-q", "--bare", src.to_str().unwrap()], tmp.path());
368+ git(
369+ &["init", "-q", "-b", "main", work.to_str().unwrap()],
370+ tmp.path(),
371+ );
372+ std::fs::write(work.join("f"), "one").unwrap();
373+ git(&["add", "."], &work);
374+ git(&["commit", "-qm", "c1"], &work);
375+ git(&["tag", "-a", "-m", "annotated", "v1"], &work);
376+ git(
377+ &[
378+ "push",
379+ "-q",
380+ src.to_str().unwrap(),
381+ "main",
382+ "main:extra",
383+ "v1",
384+ ],
385+ &work,
386+ );
387+
388+ // First mirror: creates two branches and the annotated tag.
389+ let outcome = mirror(&src, dst.to_str().unwrap()).await.unwrap();
390+ assert_eq!((outcome.updated, outcome.deleted), (3, 0));
391+ let heads = gitserver_core::receive_pack::advertise_receive_refs(&dst).unwrap();
392+ let (refs, caps) = parse_advertisement(&heads).unwrap();
393+ assert!(caps.iter().any(|c| c == "delete-refs"));
394+ assert!(refs.contains_key("refs/heads/main"));
395+ assert!(refs.contains_key("refs/heads/extra"));
396+ assert!(refs.contains_key("refs/tags/v1"));
397+
398+ // Mirroring again is a no-op.
399+ assert!(
400+ mirror(&src, dst.to_str().unwrap())
401+ .await
402+ .unwrap()
403+ .up_to_date
404+ );
405+
406+ // New commit on main + branch deletion both propagate.
407+ std::fs::write(work.join("f"), "two").unwrap();
408+ git(&["commit", "-qam", "c2"], &work);
409+ git(
410+ &["push", "-q", src.to_str().unwrap(), "main", ":extra"],
411+ &work,
412+ );
413+ let outcome = mirror(&src, dst.to_str().unwrap()).await.unwrap();
414+ assert_eq!((outcome.updated, outcome.deleted), (1, 1));
415+ let (refs, _) = parse_advertisement(
416+ &gitserver_core::receive_pack::advertise_receive_refs(&dst).unwrap(),
417+ )
418+ .unwrap();
419+ assert!(!refs.contains_key("refs/heads/extra"));
420+ assert_eq!(
421+ refs["refs/heads/main"],
422+ parse_advertisement(
423+ &gitserver_core::receive_pack::advertise_receive_refs(&src).unwrap()
424+ )
425+ .unwrap()
426+ .0["refs/heads/main"],
427+ "destination main matches source after the second mirror"
428+ );
429+
430+ // The mirrored objects are genuinely usable on the destination side:
431+ // read the new file content back out of dst with gix.
432+ let dst_repo = gix::open(&dst).unwrap();
433+ let head = dst_repo
434+ .rev_parse_single("refs/heads/main")
435+ .unwrap()
436+ .object()
437+ .unwrap()
438+ .peel_to_commit()
439+ .unwrap();
440+ let blob = head
441+ .tree()
442+ .unwrap()
443+ .lookup_entry_by_path("f")
444+ .unwrap()
445+ .unwrap()
446+ .object()
447+ .unwrap();
448+ assert_eq!(blob.data.as_slice(), b"two");
449+ }
450+
451+ #[test]
452+ fn advertisement_parsing_handles_preamble_and_empty() {
453+ // Smart-HTTP preamble + first-line capabilities.
454+ let mut bytes = Vec::new();
455+ bytes.extend_from_slice(&pktline::encode_comment("service=git-receive-pack"));
456+ bytes.extend_from_slice(pktline::flush());
457+ bytes.extend_from_slice(&pktline::encode(
458+ b"1111111111111111111111111111111111111111 refs/heads/main\0report-status\n",
459+ ));
460+ bytes.extend_from_slice(&pktline::encode(
461+ b"2222222222222222222222222222222222222222 refs/tags/v1\n",
462+ ));
463+ bytes.extend_from_slice(pktline::flush());
464+ let (refs, caps) = parse_advertisement(&bytes).unwrap();
465+ assert!(caps.iter().any(|c| c == "report-status"));
466+ assert_eq!(refs.len(), 2);
467+ assert_eq!(refs["refs/heads/main"], "1".repeat(40));
468+
469+ // Empty repository advertisement.
470+ let empty =
471+ pktline::encode(format!("{ZERO_OID} capabilities^{{}}\0report-status\n").as_bytes());
472+ assert!(parse_advertisement(&empty).unwrap().0.is_empty());
473+ }
474+
475+ #[test]
476+ fn report_status_failures_surface() {
477+ let mut ok = Vec::new();
478+ ok.extend_from_slice(&pktline::encode(b"unpack ok\n"));
479+ ok.extend_from_slice(&pktline::encode(b"ok refs/heads/main\n"));
480+ ok.extend_from_slice(pktline::flush());
481+ assert!(check_report_status(&ok).is_ok());
482+
483+ let mut bad = Vec::new();
484+ bad.extend_from_slice(&pktline::encode(b"unpack ok\n"));
485+ bad.extend_from_slice(&pktline::encode(b"ng refs/heads/main denied\n"));
486+ bad.extend_from_slice(pktline::flush());
487+ let err = check_report_status(&bad).unwrap_err();
488+ assert!(err.contains("refs/heads/main denied"));
489+ }
490+}