routa_server/api/
clone.rs1use 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
18fn parse_git_clone_error(stderr: &str, exit_code: Option<i32>) -> String {
20 let stderr_lower = stderr.to_lowercase();
21
22 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 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 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 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 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 if stderr_lower.contains("ssl certificate problem") {
65 return "SSL certificate error. Check your network or proxy settings.".to_string();
66 }
67
68 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 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 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 tokio::task::spawn_blocking({
118 let target_str = target_str.clone();
119 move || {
120 let _ = std::process::Command::new("git")
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 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 std::process::Command::new("git")
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 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 let _ = tokio::task::spawn_blocking({
172 let ts = target_str.clone();
173 move || {
174 let _ = std::process::Command::new("git")
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 '{}'",
270 branch
271 )));
272 }
273
274 let info = tokio::task::spawn_blocking({
275 let rp = repo_path;
276 move || git::get_branch_info(&rp)
277 })
278 .await
279 .map_err(|e| ServerError::Internal(e.to_string()))?;
280
281 Ok(Json(serde_json::json!({
282 "success": true,
283 "branch": info.current,
284 "branches": info.branches,
285 })))
286}