thoughts_tool/git/
shell_push.rs1use 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}