Skip to main content

routa_server/api/
clone_progress.rs

1//! Clone Progress API - /api/clone/progress
2//!
3//! POST /api/clone/progress - Clone a repo with SSE progress streaming
4
5use 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
23/// Parse git error output and return a user-friendly message
24fn parse_git_error(stderr: &str, exit_code: Option<i32>) -> String {
25    let stderr_lower = stderr.to_lowercase();
26
27    // Auth errors
28    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    // SSH auth errors
38    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    // Repository not found (exit code 128 often means this)
45    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    // HTTP errors
50    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    // Network errors
61    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    // SSL/TLS errors
69    if stderr_lower.contains("ssl certificate problem") {
70        return "SSL certificate error. Check your network or proxy settings.".to_string();
71    }
72
73    // Rate limiting
74    if stderr_lower.contains("rate limit") {
75        return "API rate limit exceeded. Please try again later.".to_string();
76    }
77
78    // If we have stderr content, extract the "fatal:" line
79    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    // Fallback: include stderr content if available
84    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    // Last resort: just show the exit code
92    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 already exists, return immediately
120    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 = git::git_tokio_command()
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        // Collect stderr output for error reporting
169        let mut stderr_buf = String::new();
170
171        // git clone writes progress to stderr
172        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                // Accumulate all stderr for error reporting
181                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 _ = git::git_command()
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                // Parse error message from stderr
238                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}