anvilsign in

collin/anvil

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
13use std::path::PathBuf;
14
15/// Spawn a background `git push --mirror <url>` for `repo_path`. Returns
16/// immediately; the result is only logged.
17pub 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
30async 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.
52fn 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)]
73mod 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}