Skip to main content

routa_server/api/
clone.rs

1//! Clone API - /api/clone
2//!
3//! POST /api/clone - Clone a GitHub repository
4//! GET  /api/clone - List cloned repositories
5//! PATCH /api/clone - Switch branch
6
7use axum::{routing::get, Json, Router};
8use serde::Deserialize;
9
10use crate::error::ServerError;
11use crate::git;
12use crate::state::AppState;
13
14pub fn router() -> Router<AppState> {
15    Router::new().route("/", get(list_repos).post(clone_repo).patch(switch_branch))
16}
17
18/// Parse git clone error output and return a user-friendly message
19fn parse_git_clone_error(stderr: &str, exit_code: Option<i32>) -> String {
20    let stderr_lower = stderr.to_lowercase();
21
22    // Auth errors
23    if stderr_lower.contains("authentication failed")
24        || stderr_lower.contains("could not read username")
25        || stderr_lower.contains("could not read password")
26        || stderr_lower.contains("terminal prompts disabled")
27    {
28        return "Git credentials not configured. Set up a credential manager or use SSH."
29            .to_string();
30    }
31
32    // SSH auth errors
33    if stderr_lower.contains("permission denied (publickey)")
34        || stderr_lower.contains("host key verification failed")
35    {
36        return "SSH key not configured. Set up SSH keys or switch to HTTPS.".to_string();
37    }
38
39    // Repository not found
40    if stderr_lower.contains("repository") && stderr_lower.contains("not found") {
41        return "Repository not found or you don't have access.".to_string();
42    }
43
44    // HTTP errors
45    if stderr_lower.contains("the requested url returned error: 401")
46        || stderr_lower.contains("the requested url returned error: 403")
47    {
48        return "Access denied. Check your credentials or repository permissions.".to_string();
49    }
50
51    if stderr_lower.contains("the requested url returned error: 404") {
52        return "Repository not found. Check the URL and your access permissions.".to_string();
53    }
54
55    // Network errors
56    if stderr_lower.contains("could not resolve host")
57        || stderr_lower.contains("network is unreachable")
58        || stderr_lower.contains("connection refused")
59    {
60        return "Network error. Check your internet connection.".to_string();
61    }
62
63    // SSL/TLS errors
64    if stderr_lower.contains("ssl certificate problem") {
65        return "SSL certificate error. Check your network or proxy settings.".to_string();
66    }
67
68    // If we have stderr content, extract the "fatal:" line
69    if let Some(fatal_line) = stderr.lines().find(|l| l.starts_with("fatal:")) {
70        return format!(
71            "Clone failed: {}",
72            fatal_line.trim_start_matches("fatal:").trim()
73        );
74    }
75
76    // Fallback: include stderr content if available
77    if !stderr.trim().is_empty() {
78        let first_line = stderr.lines().next().unwrap_or("").trim();
79        if !first_line.is_empty() {
80            return format!("Clone failed: {first_line}");
81        }
82    }
83
84    // Last resort: just show the exit code
85    format!("Clone failed with exit code {}", exit_code.unwrap_or(-1))
86}
87
88#[derive(Debug, Deserialize)]
89struct CloneRequest {
90    url: Option<String>,
91}
92
93async fn clone_repo(
94    Json(body): Json<CloneRequest>,
95) -> Result<Json<serde_json::Value>, ServerError> {
96    let url = body
97        .url
98        .as_deref()
99        .ok_or_else(|| ServerError::BadRequest("Missing 'url' field".into()))?;
100
101    let parsed = git::parse_github_url(url).ok_or_else(|| {
102        ServerError::BadRequest(
103            "Invalid GitHub URL. Expected: https://github.com/owner/repo or owner/repo".into(),
104        )
105    })?;
106
107    let repo_name = git::repo_to_dir_name(&parsed.owner, &parsed.repo);
108    let base_dir = git::get_clone_base_dir();
109    std::fs::create_dir_all(&base_dir)
110        .map_err(|e| ServerError::Internal(format!("Failed to create base dir: {e}")))?;
111
112    let target_dir = base_dir.join(&repo_name);
113    let target_str = target_dir.to_string_lossy().to_string();
114
115    if target_dir.exists() {
116        // Already cloned — pull latest
117        tokio::task::spawn_blocking({
118            let target_str = target_str.clone();
119            move || {
120                let _ = git::git_command()
121                    .args(["pull", "--ff-only"])
122                    .current_dir(&target_str)
123                    .output();
124            }
125        })
126        .await
127        .ok();
128
129        let info = tokio::task::spawn_blocking({
130            let ts = target_str.clone();
131            move || git::get_branch_info(&ts)
132        })
133        .await
134        .map_err(|e| ServerError::Internal(e.to_string()))?;
135
136        return Ok(Json(serde_json::json!({
137            "success": true,
138            "path": target_str,
139            "name": format!("{}/{}", parsed.owner, parsed.repo),
140            "branch": info.current,
141            "branches": info.branches,
142            "existed": true,
143        })));
144    }
145
146    // Clone the repository
147    let clone_url = format!("https://github.com/{}/{}.git", parsed.owner, parsed.repo);
148    let target_dir_str = target_dir.to_string_lossy().to_string();
149
150    let output = tokio::task::spawn_blocking({
151        let clone_url = clone_url.clone();
152        let target = target_dir_str.clone();
153        move || {
154            git::git_command()
155                .args(["clone", "--depth", "1", &clone_url, &target])
156                .output()
157        }
158    })
159    .await
160    .map_err(|e| ServerError::Internal(e.to_string()))?
161    .map_err(|e| ServerError::Internal(format!("Clone failed: {e}")))?;
162
163    // Check if clone succeeded
164    if !output.status.success() {
165        let stderr = String::from_utf8_lossy(&output.stderr);
166        let error_msg = parse_git_clone_error(&stderr, output.status.code());
167        return Err(ServerError::Internal(error_msg));
168    }
169
170    // Fetch all branches
171    let _ = tokio::task::spawn_blocking({
172        let ts = target_str.clone();
173        move || {
174            let _ = git::git_command()
175                .args(["fetch", "--all"])
176                .current_dir(&ts)
177                .output();
178        }
179    })
180    .await;
181
182    let info = tokio::task::spawn_blocking({
183        let ts = target_str.clone();
184        move || git::get_branch_info(&ts)
185    })
186    .await
187    .map_err(|e| ServerError::Internal(e.to_string()))?;
188
189    Ok(Json(serde_json::json!({
190        "success": true,
191        "path": target_str,
192        "name": format!("{}/{}", parsed.owner, parsed.repo),
193        "branch": info.current,
194        "branches": info.branches,
195        "existed": false,
196    })))
197}
198
199async fn list_repos() -> Result<Json<serde_json::Value>, ServerError> {
200    let repos = tokio::task::spawn_blocking(git::list_cloned_repos)
201        .await
202        .map_err(|e| ServerError::Internal(e.to_string()))?;
203    Ok(Json(serde_json::json!({ "repos": repos })))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::parse_git_clone_error;
209
210    #[test]
211    fn parse_git_clone_error_maps_auth_and_network_failures() {
212        let auth = parse_git_clone_error("fatal: Authentication failed", Some(128));
213        assert!(auth.contains("Git credentials not configured"));
214
215        let ssh = parse_git_clone_error("Permission denied (publickey).", Some(128));
216        assert!(ssh.contains("SSH key not configured"));
217
218        let network = parse_git_clone_error("fatal: Could not resolve host: github.com", Some(128));
219        assert!(network.contains("Network error"));
220    }
221
222    #[test]
223    fn parse_git_clone_error_prefers_fatal_line_and_fallback() {
224        let fatal = parse_git_clone_error(
225            "warning: x\nfatal: repository 'https://x' not found\n",
226            Some(128),
227        );
228        assert!(fatal.contains("Repository not found"));
229
230        let generic = parse_git_clone_error("unexpected failure happened", Some(42));
231        assert_eq!(generic, "Clone failed: unexpected failure happened");
232
233        let code_only = parse_git_clone_error("", Some(7));
234        assert_eq!(code_only, "Clone failed with exit code 7");
235    }
236}
237
238#[derive(Debug, Deserialize)]
239#[serde(rename_all = "camelCase")]
240struct SwitchBranchRequest {
241    repo_path: Option<String>,
242    branch: Option<String>,
243}
244
245async fn switch_branch(
246    Json(body): Json<SwitchBranchRequest>,
247) -> Result<Json<serde_json::Value>, ServerError> {
248    let repo_path = body
249        .repo_path
250        .ok_or_else(|| ServerError::BadRequest("Missing 'repoPath'".into()))?;
251    let branch = body
252        .branch
253        .ok_or_else(|| ServerError::BadRequest("Missing 'branch'".into()))?;
254
255    if !std::path::Path::new(&repo_path).exists() {
256        return Err(ServerError::NotFound("Repository not found".into()));
257    }
258
259    let success = tokio::task::spawn_blocking({
260        let rp = repo_path.clone();
261        let br = branch.clone();
262        move || git::checkout_branch(&rp, &br)
263    })
264    .await
265    .map_err(|e| ServerError::Internal(e.to_string()))?;
266
267    if !success {
268        return Err(ServerError::Internal(format!(
269            "Failed to checkout branch '{branch}'"
270        )));
271    }
272
273    let info = tokio::task::spawn_blocking({
274        let rp = repo_path;
275        move || git::get_branch_info(&rp)
276    })
277    .await
278    .map_err(|e| ServerError::Internal(e.to_string()))?;
279
280    Ok(Json(serde_json::json!({
281        "success": true,
282        "branch": info.current,
283        "branches": info.branches,
284    })))
285}