| 1 | //! Push mirroring: after a successful push to an anvil repo, mirror all refs |
| 2 | //! to a configured remote (e.g. a GitHub repo). |
| 3 | //! |
| 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. |
| 8 | //! |
| 9 | //! For GitHub over HTTPS, use a token URL: |
| 10 | //! `https://x-access-token:<token>@github.com/you/repo.git`. The URL is |
| 11 | //! stored as-is in the database — treat it like a secret. |
| 12 | |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | /// Spawn a background mirror push of `repo_path` to `url`. Returns |
| 16 | /// immediately; the result is only logged. |
| 17 | pub fn spawn_push(repo_path: PathBuf, url: String) { |
| 18 | tokio::spawn(async move { |
| 19 | let shown = redact(&url); |
| 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 | ), |
| 33 | Err(e) => tracing::error!( |
| 34 | "mirror: push of {} to {shown} failed: {}", |
| 35 | repo_path.display(), |
| 36 | redact(&e) |
| 37 | ), |
| 38 | } |
| 39 | }); |
| 40 | } |
| 41 | |
| 42 | /// Strip the userinfo (`user:token@`) out of anything URL-shaped so secrets |
| 43 | /// never reach the log. |
| 44 | fn redact(text: &str) -> String { |
| 45 | let mut out = String::with_capacity(text.len()); |
| 46 | for (i, part) in text.split("://").enumerate() { |
| 47 | if i == 0 { |
| 48 | out.push_str(part); |
| 49 | continue; |
| 50 | } |
| 51 | out.push_str("://"); |
| 52 | match part.split_once('@') { |
| 53 | // Heuristic: an '@' before the next '/' is userinfo. |
| 54 | Some((userinfo, rest)) if !userinfo.contains('/') => { |
| 55 | out.push_str("***@"); |
| 56 | out.push_str(rest); |
| 57 | } |
| 58 | _ => out.push_str(part), |
| 59 | } |
| 60 | } |
| 61 | out |
| 62 | } |
| 63 | |
| 64 | #[cfg(test)] |
| 65 | mod tests { |
| 66 | use super::*; |
| 67 | |
| 68 | #[test] |
| 69 | fn redacts_userinfo_only() { |
| 70 | assert_eq!( |
| 71 | redact("https://x-access-token:ghp_abc@github.com/a/b.git"), |
| 72 | "https://***@github.com/a/b.git" |
| 73 | ); |
| 74 | assert_eq!( |
| 75 | redact("error: https://github.com/a/b.git denied"), |
| 76 | "error: https://github.com/a/b.git denied" |
| 77 | ); |
| 78 | assert_eq!(redact("no urls here"), "no urls here"); |
| 79 | } |
| 80 | } |