routa_server/api/
clone_progress.rs1use axum::{
6 response::sse::{Event, Sse},
7 routing::post,
8 Json, Router,
9};
10use serde::Deserialize;
11use std::convert::Infallible;
12use std::pin::Pin;
13
14use crate::git;
15use crate::state::AppState;
16
17type SseStream = Pin<Box<dyn tokio_stream::Stream<Item = Result<Event, Infallible>> + Send>>;
18
19pub fn router() -> Router<AppState> {
20 Router::new().route("/", post(clone_with_progress))
21}
22
23fn parse_git_error(stderr: &str, exit_code: Option<i32>) -> String {
25 let stderr_lower = stderr.to_lowercase();
26
27 if stderr_lower.contains("authentication failed")
29 || stderr_lower.contains("could not read username")
30 || stderr_lower.contains("could not read password")
31 || stderr_lower.contains("terminal prompts disabled")
32 {
33 return "Git credentials not configured. Set up a credential manager or use SSH."
34 .to_string();
35 }
36
37 if stderr_lower.contains("permission denied (publickey)")
39 || stderr_lower.contains("host key verification failed")
40 {
41 return "SSH key not configured. Set up SSH keys or switch to HTTPS.".to_string();
42 }
43
44 if stderr_lower.contains("repository") && stderr_lower.contains("not found") {
46 return "Repository not found or you don't have access.".to_string();
47 }
48
49 if stderr_lower.contains("the requested url returned error: 401")
51 || stderr_lower.contains("the requested url returned error: 403")
52 {
53 return "Access denied. Check your credentials or repository permissions.".to_string();
54 }
55
56 if stderr_lower.contains("the requested url returned error: 404") {
57 return "Repository not found. Check the URL and your access permissions.".to_string();
58 }
59
60 if stderr_lower.contains("could not resolve host")
62 || stderr_lower.contains("network is unreachable")
63 || stderr_lower.contains("connection refused")
64 {
65 return "Network error. Check your internet connection.".to_string();
66 }
67
68 if stderr_lower.contains("ssl certificate problem") {
70 return "SSL certificate error. Check your network or proxy settings.".to_string();
71 }
72
73 if stderr_lower.contains("rate limit") {
75 return "API rate limit exceeded. Please try again later.".to_string();
76 }
77
78 if let Some(fatal_line) = stderr.lines().find(|l| l.starts_with("fatal:")) {
80 return fatal_line.trim_start_matches("fatal:").trim().to_string();
81 }
82
83 if !stderr.trim().is_empty() {
85 let first_line = stderr.lines().next().unwrap_or("").trim();
86 if !first_line.is_empty() {
87 return format!("Clone failed: {}", first_line);
88 }
89 }
90
91 format!("Clone failed with exit code {}", exit_code.unwrap_or(-1))
93}
94
95#[derive(Debug, Deserialize)]
96struct CloneProgressRequest {
97 url: Option<String>,
98}
99
100async fn clone_with_progress(
101 Json(body): Json<CloneProgressRequest>,
102) -> Result<Sse<SseStream>, axum::http::StatusCode> {
103 let url = match body.url.as_deref() {
104 Some(u) if !u.is_empty() => u.to_string(),
105 _ => return Err(axum::http::StatusCode::BAD_REQUEST),
106 };
107
108 let parsed = match git::parse_github_url(&url) {
109 Some(p) => p,
110 None => return Err(axum::http::StatusCode::BAD_REQUEST),
111 };
112
113 let repo_name = git::repo_to_dir_name(&parsed.owner, &parsed.repo);
114 let base_dir = git::get_clone_base_dir();
115 let _ = std::fs::create_dir_all(&base_dir);
116 let target_dir = base_dir.join(&repo_name);
117 let target_str = target_dir.to_string_lossy().to_string();
118
119 if target_dir.exists() {
121 let info = git::get_branch_info(&target_str);
122 let data = serde_json::json!({
123 "phase": "done",
124 "success": true,
125 "path": target_str,
126 "name": format!("{}/{}", parsed.owner, parsed.repo),
127 "branch": info.current,
128 "branches": info.branches,
129 "existed": true,
130 });
131 let stream: SseStream = Box::pin(tokio_stream::once(Ok::<_, Infallible>(
132 Event::default().data(data.to_string()),
133 )));
134 return Ok(Sse::new(stream));
135 }
136
137 let clone_url = format!("https://github.com/{}/{}.git", parsed.owner, parsed.repo);
138
139 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
140
141 tokio::spawn(async move {
142 let _ = tx
143 .send(Ok(Event::default().data(
144 serde_json::json!({"phase":"starting","percent":0,"message":"Starting clone..."})
145 .to_string(),
146 )))
147 .await;
148
149 let child = tokio::process::Command::new("git")
150 .args(["clone", "--progress", &clone_url, &target_str])
151 .stdin(std::process::Stdio::null())
152 .stdout(std::process::Stdio::piped())
153 .stderr(std::process::Stdio::piped())
154 .spawn();
155
156 let mut child = match child {
157 Ok(c) => c,
158 Err(e) => {
159 let _ = tx
160 .send(Ok(Event::default().data(
161 serde_json::json!({"phase":"error","error": e.to_string()}).to_string(),
162 )))
163 .await;
164 return;
165 }
166 };
167
168 let mut stderr_buf = String::new();
170
171 if let Some(stderr) = child.stderr.take() {
173 let reader = tokio::io::BufReader::new(stderr);
174 let mut lines = tokio::io::AsyncBufReadExt::lines(reader);
175
176 let phase_re = regex::Regex::new(
177 r"(Counting objects|Compressing objects|Receiving objects|Resolving deltas):\s+(\d+)%",
178 );
179 while let Ok(Some(text)) = lines.next_line().await {
180 stderr_buf.push_str(&text);
182 stderr_buf.push('\n');
183
184 if let Ok(ref re) = phase_re {
185 if let Some(caps) = re.captures(&text) {
186 let phase_name = match caps.get(1).map(|m| m.as_str()) {
187 Some("Counting objects") => "counting",
188 Some("Compressing objects") => "compressing",
189 Some("Receiving objects") => "receiving",
190 Some("Resolving deltas") => "resolving",
191 _ => "progress",
192 };
193 let percent: i32 = caps
194 .get(2)
195 .and_then(|m| m.as_str().parse().ok())
196 .unwrap_or(0);
197 let _ = tx
198 .send(Ok(Event::default().data(
199 serde_json::json!({
200 "phase": phase_name,
201 "percent": percent,
202 "message": text.trim(),
203 })
204 .to_string(),
205 )))
206 .await;
207 }
208 }
209 }
210 }
211
212 let status = child.wait().await;
213 match status {
214 Ok(s) if s.success() => {
215 let _ = std::process::Command::new("git")
216 .args(["fetch", "--all"])
217 .current_dir(&target_str)
218 .output();
219
220 let info = git::get_branch_info(&target_str);
221 let _ = tx
222 .send(Ok(Event::default().data(
223 serde_json::json!({
224 "phase": "done",
225 "success": true,
226 "path": target_str,
227 "name": format!("{}/{}", parsed.owner, parsed.repo),
228 "branch": info.current,
229 "branches": info.branches,
230 "existed": false,
231 })
232 .to_string(),
233 )))
234 .await;
235 }
236 Ok(s) => {
237 let error_msg = parse_git_error(&stderr_buf, s.code());
239 let _ = tx
240 .send(Ok(Event::default().data(
241 serde_json::json!({
242 "phase": "error",
243 "error": error_msg,
244 })
245 .to_string(),
246 )))
247 .await;
248 }
249 Err(e) => {
250 let _ = tx
251 .send(Ok(Event::default().data(
252 serde_json::json!({"phase":"error","error": e.to_string()}).to_string(),
253 )))
254 .await;
255 }
256 }
257 });
258
259 let stream: SseStream = Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx));
260 Ok(Sse::new(stream))
261}