| 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`. |
| 3 | //! |
| 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. |
| 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 `git push --mirror <url>` for `repo_path`. 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 push(&repo_path, &url).await { |
| 21 | Ok(()) => tracing::info!("mirror: pushed {} to {shown}", repo_path.display()), |
| 22 | Err(e) => tracing::error!( |
| 23 | "mirror: push of {} to {shown} failed: {e}", |
| 24 | repo_path.display() |
| 25 | ), |
| 26 | } |
| 27 | }); |
| 28 | } |
| 29 | |
| 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 | |
| 50 | /// Strip the userinfo (`user:token@`) out of anything URL-shaped so secrets |
| 51 | /// never reach the log. |
| 52 | fn redact(text: &str) -> String { |
| 53 | let mut out = String::with_capacity(text.len()); |
| 54 | for (i, part) in text.split("://").enumerate() { |
| 55 | if i == 0 { |
| 56 | out.push_str(part); |
| 57 | continue; |
| 58 | } |
| 59 | out.push_str("://"); |
| 60 | match part.split_once('@') { |
| 61 | // Heuristic: an '@' before the next '/' is userinfo. |
| 62 | Some((userinfo, rest)) if !userinfo.contains('/') => { |
| 63 | out.push_str("***@"); |
| 64 | out.push_str(rest); |
| 65 | } |
| 66 | _ => out.push_str(part), |
| 67 | } |
| 68 | } |
| 69 | out |
| 70 | } |
| 71 | |
| 72 | #[cfg(test)] |
| 73 | mod tests { |
| 74 | use super::*; |
| 75 | |
| 76 | #[test] |
| 77 | fn redacts_userinfo_only() { |
| 78 | assert_eq!( |
| 79 | redact("https://x-access-token:ghp_abc@github.com/a/b.git"), |
| 80 | "https://***@github.com/a/b.git" |
| 81 | ); |
| 82 | assert_eq!( |
| 83 | redact("error: https://github.com/a/b.git denied"), |
| 84 | "error: https://github.com/a/b.git denied" |
| 85 | ); |
| 86 | 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")); |
| 151 | } |
| 152 | } |