Skip to main content

thoughts_tool/git/
shell_push.rs

1use anyhow::Context;
2use anyhow::Result;
3use anyhow::bail;
4use std::io::BufRead;
5use std::io::BufReader;
6use std::path::Path;
7use std::process::Command;
8use std::process::Stdio;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PushFailureKind {
12    Race,
13    Auth,
14    Network,
15    Other,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PushResult {
20    pub success: bool,
21    pub failure_kind: Option<PushFailureKind>,
22    pub stderr: String,
23}
24
25pub fn build_push_command(repo_path: &Path, remote: &str, branch: &str) -> Command {
26    let mut cmd = Command::new("git");
27    cmd.current_dir(repo_path)
28        .arg("push")
29        .arg("--progress")
30        .arg(remote)
31        .arg(format!("HEAD:refs/heads/{branch}"));
32    cmd
33}
34
35fn print_progress_line(line: &str) {
36    if line.starts_with("To ")
37        || line.starts_with("Everything up-to-date")
38        || line.contains('%')
39        || line.starts_with("remote:")
40        || line.contains("Counting objects")
41    {
42        println!("    {line}");
43    }
44}
45
46fn sanitize_push_output(text: &str) -> String {
47    let mut sanitized = text.to_string();
48    for scheme in ["https://", "http://"] {
49        sanitized = sanitize_urls_with_scheme(&sanitized, scheme);
50    }
51    sanitized
52}
53
54fn sanitize_urls_with_scheme(text: &str, scheme: &str) -> String {
55    let mut out = String::with_capacity(text.len());
56    let mut remaining = text;
57
58    while let Some(idx) = remaining.find(scheme) {
59        out.push_str(&remaining[..idx]);
60
61        let after_scheme = &remaining[idx + scheme.len()..];
62        let end = after_scheme
63            .find(|c: char| {
64                c.is_whitespace()
65                    || matches!(
66                        c,
67                        '\'' | '"' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}'
68                    )
69            })
70            .unwrap_or(after_scheme.len());
71        let url_body = &after_scheme[..end];
72        let tail = &after_scheme[end..];
73
74        out.push_str(scheme);
75        out.push_str(&sanitize_url_body(url_body));
76        remaining = tail;
77    }
78
79    out.push_str(remaining);
80    out
81}
82
83fn sanitize_url_body(url_body: &str) -> String {
84    let (authority, path) = match url_body.find('/') {
85        Some(idx) => (&url_body[..idx], &url_body[idx..]),
86        None => (url_body, ""),
87    };
88
89    if let Some((_, host)) = authority.rsplit_once('@') {
90        format!("***@{host}{path}")
91    } else {
92        url_body.to_string()
93    }
94}
95
96fn classify_push_failure(stderr: &str) -> PushFailureKind {
97    let stderr = stderr.to_ascii_lowercase();
98
99    if stderr.contains("[rejected]")
100        || stderr.contains("non-fast-forward")
101        || stderr.contains("fetch first")
102        || stderr.contains("failed to push some refs")
103    {
104        return PushFailureKind::Race;
105    }
106
107    if stderr.contains("authentication failed")
108        || stderr.contains("permission denied")
109        || stderr.contains("could not read from remote repository")
110        || stderr.contains("repository not found")
111    {
112        return PushFailureKind::Auth;
113    }
114
115    if stderr.contains("could not resolve host")
116        || stderr.contains("temporary failure in name resolution")
117        || stderr.contains("connection timed out")
118        || stderr.contains("operation timed out")
119        || stderr.contains("network is unreachable")
120        || stderr.contains("no route to host")
121        || stderr.contains("connection refused")
122        || stderr.contains("connection reset")
123    {
124        return PushFailureKind::Network;
125    }
126
127    PushFailureKind::Other
128}
129
130pub fn push_current_branch_with_result(
131    repo_path: &Path,
132    remote: &str,
133    branch: &str,
134) -> Result<PushResult> {
135    which::which("git").context("git executable not found in PATH")?;
136
137    let mut cmd = build_push_command(repo_path, remote, branch);
138    let mut child = cmd
139        .stdout(Stdio::null())
140        .stderr(Stdio::piped())
141        .spawn()
142        .context("Failed to spawn git push")?;
143
144    let mut stderr_lines = Vec::new();
145    if let Some(stderr) = child.stderr.take() {
146        let reader = BufReader::new(stderr);
147        for line in reader.lines() {
148            match line {
149                Ok(line) => {
150                    let line = sanitize_push_output(&line);
151                    print_progress_line(&line);
152                    stderr_lines.push(line);
153                }
154                Err(_) => break,
155            }
156        }
157    }
158
159    let status = child.wait().context("Failed to wait for git push")?;
160    let stderr = stderr_lines.join("\n");
161
162    Ok(PushResult {
163        success: status.success(),
164        failure_kind: (!status.success()).then(|| classify_push_failure(&stderr)),
165        stderr,
166    })
167}
168
169pub fn push_current_branch(repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
170    let result = push_current_branch_with_result(repo_path, remote, branch)?;
171    if !result.success {
172        let kind = result.failure_kind.unwrap_or(PushFailureKind::Other);
173        let stderr = result.stderr.trim();
174        if stderr.is_empty() {
175            bail!("git push failed ({kind:?})");
176        }
177        bail!("git push failed ({kind:?}): {stderr}");
178    }
179    Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn build_push_cmd_has_expected_args() {
188        let cmd = build_push_command(Path::new("/tmp/repo"), "origin", "main");
189        let args: Vec<String> = cmd
190            .get_args()
191            .map(|s| s.to_string_lossy().into_owned())
192            .collect();
193        assert_eq!(
194            args,
195            vec!["push", "--progress", "origin", "HEAD:refs/heads/main"]
196        );
197        assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp/repo")));
198    }
199
200    #[test]
201    fn classify_rejected_push_as_race() {
202        let stderr =
203            "! [rejected]        HEAD -> main (fetch first)\nerror: failed to push some refs";
204        assert_eq!(classify_push_failure(stderr), PushFailureKind::Race);
205    }
206
207    #[test]
208    fn classify_auth_push_failure() {
209        let stderr = "remote: Permission denied\nfatal: Could not read from remote repository.";
210        assert_eq!(classify_push_failure(stderr), PushFailureKind::Auth);
211    }
212
213    #[test]
214    fn classify_network_push_failure() {
215        let stderr = "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com";
216        assert_eq!(classify_push_failure(stderr), PushFailureKind::Network);
217    }
218
219    #[test]
220    fn classify_unknown_push_failure_as_other() {
221        let stderr = "fatal: unexpected server failure";
222        assert_eq!(classify_push_failure(stderr), PushFailureKind::Other);
223    }
224
225    #[test]
226    fn sanitize_push_output_redacts_https_credentials() {
227        let stderr =
228            "fatal: unable to access 'https://user:secret@example.com/repo.git/': auth failed";
229
230        let sanitized = sanitize_push_output(stderr);
231
232        assert!(sanitized.contains("https://***@example.com/repo.git/"));
233        assert!(!sanitized.contains("user:secret"));
234    }
235
236    #[test]
237    fn sanitize_push_output_keeps_plain_https_urls() {
238        let stderr = "fatal: unable to access 'https://example.com/repo.git/': auth failed";
239
240        assert_eq!(sanitize_push_output(stderr), stderr);
241    }
242}