use reqwest::blocking::Client; use reqwest::header::{ACCEPT, AUTHORIZATION}; use serde::Serialize; use std::collections::HashSet; use std::env; use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; #[derive(Serialize)] struct GitCommandResult { command: String, stdout: String, stderr: String, success: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ServerConnectionResult { ok: bool, message: String, api_base_url: String, version: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LocalRepoBranch { name: String, current: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LocalRepoEntry { name: String, path: String, entry_type: String, size: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LocalRepoFile { path: String, size: u64, content: Option, is_binary: bool, too_large: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LocalRepoCandidate { name: String, path: String, matched_remote_url: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitChangedFile { path: String, original_path: Option, x: String, y: String, status: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitStatusSummary { branch: Option, upstream: Option, ahead: u32, behind: u32, files: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitRepositorySyncStatus { branch: Option, upstream: Option, upstream_remote: Option, default_remote: Option, ahead: u32, behind: u32, has_remote: bool, is_detached: bool, is_unpublished: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitFileDiff { path: String, diff: String, is_binary: bool, is_deleted: bool, is_untracked: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitCommitSummary { hash: String, short_hash: String, title: String, author: String, date: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GitCommitDetail { hash: String, short_hash: String, title: String, message: String, author: String, date: String, files: Vec, diff: String, } fn normalize_api_base_url(server_url: &str) -> Result { // Normalize user input so every backend consistently resolves to /api/v1. let trimmed = server_url.trim().trim_end_matches('/'); if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { return Err("Server URL must start with http:// or https://".to_string()); } if trimmed.ends_with("/api/v1") { Ok(trimmed.to_string()) } else { Ok(format!("{trimmed}/api/v1")) } } fn resolve_git_binary(git_path: Option) -> String { git_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("git") .to_string() } fn run_git_command( repo_path: Option<&str>, git_path: Option, args: Vec, ) -> Result { // Central command executor keeps Git command behavior consistent. let git_binary = resolve_git_binary(git_path); let mut command = Command::new(&git_binary); if let Some(path) = repo_path { if !Path::new(path).exists() { return Err(format!("Repository path does not exist: {path}")); } command.current_dir(path); } command.args(&args); let output = command .output() .map_err(|err| format!("Failed to run git command: {err}"))?; Ok(GitCommandResult { command: format!("{git_binary} {}", args.join(" ")), stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), success: output.status.success(), }) } fn run_git_output( repo_path: &str, git_path: Option, args: Vec, ) -> Result, String> { let git_binary = resolve_git_binary(git_path); let path = repo_path.trim(); if !Path::new(path).exists() { return Err(format!("Repository path does not exist: {path}")); } let output = Command::new(&git_binary) .current_dir(path) .args(&args) .output() .map_err(|err| format!("Failed to run git command: {err}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { format!("{git_binary} {} failed", args.join(" ")) } else { stderr }); } Ok(output.stdout) } fn ensure_git_success(result: GitCommandResult) -> Result { if result.success { Ok(result) } else if !result.stderr.is_empty() { Err(result.stderr) } else { Err(format!("{} failed", result.command)) } } fn validate_repo_dir(repo_path: &str) -> Result { let path = repo_path.trim(); if path.is_empty() { return Err("Repository path is required.".to_string()); } let path_buf = PathBuf::from(path); if !path_buf.exists() || !path_buf.is_dir() { return Err(format!("Repository path does not exist: {path}")); } if !path_buf.join(".git").exists() { return Err(format!("Selected path is not a Git repository: {path}")); } Ok(path.to_string()) } fn validate_git_path(path: &str) -> Result { let normalized = normalize_repo_path(path)?; if normalized.is_empty() { return Err("File path is required.".to_string()); } Ok(normalized) } fn status_kind(x: &str, y: &str) -> String { match (x, y) { ("?", "?") => "untracked", ("U", _) | (_, "U") | ("A", "A") | ("D", "D") => "conflicted", ("R", _) | (_, "R") => "renamed", ("A", _) | (_, "A") => "added", ("D", _) | (_, "D") => "deleted", _ => "modified", } .to_string() } fn parse_porcelain_file(line: &str) -> Option { if line.len() < 4 { return None; } let x = line.get(0..1)?.to_string(); let y = line.get(1..2)?.to_string(); let path_part = line.get(3..)?.trim(); if path_part.is_empty() { return None; } let (path, original_path) = if matches!(x.as_str(), "R" | "C") || matches!(y.as_str(), "R" | "C") { if let Some((old_path, new_path)) = path_part.split_once(" -> ") { ( new_path.trim().to_string(), Some(old_path.trim().to_string()), ) } else { (path_part.to_string(), None) } } else { (path_part.to_string(), None) }; Some(GitChangedFile { status: status_kind(&x, &y), path, original_path, x, y, }) } fn parse_branch_status(line: &str) -> (Option, Option, u32, u32) { let value = line.trim_start_matches("## ").trim(); if value == "No commits yet on" { return (None, None, 0, 0); } let (head, meta) = value .split_once(" [") .map(|(left, right)| (left, Some(right.trim_end_matches(']')))) .unwrap_or((value, None)); let (branch, upstream) = head .split_once("...") .map(|(left, right)| (left.to_string(), Some(right.to_string()))) .unwrap_or((head.to_string(), None)); let mut ahead = 0; let mut behind = 0; if let Some(meta) = meta { for part in meta.split(',').map(str::trim) { if let Some(value) = part.strip_prefix("ahead ") { ahead = value.parse().unwrap_or(0); } else if let Some(value) = part.strip_prefix("behind ") { behind = value.parse().unwrap_or(0); } } } (Some(branch), upstream, ahead, behind) } fn parse_porcelain_status(stdout: &str) -> GitStatusSummary { let mut branch = None; let mut upstream = None; let mut ahead = 0; let mut behind = 0; let mut files = Vec::new(); for line in stdout.lines() { if line.starts_with("## ") { (branch, upstream, ahead, behind) = parse_branch_status(line); } else if let Some(file) = parse_porcelain_file(line) { files.push(file); } } GitStatusSummary { branch, upstream, ahead, behind, files, } } fn git_output_text( repo_path: &str, git_path: Option, args: Vec<&str>, ) -> Result { let result = ensure_git_success(run_git_command( Some(repo_path), git_path, args.into_iter().map(str::to_string).collect(), )?)?; Ok(result.stdout.trim().to_string()) } fn git_output_text_optional( repo_path: &str, git_path: Option, args: Vec<&str>, ) -> Option { git_output_text(repo_path, git_path, args) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn parse_remote_names(stdout: &str) -> Vec { stdout .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect() } fn split_upstream_remote(upstream: &str) -> Option { upstream .split_once('/') .map(|(remote, _)| remote.to_string()) .filter(|remote| !remote.is_empty()) } fn ahead_behind_counts(repo_path: &str, git_path: Option) -> (u32, u32) { let Some(stdout) = git_output_text_optional( repo_path, git_path, vec!["rev-list", "--left-right", "--count", "HEAD...@{u}"], ) else { return (0, 0); }; let mut parts = stdout.split_whitespace(); let ahead = parts .next() .and_then(|value| value.parse().ok()) .unwrap_or(0); let behind = parts .next() .and_then(|value| value.parse().ok()) .unwrap_or(0); (ahead, behind) } fn command_with_paths(base_args: &[&str], paths: Vec) -> Result, String> { if paths.is_empty() { return Err("At least one file must be selected.".to_string()); } let mut args = base_args .iter() .map(|value| value.to_string()) .collect::>(); args.push("--".to_string()); for path in paths { args.push(validate_git_path(&path)?); } Ok(args) } fn is_binary_file(path: &Path) -> bool { fs::read(path) .map(|content| content.contains(&0)) .unwrap_or(false) } fn normalize_repo_path(path: &str) -> Result { let trimmed = path.trim().trim_matches('/'); if trimmed.contains('\\') || trimmed.contains('\0') { return Err("Repository paths must use forward slashes.".to_string()); } let mut parts = Vec::new(); for part in trimmed.split('/') { if part.is_empty() || part == "." { continue; } if part == ".." { return Err("Repository paths cannot contain parent directory segments.".to_string()); } parts.push(part); } Ok(parts.join("/")) } fn normalize_reference(reference: &str) -> Result { let trimmed = reference.trim(); if trimmed.is_empty() || trimmed.contains('\0') { return Err("Branch or reference is required.".to_string()); } Ok(trimmed.to_string()) } fn treeish(reference: &str, path: &str) -> String { if path.is_empty() { reference.to_string() } else { format!("{reference}:{path}") } } fn dedupe_key(path: &Path) -> String { let path = path .canonicalize() .unwrap_or_else(|_| path.to_path_buf()) .to_string_lossy() .replace('\\', "/") .trim_end_matches('/') .to_string(); #[cfg(windows)] { path.to_lowercase() } #[cfg(not(windows))] { path } } fn add_if_exists(paths: &mut Vec, path: PathBuf) { if !path.exists() { return; } let key = dedupe_key(&path); if !paths.iter().any(|existing| dedupe_key(existing) == key) { paths.push(path); } } fn default_repo_scan_roots() -> Vec { let mut roots = Vec::new(); if let Some(home) = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) { let home = PathBuf::from(home); add_if_exists(&mut roots, home.join("Repos")); add_if_exists(&mut roots, home.join("repos")); add_if_exists(&mut roots, home.join("Code")); add_if_exists(&mut roots, home.join("code")); add_if_exists(&mut roots, home.join("Source")); add_if_exists(&mut roots, home.join("source")); add_if_exists(&mut roots, home.join("Projects")); add_if_exists(&mut roots, home.join("GitHub")); add_if_exists(&mut roots, home.join("Documents").join("GitHub")); add_if_exists(&mut roots, home.join("Documents").join("Repos")); add_if_exists(&mut roots, home.join("Desktop")); } #[cfg(windows)] { for drive in b'A'..=b'Z' { let root = format!("{}:\\", drive as char); let root_path = PathBuf::from(&root); if !root_path.exists() { continue; } for folder in [ "Repos", "repos", "Code", "code", "Source", "source", "Projects", "GitHub", "Dev", ] { add_if_exists(&mut roots, root_path.join(folder)); } } } roots } fn is_skippable_dir(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|value| value.to_str()) else { return false; }; matches!( name, ".cache" | ".cargo" | ".gradle" | ".npm" | ".rustup" | ".svn" | ".tauri" | "AppData" | "Library" | "node_modules" | "target" | "vendor" ) } fn is_git_repo(path: &Path) -> bool { path.join(".git").exists() } fn normalize_git_remote_url(value: &str) -> String { let mut remote = value.trim().trim_end_matches('/').to_lowercase(); if let Some(stripped) = remote.strip_suffix(".git") { remote = stripped.to_string(); } remote } fn local_remote_urls(repo_path: &Path, git_path: Option) -> Vec { let git_binary = resolve_git_binary(git_path); let Ok(output) = Command::new(&git_binary) .current_dir(repo_path) .args(["config", "--get-regexp", r"^remote\..*\.url$"]) .output() else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| line.split_once(' ').map(|(_, url)| url.trim().to_string())) .filter(|url| !url.is_empty()) .collect() } fn matched_server_remote( repo_path: &Path, git_path: Option, allowed_remote_urls: &HashSet, ) -> Option { local_remote_urls(repo_path, git_path) .into_iter() .find(|remote| allowed_remote_urls.contains(&normalize_git_remote_url(remote))) } fn collect_repo_candidates( root: &Path, depth: u8, max_depth: u8, results: &mut Vec, seen: &mut HashSet, allowed_remote_urls: &HashSet, git_path: Option, max_results: usize, ) { if results.len() >= max_results || is_skippable_dir(root) { return; } if is_git_repo(root) { if seen.insert(dedupe_key(root)) { let display_path = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let path = display_path.to_string_lossy().to_string(); let Some(matched_remote_url) = matched_server_remote(root, git_path.clone(), allowed_remote_urls) else { return; }; let name = root .file_name() .and_then(|value| value.to_str()) .unwrap_or(&path) .to_string(); results.push(LocalRepoCandidate { name, path, matched_remote_url, }); } return; } if depth >= max_depth { return; } let Ok(entries) = fs::read_dir(root) else { return; }; for entry in entries.flatten() { if results.len() >= max_results { return; } let path = entry.path(); if path.is_dir() { collect_repo_candidates( &path, depth + 1, max_depth, results, seen, allowed_remote_urls, git_path.clone(), max_results, ); } } } #[tauri::command] fn git_clone( repo_url: String, destination_path: String, git_path: Option, ) -> Result { if repo_url.trim().is_empty() { return Err("Repository URL is required".to_string()); } if destination_path.trim().is_empty() { return Err("Destination path is required".to_string()); } run_git_command( None, git_path, vec![ "clone".to_string(), repo_url.trim().to_string(), destination_path.trim().to_string(), ], ) } #[tauri::command] fn git_pull(repo_path: String, git_path: Option) -> Result { let repo_path = validate_repo_dir(&repo_path)?; ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["pull".to_string()], )?) } #[tauri::command] fn git_push(repo_path: String, git_path: Option) -> Result { let repo_path = validate_repo_dir(&repo_path)?; ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["push".to_string()], )?) } #[tauri::command] fn git_publish_branch( repo_path: String, remote: Option, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let status = repository_sync_status(repo_path.clone(), git_path.clone())?; if status.is_detached { return Err("Cannot publish a detached HEAD.".to_string()); } let branch = status .branch .ok_or_else(|| "Cannot determine the current branch.".to_string())?; let remote = remote .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or(status.default_remote.as_deref()) .ok_or_else(|| "No Git remote is configured for this repository.".to_string())? .to_string(); ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["push".to_string(), "-u".to_string(), remote, branch], )?) } #[tauri::command] fn git_status(repo_path: String, git_path: Option) -> Result { run_git_command( Some(repo_path.trim()), git_path, vec![ "status".to_string(), "--short".to_string(), "--branch".to_string(), ], ) } #[tauri::command] fn git_branch(repo_path: String, git_path: Option) -> Result { run_git_command( Some(repo_path.trim()), git_path, vec!["branch".to_string(), "--all".to_string()], ) } #[tauri::command] fn git_fetch(repo_path: String, git_path: Option) -> Result { let repo_path = validate_repo_dir(&repo_path)?; ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["fetch".to_string(), "--prune".to_string()], )?) } #[tauri::command] fn git_sync(repo_path: String, git_path: Option) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let fetch = ensure_git_success(run_git_command( Some(&repo_path), git_path.clone(), vec!["fetch".to_string(), "--prune".to_string()], )?)?; let pull = ensure_git_success(run_git_command( Some(&repo_path), git_path.clone(), vec!["pull".to_string()], )?)?; let push = ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["push".to_string()], )?)?; Ok(GitCommandResult { command: "git fetch --prune && git pull && git push".to_string(), stdout: [fetch.stdout, pull.stdout, push.stdout] .into_iter() .filter(|value| !value.is_empty()) .collect::>() .join("\n\n"), stderr: [fetch.stderr, pull.stderr, push.stderr] .into_iter() .filter(|value| !value.is_empty()) .collect::>() .join("\n\n"), success: true, }) } #[tauri::command] fn repository_sync_status( repo_path: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let branch_output = git_output_text( &repo_path, git_path.clone(), vec!["rev-parse", "--abbrev-ref", "HEAD"], )?; let is_detached = branch_output == "HEAD"; let branch = if is_detached { None } else { Some(branch_output) }; let upstream = git_output_text_optional( &repo_path, git_path.clone(), vec!["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], ); let upstream_remote = upstream.as_deref().and_then(split_upstream_remote); let (ahead, behind) = if upstream.is_some() { ahead_behind_counts(&repo_path, git_path.clone()) } else { (0, 0) }; let remotes = git_output_text_optional(&repo_path, git_path, vec!["remote"]) .map(|stdout| parse_remote_names(&stdout)) .unwrap_or_default(); let default_remote = remotes .iter() .find(|remote| remote.as_str() == "origin") .or_else(|| remotes.first()) .cloned(); let has_remote = default_remote.is_some(); let is_unpublished = branch.is_some() && upstream.is_none() && has_remote && !is_detached; Ok(GitRepositorySyncStatus { branch, upstream, upstream_remote, default_remote, ahead, behind, has_remote, is_detached, is_unpublished, }) } #[tauri::command] fn working_tree_status( repo_path: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let result = ensure_git_success(run_git_command( Some(&repo_path), git_path, vec![ "status".to_string(), "--porcelain=v1".to_string(), "--branch".to_string(), "--renames".to_string(), ], )?)?; Ok(parse_porcelain_status(&result.stdout)) } #[tauri::command] fn get_file_diff( repo_path: String, path: String, status: Option, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let file_path = validate_git_path(&path)?; let status = status.unwrap_or_default(); let is_untracked = status == "untracked"; let is_deleted = status == "deleted"; if is_untracked { let absolute_path = PathBuf::from(&repo_path).join(&file_path); if is_binary_file(&absolute_path) { return Ok(GitFileDiff { path: file_path, diff: "Binary file. Diff preview is not available for untracked binary files." .to_string(), is_binary: true, is_deleted: false, is_untracked: true, }); } let content = fs::read_to_string(&absolute_path) .map_err(|err| format!("Unable to read untracked file: {err}"))?; let diff = format!( "diff --git a/{0} b/{0}\nnew file mode 100644\n--- /dev/null\n+++ b/{0}\n{1}", file_path, content .lines() .map(|line| format!("+{line}")) .collect::>() .join("\n") ); return Ok(GitFileDiff { path: file_path, diff, is_binary: false, is_deleted: false, is_untracked: true, }); } let output = run_git_command( Some(&repo_path), git_path, vec![ "diff".to_string(), "--no-ext-diff".to_string(), "--".to_string(), file_path.clone(), ], )?; let mut diff = output.stdout; if diff.is_empty() { diff = output.stderr; } let is_binary = diff.contains("Binary files") || diff.contains("GIT binary patch"); Ok(GitFileDiff { path: file_path, diff: if diff.is_empty() { "No unstaged diff is available for this file.".to_string() } else { diff }, is_binary, is_deleted, is_untracked: false, }) } #[tauri::command] fn stage_files( repo_path: String, paths: Vec, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let args = command_with_paths(&["add"], paths)?; ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?) } #[tauri::command] fn unstage_files( repo_path: String, paths: Vec, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let args = command_with_paths(&["restore", "--staged"], paths)?; ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?) } #[tauri::command] fn commit_changes( repo_path: String, paths: Vec, summary: String, description: Option, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let summary = summary.trim(); if summary.is_empty() { return Err("Commit summary is required.".to_string()); } let paths = paths .into_iter() .map(|path| validate_git_path(&path)) .collect::, _>>()?; if paths.is_empty() { return Err("Select at least one file to commit.".to_string()); } ensure_git_success(run_git_command( Some(&repo_path), git_path.clone(), command_with_paths(&["add"], paths.clone())?, )?)?; let mut args = vec!["commit".to_string(), "--only".to_string()]; args.push("-m".to_string()); args.push(summary.to_string()); if let Some(description) = description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { args.push("-m".to_string()); args.push(description.to_string()); } args.push("--".to_string()); for path in paths { args.push(path); } ensure_git_success(run_git_command(Some(&repo_path), git_path, args)?) } #[tauri::command] fn checkout_branch( repo_path: String, branch: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let branch = normalize_reference(&branch)?; ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["checkout".to_string(), branch], )?) } #[tauri::command] fn create_branch( repo_path: String, branch: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let branch = normalize_reference(&branch)?; ensure_git_success(run_git_command( Some(&repo_path), git_path, vec!["checkout".to_string(), "-b".to_string(), branch], )?) } #[tauri::command] fn delete_branch( repo_path: String, branch: String, force: Option, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let branch = normalize_reference(&branch)?; let current = run_git_output( &repo_path, git_path.clone(), vec![ "rev-parse".to_string(), "--abbrev-ref".to_string(), "HEAD".to_string(), ], )?; if String::from_utf8_lossy(¤t).trim() == branch { return Err("Cannot delete the current branch.".to_string()); } ensure_git_success(run_git_command( Some(&repo_path), git_path, vec![ "branch".to_string(), if force.unwrap_or(false) { "-D" } else { "-d" }.to_string(), branch, ], )?) } #[tauri::command] fn rename_branch( repo_path: String, old_branch: String, new_branch: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let old_branch = normalize_reference(&old_branch)?; let new_branch = normalize_reference(&new_branch)?; if old_branch == new_branch { return Err("Choose a different branch name.".to_string()); } ensure_git_success(run_git_command( Some(&repo_path), git_path, vec![ "branch".to_string(), "-m".to_string(), old_branch, new_branch, ], )?) } #[tauri::command] fn commit_history( repo_path: String, limit: Option, git_path: Option, ) -> Result, String> { let repo_path = validate_repo_dir(&repo_path)?; let max_count = limit.unwrap_or(100).clamp(1, 500).to_string(); let output = run_git_output( &repo_path, git_path, vec![ "log".to_string(), format!("--max-count={max_count}"), "--date=short".to_string(), "--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1e".to_string(), ], )?; let commits = String::from_utf8_lossy(&output) .split('\x1e') .filter_map(|record| { let fields = record.trim_matches('\n').split('\x1f').collect::>(); if fields.len() < 5 { return None; } Some(GitCommitSummary { hash: fields[0].to_string(), short_hash: fields[1].to_string(), author: fields[2].to_string(), date: fields[3].to_string(), title: fields[4].to_string(), }) }) .collect(); Ok(commits) } #[tauri::command] fn commit_detail( repo_path: String, hash: String, git_path: Option, ) -> Result { let repo_path = validate_repo_dir(&repo_path)?; let hash = normalize_reference(&hash)?; let meta = run_git_output( &repo_path, git_path.clone(), vec![ "show".to_string(), "--quiet".to_string(), "--date=short".to_string(), "--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%B".to_string(), hash.clone(), ], )?; let meta_text = String::from_utf8_lossy(&meta); let fields = meta_text.splitn(6, '\x1f').collect::>(); if fields.len() < 6 { return Err("Unable to parse commit metadata.".to_string()); } let files_output = run_git_output( &repo_path, git_path.clone(), vec![ "show".to_string(), "--name-status".to_string(), "--pretty=format:".to_string(), hash.clone(), ], )?; let files = String::from_utf8_lossy(&files_output) .lines() .filter_map(|line| { let mut parts = line.split('\t'); let status_code = parts.next()?.chars().next()?.to_string(); let first_path = parts.next()?.to_string(); let second_path = parts.next().map(ToString::to_string); let path = second_path.clone().unwrap_or_else(|| first_path.clone()); Some(GitChangedFile { status: status_kind(&status_code, " "), path, original_path: second_path.map(|_| first_path), x: status_code, y: " ".to_string(), }) }) .collect(); let diff = run_git_output( &repo_path, git_path, vec![ "show".to_string(), "--format=".to_string(), "--no-ext-diff".to_string(), hash, ], ) .map(|output| String::from_utf8_lossy(&output).trim().to_string()) .unwrap_or_default(); Ok(GitCommitDetail { hash: fields[0].to_string(), short_hash: fields[1].to_string(), author: fields[2].to_string(), date: fields[3].to_string(), title: fields[4].to_string(), message: fields[5].trim().to_string(), files, diff, }) } #[tauri::command] fn open_in_file_explorer(repo_path: String) -> Result<(), String> { let repo_path = validate_repo_dir(&repo_path)?; #[cfg(target_os = "windows")] let mut command = { let mut cmd = Command::new("explorer"); cmd.arg(&repo_path); cmd }; #[cfg(target_os = "macos")] let mut command = { let mut cmd = Command::new("open"); cmd.arg(&repo_path); cmd }; #[cfg(all(unix, not(target_os = "macos")))] let mut command = { let mut cmd = Command::new("xdg-open"); cmd.arg(&repo_path); cmd }; command .spawn() .map_err(|err| format!("Failed to open file explorer: {err}"))?; Ok(()) } #[tauri::command] fn open_in_external_editor(repo_path: String, editor_path: String) -> Result<(), String> { let repo_path = validate_repo_dir(&repo_path)?; let editor_path = editor_path.trim(); if editor_path.is_empty() { return Err("Configure an external editor command in Settings first.".to_string()); } Command::new(editor_path) .arg(repo_path) .spawn() .map_err(|err| format!("Failed to open external editor: {err}"))?; Ok(()) } #[tauri::command] fn scan_local_repos( roots: Option>, allowed_remote_urls: Vec, git_path: Option, max_depth: Option, max_results: Option, ) -> Result, String> { let max_depth = max_depth.unwrap_or(4).clamp(1, 8); let max_results = max_results.unwrap_or(200).clamp(1, 500); let allowed_remote_urls = allowed_remote_urls .into_iter() .map(|url| normalize_git_remote_url(&url)) .filter(|url| !url.is_empty()) .collect::>(); if allowed_remote_urls.is_empty() { return Err( "No selected server repository URLs are available to match against.".to_string(), ); } let root_paths = roots .unwrap_or_default() .into_iter() .map(|path| path.trim().to_string()) .filter(|path| !path.is_empty()) .map(PathBuf::from) .collect::>(); let root_paths = if root_paths.is_empty() { default_repo_scan_roots() } else { root_paths }; if root_paths.is_empty() { return Err( "No scan roots found. Set a default clone directory or enter a scan root.".to_string(), ); } let mut results = Vec::new(); let mut seen = HashSet::new(); for root in root_paths { if root.exists() && root.is_dir() { collect_repo_candidates( &root, 0, max_depth, &mut results, &mut seen, &allowed_remote_urls, git_path.clone(), max_results, ); } if results.len() >= max_results { break; } } results.sort_by(|a, b| { a.name .to_lowercase() .cmp(&b.name.to_lowercase()) .then_with(|| a.path.to_lowercase().cmp(&b.path.to_lowercase())) }); Ok(results) } #[tauri::command] fn local_repo_branches( repo_path: String, git_path: Option, ) -> Result, String> { let current = run_git_output( repo_path.trim(), git_path.clone(), vec![ "rev-parse".to_string(), "--abbrev-ref".to_string(), "HEAD".to_string(), ], ) .ok() .map(|output| String::from_utf8_lossy(&output).trim().to_string()) .filter(|value| !value.is_empty() && value != "HEAD"); let output = run_git_output( repo_path.trim(), git_path, vec![ "for-each-ref".to_string(), "--format=%(refname)".to_string(), "refs/heads".to_string(), "refs/remotes".to_string(), ], )?; let branches = String::from_utf8_lossy(&output) .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.ends_with("/HEAD")) .filter_map(|ref_name| { ref_name .strip_prefix("refs/heads/") .or_else(|| ref_name.strip_prefix("refs/remotes/")) }) .map(|name| LocalRepoBranch { name: name.to_string(), current: current.as_deref() == Some(name), }) .collect(); Ok(branches) } #[tauri::command] fn local_repo_tree( repo_path: String, reference: String, path: String, git_path: Option, ) -> Result, String> { let reference = normalize_reference(&reference)?; let path = normalize_repo_path(&path)?; let output = run_git_output( repo_path.trim(), git_path, vec![ "ls-tree".to_string(), "-l".to_string(), treeish(&reference, &path), ], )?; let mut entries = Vec::new(); for line in String::from_utf8_lossy(&output).lines() { let Some((meta, name)) = line.split_once('\t') else { continue; }; let meta_parts: Vec<&str> = meta.split_whitespace().collect(); if meta_parts.len() < 4 { continue; } let entry_type = meta_parts[1].to_string(); let size = meta_parts[3].parse::().ok(); let entry_path = if path.is_empty() { name.to_string() } else { format!("{path}/{name}") }; entries.push(LocalRepoEntry { name: name.to_string(), path: entry_path, entry_type, size, }); } entries.sort_by(|a, b| { let a_is_tree = a.entry_type == "tree"; let b_is_tree = b.entry_type == "tree"; b_is_tree .cmp(&a_is_tree) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); Ok(entries) } #[tauri::command] fn local_repo_file( repo_path: String, reference: String, path: String, git_path: Option, ) -> Result { const MAX_TEXT_PREVIEW_BYTES: u64 = 256 * 1024; let reference = normalize_reference(&reference)?; let path = normalize_repo_path(&path)?; if path.is_empty() { return Err("File path is required.".to_string()); } let spec = treeish(&reference, &path); let object_type = run_git_output( repo_path.trim(), git_path.clone(), vec!["cat-file".to_string(), "-t".to_string(), spec.clone()], )?; if String::from_utf8_lossy(&object_type).trim() != "blob" { return Err("Selected path is not a file.".to_string()); } let size_output = run_git_output( repo_path.trim(), git_path.clone(), vec!["cat-file".to_string(), "-s".to_string(), spec.clone()], )?; let size = String::from_utf8_lossy(&size_output) .trim() .parse::() .map_err(|_| "Unable to determine file size.".to_string())?; if size > MAX_TEXT_PREVIEW_BYTES { return Ok(LocalRepoFile { path, size, content: None, is_binary: false, too_large: true, }); } let content = run_git_output( repo_path.trim(), git_path, vec!["show".to_string(), "--no-ext-diff".to_string(), spec], )?; let is_binary = content.contains(&0); let content = if is_binary { None } else { String::from_utf8(content).ok() }; Ok(LocalRepoFile { path, size, is_binary: content.is_none(), too_large: false, content, }) } #[tauri::command] fn test_gitea_connection( server_url: String, auth_method: String, token: Option, username: Option, password: Option, ) -> Result { // A lightweight compatibility check against the canonical Gitea version endpoint. let api_base_url = normalize_api_base_url(&server_url)?; let version_url = format!("{api_base_url}/version"); let mut request = Client::new() .get(&version_url) .header(ACCEPT, "application/json"); if auth_method == "token" { if let Some(value) = token.as_deref() { if !value.trim().is_empty() { request = request.header(AUTHORIZATION, format!("token {}", value.trim())); } } } else if auth_method == "password" { request = request.basic_auth(username.unwrap_or_default(), password); } let response = request .send() .map_err(|err| format!("Failed to connect to Gitea server: {err}"))?; if !response.status().is_success() { return Ok(ServerConnectionResult { ok: false, message: format!("Server responded with status {}", response.status()), api_base_url, version: None, }); } let response_json: serde_json::Value = response .json() .map_err(|err| format!("Failed to parse Gitea response: {err}"))?; let version = response_json .get("version") .and_then(|value| value.as_str()) .map(ToString::to_string); Ok(ServerConnectionResult { ok: true, message: "Gitea API is reachable and compatible.".to_string(), api_base_url, version, }) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ git_clone, git_pull, git_push, git_publish_branch, git_status, git_branch, git_fetch, git_sync, repository_sync_status, working_tree_status, get_file_diff, stage_files, unstage_files, commit_changes, checkout_branch, create_branch, delete_branch, rename_branch, commit_history, commit_detail, open_in_file_explorer, open_in_external_editor, scan_local_repos, local_repo_branches, local_repo_tree, local_repo_file, test_gitea_connection ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }