use axum::{routing::get, Json, Router};
use serde::Deserialize;
use crate::error::ServerError;
use crate::git;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(list_repos).post(clone_repo).patch(switch_branch))
}
fn parse_git_clone_error(stderr: &str, exit_code: Option<i32>) -> String {
let stderr_lower = stderr.to_lowercase();
if stderr_lower.contains("authentication failed")
|| stderr_lower.contains("could not read username")
|| stderr_lower.contains("could not read password")
|| stderr_lower.contains("terminal prompts disabled")
{
return "Git credentials not configured. Set up a credential manager or use SSH."
.to_string();
}
if stderr_lower.contains("permission denied (publickey)")
|| stderr_lower.contains("host key verification failed")
{
return "SSH key not configured. Set up SSH keys or switch to HTTPS.".to_string();
}
if stderr_lower.contains("repository") && stderr_lower.contains("not found") {
return "Repository not found or you don't have access.".to_string();
}
if stderr_lower.contains("the requested url returned error: 401")
|| stderr_lower.contains("the requested url returned error: 403")
{
return "Access denied. Check your credentials or repository permissions.".to_string();
}
if stderr_lower.contains("the requested url returned error: 404") {
return "Repository not found. Check the URL and your access permissions.".to_string();
}
if stderr_lower.contains("could not resolve host")
|| stderr_lower.contains("network is unreachable")
|| stderr_lower.contains("connection refused")
{
return "Network error. Check your internet connection.".to_string();
}
if stderr_lower.contains("ssl certificate problem") {
return "SSL certificate error. Check your network or proxy settings.".to_string();
}
if let Some(fatal_line) = stderr.lines().find(|l| l.starts_with("fatal:")) {
return format!(
"Clone failed: {}",
fatal_line.trim_start_matches("fatal:").trim()
);
}
if !stderr.trim().is_empty() {
let first_line = stderr.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return format!("Clone failed: {}", first_line);
}
}
format!("Clone failed with exit code {}", exit_code.unwrap_or(-1))
}
#[derive(Debug, Deserialize)]
struct CloneRequest {
url: Option<String>,
}
async fn clone_repo(
Json(body): Json<CloneRequest>,
) -> Result<Json<serde_json::Value>, ServerError> {
let url = body
.url
.as_deref()
.ok_or_else(|| ServerError::BadRequest("Missing 'url' field".into()))?;
let parsed = git::parse_github_url(url).ok_or_else(|| {
ServerError::BadRequest(
"Invalid GitHub URL. Expected: https://github.com/owner/repo or owner/repo".into(),
)
})?;
let repo_name = git::repo_to_dir_name(&parsed.owner, &parsed.repo);
let base_dir = git::get_clone_base_dir();
std::fs::create_dir_all(&base_dir)
.map_err(|e| ServerError::Internal(format!("Failed to create base dir: {}", e)))?;
let target_dir = base_dir.join(&repo_name);
let target_str = target_dir.to_string_lossy().to_string();
if target_dir.exists() {
tokio::task::spawn_blocking({
let target_str = target_str.clone();
move || {
let _ = std::process::Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(&target_str)
.output();
}
})
.await
.ok();
let info = tokio::task::spawn_blocking({
let ts = target_str.clone();
move || git::get_branch_info(&ts)
})
.await
.map_err(|e| ServerError::Internal(e.to_string()))?;
return Ok(Json(serde_json::json!({
"success": true,
"path": target_str,
"name": format!("{}/{}", parsed.owner, parsed.repo),
"branch": info.current,
"branches": info.branches,
"existed": true,
})));
}
let clone_url = format!("https://github.com/{}/{}.git", parsed.owner, parsed.repo);
let target_dir_str = target_dir.to_string_lossy().to_string();
let output = tokio::task::spawn_blocking({
let clone_url = clone_url.clone();
let target = target_dir_str.clone();
move || {
std::process::Command::new("git")
.args(["clone", "--depth", "1", &clone_url, &target])
.output()
}
})
.await
.map_err(|e| ServerError::Internal(e.to_string()))?
.map_err(|e| ServerError::Internal(format!("Clone failed: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let error_msg = parse_git_clone_error(&stderr, output.status.code());
return Err(ServerError::Internal(error_msg));
}
let _ = tokio::task::spawn_blocking({
let ts = target_str.clone();
move || {
let _ = std::process::Command::new("git")
.args(["fetch", "--all"])
.current_dir(&ts)
.output();
}
})
.await;
let info = tokio::task::spawn_blocking({
let ts = target_str.clone();
move || git::get_branch_info(&ts)
})
.await
.map_err(|e| ServerError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"success": true,
"path": target_str,
"name": format!("{}/{}", parsed.owner, parsed.repo),
"branch": info.current,
"branches": info.branches,
"existed": false,
})))
}
async fn list_repos() -> Result<Json<serde_json::Value>, ServerError> {
let repos = tokio::task::spawn_blocking(git::list_cloned_repos)
.await
.map_err(|e| ServerError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "repos": repos })))
}
#[cfg(test)]
mod tests {
use super::parse_git_clone_error;
#[test]
fn parse_git_clone_error_maps_auth_and_network_failures() {
let auth = parse_git_clone_error("fatal: Authentication failed", Some(128));
assert!(auth.contains("Git credentials not configured"));
let ssh = parse_git_clone_error("Permission denied (publickey).", Some(128));
assert!(ssh.contains("SSH key not configured"));
let network = parse_git_clone_error("fatal: Could not resolve host: github.com", Some(128));
assert!(network.contains("Network error"));
}
#[test]
fn parse_git_clone_error_prefers_fatal_line_and_fallback() {
let fatal = parse_git_clone_error(
"warning: x\nfatal: repository 'https://x' not found\n",
Some(128),
);
assert!(fatal.contains("Repository not found"));
let generic = parse_git_clone_error("unexpected failure happened", Some(42));
assert_eq!(generic, "Clone failed: unexpected failure happened");
let code_only = parse_git_clone_error("", Some(7));
assert_eq!(code_only, "Clone failed with exit code 7");
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SwitchBranchRequest {
repo_path: Option<String>,
branch: Option<String>,
}
async fn switch_branch(
Json(body): Json<SwitchBranchRequest>,
) -> Result<Json<serde_json::Value>, ServerError> {
let repo_path = body
.repo_path
.ok_or_else(|| ServerError::BadRequest("Missing 'repoPath'".into()))?;
let branch = body
.branch
.ok_or_else(|| ServerError::BadRequest("Missing 'branch'".into()))?;
if !std::path::Path::new(&repo_path).exists() {
return Err(ServerError::NotFound("Repository not found".into()));
}
let success = tokio::task::spawn_blocking({
let rp = repo_path.clone();
let br = branch.clone();
move || git::checkout_branch(&rp, &br)
})
.await
.map_err(|e| ServerError::Internal(e.to_string()))?;
if !success {
return Err(ServerError::Internal(format!(
"Failed to checkout branch '{}'",
branch
)));
}
let info = tokio::task::spawn_blocking({
let rp = repo_path;
move || git::get_branch_info(&rp)
})
.await
.map_err(|e| ServerError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"success": true,
"branch": info.current,
"branches": info.branches,
})))
}