use serde::Serialize; use std::{ collections::BTreeMap, ffi::{OsStr, OsString}, path::{Path, PathBuf}, process::Command, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum FileStatusKind { Modified, Added, Deleted, Renamed, Untracked, Conflicted, Unknown, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitFileStatus { pub path: String, pub old_path: Option, pub staged: Option, pub unstaged: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitStatus { pub repo_path: String, pub current_branch: Option, pub upstream: Option, pub ahead: u32, pub behind: u32, pub files: Vec, pub clean: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitBranch { pub name: String, pub current: bool, pub remote: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitCommit { pub hash: String, pub short_hash: String, pub summary: String, pub author_name: String, pub author_email: String, pub date: String, pub refs: Vec, pub files: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitCommitFile { pub path: String, pub old_path: Option, pub status: FileStatusKind, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitRepositoryFile { pub path: String, pub tracked: bool, pub status: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq)] struct BranchInfo { current_branch: Option, upstream: Option, ahead: u32, behind: u32, } #[derive(Debug, Clone, PartialEq, Eq)] enum CheckoutPlan { Local(String), TrackRemote { local: String, remote: String }, Raw(String), } #[tauri::command] pub fn open_repository(path: String) -> Result { let repo = resolve_repo(&path)?; status_for_repo(&repo) } #[tauri::command] pub fn get_status(path: String) -> Result { let repo = resolve_repo(&path)?; status_for_repo(&repo) } #[tauri::command] pub fn list_branches(path: String) -> Result, String> { let repo = resolve_repo(&path)?; let output = run_git( &repo, [ "for-each-ref", "--format=%(refname)\t%(HEAD)", "refs/heads", "refs/remotes", ], )?; let text = String::from_utf8_lossy(&output); let mut branches = Vec::new(); for line in text.lines() { let Some((ref_name, head_marker)) = line.split_once('\t') else { continue; }; let (name, remote) = if let Some(name) = ref_name.strip_prefix("refs/heads/") { (name, false) } else if let Some(name) = ref_name.strip_prefix("refs/remotes/") { if name.ends_with("/HEAD") { continue; } (name, true) } else { continue; }; branches.push(GitBranch { name: name.to_string(), current: head_marker.trim() == "*", remote, }); } Ok(branches) } #[tauri::command] pub fn checkout_branch(path: String, branch: String) -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim().to_string(); if branch.is_empty() { return Err("Branch-Name darf nicht leer sein.".to_string()); } match checkout_plan(&repo, &branch)? { CheckoutPlan::Local(local) => { run_git(&repo, ["checkout", local.as_str()])?; } CheckoutPlan::TrackRemote { local, remote } => { run_git( &repo, ["checkout", "--track", "-b", local.as_str(), remote.as_str()], )?; } CheckoutPlan::Raw(target) => { run_git(&repo, ["checkout", target.as_str()])?; } } status_for_repo(&repo) } #[tauri::command] pub fn stage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; if !files.is_empty() { run_git_with_paths(&repo, &["add"], &files)?; } status_for_repo(&repo) } #[tauri::command] pub fn unstage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; if !files.is_empty() { let current_status = status_for_repo(&repo)?; unstage_selected_files(&repo, ¤t_status.files, &files)?; } status_for_repo(&repo) } #[tauri::command] pub fn restore_files(path: String, files: Vec, staged: bool) -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; if files.is_empty() { return status_for_repo(&repo); } let current_status = status_for_repo(&repo)?; if staged { restore_staged_files(&repo, ¤t_status.files, &files)?; } else { restore_worktree_files(&repo, ¤t_status.files, &files)?; } status_for_repo(&repo) } #[tauri::command] pub fn commit(path: String, message: String) -> Result { let repo = resolve_repo(&path)?; if message.trim().is_empty() { return Err("Commit-Message darf nicht leer sein.".to_string()); } run_git(&repo, ["commit", "-m", message.as_str()])?; status_for_repo(&repo) } #[tauri::command] pub fn pull(path: String) -> Result { let repo = resolve_repo(&path)?; run_git(&repo, ["pull", "--ff-only"])?; status_for_repo(&repo) } #[tauri::command] pub fn push(path: String) -> Result { let repo = resolve_repo(&path)?; run_git(&repo, ["push"])?; status_for_repo(&repo) } #[tauri::command] pub fn merge_branch(path: String, branch: String) -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim(); if branch.is_empty() { return Err("Branch-Name darf nicht leer sein.".to_string()); } run_git(&repo, ["merge", "--no-edit", branch])?; status_for_repo(&repo) } #[tauri::command] pub fn list_commits(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { return Ok(Vec::new()); } let limit = limit.unwrap_or(100).clamp(1, 500).to_string(); let output = run_git( &repo, [ "log", "--decorate=short", "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e", "-n", limit.as_str(), ], )?; parse_commit_log(&repo, &output) } #[tauri::command] pub fn list_repository_files(path: String) -> Result, String> { let repo = resolve_repo(&path)?; repository_files(&repo) } #[tauri::command] pub fn list_file_history( path: String, file: String, limit: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; if verify_commit(&repo, "HEAD").is_err() { return Ok(Vec::new()); } let limit = limit.unwrap_or(100).clamp(1, 500).to_string(); let mut args = vec![ OsString::from("log"), OsString::from("--decorate=short"), OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e"), OsString::from("-n"), OsString::from(limit), ]; if !is_repository_folder_path(&repo, &file)? { args.push(OsString::from("--follow")); } args.extend([OsString::from("--"), OsString::from(file)]); let output = run_git(&repo, args)?; parse_commit_log(&repo, &output) } #[tauri::command] pub fn restore_to_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; let commit_hash = verify_commit(&repo, &commit)?; run_git(&repo, ["reset", "--hard", commit_hash.as_str()])?; status_for_repo(&repo) } #[tauri::command] pub fn restore_file_from_commit( path: String, commit: String, file: String, ) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let commit_hash = verify_commit(&repo, &commit)?; run_git_with_paths( &repo, &["restore", "--source", commit_hash.as_str(), "--worktree"], &[file], )?; status_for_repo(&repo) } fn resolve_repo(path: &str) -> Result { if path.trim().is_empty() { return Err("Repository-Pfad darf nicht leer sein.".to_string()); } let input = PathBuf::from(path); let output = run_git_at( &input, ["rev-parse", "--show-toplevel"], "Kein Git-Repository oder nicht erreichbar", )?; let top_level = String::from_utf8_lossy(&output).trim().to_string(); if top_level.is_empty() { return Err("Git konnte keinen Repository-Wurzelpfad ermitteln.".to_string()); } Ok(PathBuf::from(top_level)) } fn verify_commit(repo: &Path, commit: &str) -> Result { let commit = commit.trim(); if commit.is_empty() { return Err("Commit darf nicht leer sein.".to_string()); } let rev = format!("{commit}^{{commit}}"); let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()]) .map_err(|err| format!("Commit konnte nicht gefunden werden: {err}"))?; let hash = String::from_utf8_lossy(&output).trim().to_string(); if hash.is_empty() { return Err("Commit konnte nicht gefunden werden.".to_string()); } Ok(hash) } fn status_for_repo(repo: &Path) -> Result { let output = run_git( repo, [ "status", "--porcelain=v1", "-b", "-z", "--untracked-files=all", ], )?; let (branch, files) = parse_status_output(&output)?; Ok(GitStatus { repo_path: repo.to_string_lossy().to_string(), current_branch: branch.current_branch, upstream: branch.upstream, ahead: branch.ahead, behind: branch.behind, clean: files.is_empty(), files, }) } fn repository_files(repo: &Path) -> Result, String> { let status = status_for_repo(repo)?; let mut files = BTreeMap::::new(); let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?; for path in parse_nul_paths(&tracked_output) { let status = status_for_file(&status.files, &path); files.insert( path.clone(), GitRepositoryFile { path, tracked: true, status, }, ); } let untracked_output = run_git(repo, ["ls-files", "-z", "--others", "--exclude-standard"])?; for path in parse_nul_paths(&untracked_output) { let status = status_for_file(&status.files, &path).or(Some(FileStatusKind::Untracked)); files.insert( path.clone(), GitRepositoryFile { path, tracked: false, status, }, ); } Ok(files.into_values().collect()) } fn is_repository_folder_path(repo: &Path, path: &str) -> Result { let normalized = normalize_git_path(path); if repo.join(path).is_dir() { return Ok(true); } let prefix = format!("{normalized}/"); let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?; Ok(parse_nul_paths(&tracked_output) .iter() .any(|tracked_path| normalize_git_path(tracked_path).starts_with(&prefix))) } fn normalize_git_path(path: &str) -> String { path.replace('\\', "/").trim_matches('/').to_string() } fn parse_nul_paths(output: &[u8]) -> Vec { output .split(|byte| *byte == 0) .filter(|entry| !entry.is_empty()) .map(|entry| String::from_utf8_lossy(entry).to_string()) .collect() } fn status_for_file(statuses: &[GitFileStatus], path: &str) -> Option { let status = find_status(statuses, path)?; if matches!(status.staged, Some(FileStatusKind::Conflicted)) || matches!(status.unstaged, Some(FileStatusKind::Conflicted)) { return Some(FileStatusKind::Conflicted); } status.unstaged.or(status.staged) } fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: char = '\x1f'; const RECORD_SEPARATOR: char = '\x1e'; let text = String::from_utf8_lossy(output); let mut commits = Vec::new(); for record in text.split(RECORD_SEPARATOR).map(str::trim) { if record.is_empty() { continue; } let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); if fields.len() != 7 { return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); } let refs = fields[5] .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect(); let hash = fields[0].to_string(); let files = commit_files(repo, &hash)?; commits.push(GitCommit { hash, short_hash: fields[1].to_string(), author_name: fields[2].to_string(), author_email: fields[3].to_string(), date: fields[4].to_string(), refs, summary: fields[6].to_string(), files, }); } Ok(commits) } #[cfg(test)] fn parse_commit_log_metadata(output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: char = '\x1f'; const RECORD_SEPARATOR: char = '\x1e'; let text = String::from_utf8_lossy(output); let mut commits = Vec::new(); for record in text.split(RECORD_SEPARATOR).map(str::trim) { if record.is_empty() { continue; } let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); if fields.len() != 7 { return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); } let refs = fields[5] .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect(); commits.push(GitCommit { hash: fields[0].to_string(), short_hash: fields[1].to_string(), author_name: fields[2].to_string(), author_email: fields[3].to_string(), date: fields[4].to_string(), refs, summary: fields[6].to_string(), files: Vec::new(), }); } Ok(commits) } fn commit_files(repo: &Path, commit: &str) -> Result, String> { let output = run_git( repo, [ "diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-M", "-z", commit, ], )?; parse_commit_files(&output) } fn parse_commit_files(output: &[u8]) -> Result, String> { let entries: Vec<&[u8]> = output .split(|byte| *byte == 0) .filter(|entry| !entry.is_empty()) .collect(); let mut files = Vec::new(); let mut index = 0; while index < entries.len() { let status_text = String::from_utf8_lossy(entries[index]).to_string(); index += 1; let status = map_name_status(&status_text); if index >= entries.len() { return Err(format!("Git-Diff-Eintrag ohne Pfad: {status_text}")); } if matches!(status, FileStatusKind::Renamed) { let old_path = String::from_utf8_lossy(entries[index]).to_string(); index += 1; if index >= entries.len() { return Err(format!("Git-Rename ohne Zielpfad: {old_path}")); } let path = String::from_utf8_lossy(entries[index]).to_string(); index += 1; files.push(GitCommitFile { path, old_path: Some(old_path), status, }); } else { let path = String::from_utf8_lossy(entries[index]).to_string(); index += 1; files.push(GitCommitFile { path, old_path: None, status, }); } } Ok(files) } fn map_name_status(status: &str) -> FileStatusKind { match status.chars().next() { Some('M') => FileStatusKind::Modified, Some('A') => FileStatusKind::Added, Some('D') => FileStatusKind::Deleted, Some('R') => FileStatusKind::Renamed, Some('C') => FileStatusKind::Added, Some('U') => FileStatusKind::Conflicted, _ => FileStatusKind::Unknown, } } fn checkout_plan(repo: &Path, branch: &str) -> Result { if ref_exists(repo, &format!("refs/heads/{branch}"))? { return Ok(CheckoutPlan::Local(branch.to_string())); } if ref_exists(repo, &format!("refs/remotes/{branch}"))? { let Some(local) = local_branch_name_for_remote(branch) else { return Ok(CheckoutPlan::Raw(branch.to_string())); }; if ref_exists(repo, &format!("refs/heads/{local}"))? { return Ok(CheckoutPlan::Local(local.to_string())); } return Ok(CheckoutPlan::TrackRemote { local: local.to_string(), remote: branch.to_string(), }); } Ok(CheckoutPlan::Raw(branch.to_string())) } fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> { remote_branch .split_once('/') .map(|(_, local)| local) .filter(|local| !local.is_empty()) } fn ref_exists(repo: &Path, ref_name: &str) -> Result { let output = Command::new("git") .arg("-C") .arg(repo) .args(["show-ref", "--verify", "--quiet", ref_name]) .output() .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; match output.status.code() { Some(0) => Ok(true), Some(1) => Ok(false), _ => { let stderr = String::from_utf8_lossy(&output.stderr); let details = if stderr.trim().is_empty() { "unbekannter Fehler".to_string() } else { stderr.trim().to_string() }; Err(format!("Git-Ref konnte nicht geprueft werden: {details}")) } } } fn restore_worktree_files( repo: &Path, statuses: &[GitFileStatus], files: &[String], ) -> Result<(), String> { let mut restore_paths = Vec::new(); let mut clean_paths = Vec::new(); for file in files { let status = find_status(statuses, file); if matches!( status.and_then(|entry| entry.unstaged), Some(FileStatusKind::Untracked) ) { clean_paths.push(file.clone()); } else { restore_paths.push(file.clone()); } } if !restore_paths.is_empty() { run_git_with_paths(repo, &["restore", "--worktree"], &restore_paths)?; } if !clean_paths.is_empty() { run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?; } Ok(()) } fn unstage_selected_files( repo: &Path, statuses: &[GitFileStatus], files: &[String], ) -> Result<(), String> { let mut restore_paths = Vec::new(); let mut remove_from_index = Vec::new(); for file in files { let status = find_status(statuses, file); if matches!( status.and_then(|entry| entry.staged), Some(FileStatusKind::Added) ) && status.and_then(|entry| entry.old_path.as_ref()).is_none() { remove_from_index.push(file.clone()); } else { restore_paths.push(file.clone()); } } if !restore_paths.is_empty() { run_git_with_paths(repo, &["restore", "--staged"], &restore_paths)?; } if !remove_from_index.is_empty() { run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?; } Ok(()) } fn restore_staged_files( repo: &Path, statuses: &[GitFileStatus], files: &[String], ) -> Result<(), String> { let mut restore_paths = Vec::new(); let mut remove_from_index = Vec::new(); let mut clean_paths = Vec::new(); for file in files { let status = find_status(statuses, file); match status.and_then(|entry| entry.staged) { Some(FileStatusKind::Added) => { let target = status .map(|entry| entry.path.clone()) .unwrap_or_else(|| file.clone()); remove_from_index.push(target.clone()); clean_paths.push(target); } Some(FileStatusKind::Renamed) => { if let Some(entry) = status { if let Some(old_path) = entry.old_path.clone() { restore_paths.push(old_path); remove_from_index.push(entry.path.clone()); clean_paths.push(entry.path.clone()); } else { restore_paths.push(entry.path.clone()); } } else { restore_paths.push(file.clone()); } } Some(_) => { if let Some(entry) = status { restore_paths.push(entry.path.clone()); } else { restore_paths.push(file.clone()); } } None => { if matches!( status.and_then(|entry| entry.unstaged), Some(FileStatusKind::Untracked) ) { clean_paths.push(file.clone()); } else { restore_paths.push(file.clone()); } } } } if !restore_paths.is_empty() { run_git_with_paths( repo, &["restore", "--source=HEAD", "--staged", "--worktree"], &restore_paths, )?; } if !remove_from_index.is_empty() { run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?; } if !clean_paths.is_empty() { run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?; } Ok(()) } fn find_status<'a>(statuses: &'a [GitFileStatus], file: &str) -> Option<&'a GitFileStatus> { statuses .iter() .find(|entry| entry.path == file || entry.old_path.as_deref() == Some(file)) } fn validate_files(files: &[String]) -> Result<(), String> { if files.iter().any(|file| file.is_empty()) { return Err("Dateiliste enthaelt einen leeren Pfad.".to_string()); } Ok(()) } fn run_git_with_paths( repo: &Path, base_args: &[&str], files: &[String], ) -> Result, String> { let mut args = Vec::with_capacity(base_args.len() + files.len() + 1); args.extend(base_args.iter().map(OsString::from)); args.push(OsString::from("--")); args.extend(files.iter().map(OsString::from)); run_git(repo, args) } fn run_git(repo: &Path, args: I) -> Result, String> where I: IntoIterator, S: AsRef, { run_git_at(repo, args, "Git-Befehl fehlgeschlagen") } fn run_git_at(path: &Path, args: I, context: &str) -> Result, String> where I: IntoIterator, S: AsRef, { let output = Command::new("git") .arg("-C") .arg(path) .args(args) .output() .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; if output.status.success() { return Ok(output.stdout); } let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let details = if !stderr.trim().is_empty() { stderr.trim() } else if !stdout.trim().is_empty() { stdout.trim() } else { "unbekannter Fehler" }; Err(format!("{context}: {details}")) } fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec), String> { let entries: Vec<&[u8]> = output .split(|byte| *byte == 0) .filter(|entry| !entry.is_empty()) .collect(); let mut index = 0; let mut branch = BranchInfo::default(); if let Some(first) = entries.first() { if first.starts_with(b"## ") { branch = parse_branch_header(&String::from_utf8_lossy(first)); index = 1; } } let mut files = Vec::new(); while index < entries.len() { let entry = String::from_utf8_lossy(entries[index]); index += 1; if entry.len() < 4 { return Err(format!("Unerwartete Git-Statuszeile: {entry}")); } let bytes = entry.as_bytes(); let staged_code = bytes[0] as char; let unstaged_code = bytes[1] as char; let path = entry[3..].to_string(); let old_path = if is_rename_or_copy(staged_code) || is_rename_or_copy(unstaged_code) { if index >= entries.len() { return Err(format!("Git-Status fuer Rename ohne Ursprungspfad: {path}")); } let old_path = String::from_utf8_lossy(entries[index]).to_string(); index += 1; Some(old_path) } else { None }; let (staged, unstaged) = map_status_codes(staged_code, unstaged_code); files.push(GitFileStatus { path, old_path, staged, unstaged, }); } Ok((branch, files)) } fn parse_branch_header(header: &str) -> BranchInfo { let mut info = BranchInfo::default(); let header = header.trim().strip_prefix("## ").unwrap_or(header).trim(); let (branch_part, tracking_part) = if let Some(start) = header.rfind(" [") { if header.ends_with(']') { (&header[..start], Some(&header[start + 2..header.len() - 1])) } else { (header, None) } } else { (header, None) }; if let Some(tracking) = tracking_part { for item in tracking.split(',').map(str::trim) { if let Some(value) = item.strip_prefix("ahead ") { info.ahead = value.parse().unwrap_or(0); } else if let Some(value) = item.strip_prefix("behind ") { info.behind = value.parse().unwrap_or(0); } } } if let Some(branch_name) = branch_part.strip_prefix("No commits yet on ") { info.current_branch = Some(branch_name.to_string()); return info; } if branch_part == "HEAD (no branch)" { return info; } if let Some((current, upstream)) = branch_part.split_once("...") { if !current.is_empty() { info.current_branch = Some(current.to_string()); } if !upstream.is_empty() { info.upstream = Some(upstream.to_string()); } } else if !branch_part.is_empty() { info.current_branch = Some(branch_part.to_string()); } info } fn map_status_codes( staged_code: char, unstaged_code: char, ) -> (Option, Option) { if is_conflict(staged_code, unstaged_code) { return ( Some(FileStatusKind::Conflicted), Some(FileStatusKind::Conflicted), ); } if staged_code == '?' && unstaged_code == '?' { return (None, Some(FileStatusKind::Untracked)); } ( map_index_status(staged_code), map_worktree_status(unstaged_code), ) } fn map_index_status(code: char) -> Option { match code { ' ' => None, 'M' => Some(FileStatusKind::Modified), 'A' => Some(FileStatusKind::Added), 'D' => Some(FileStatusKind::Deleted), 'R' => Some(FileStatusKind::Renamed), 'C' => Some(FileStatusKind::Added), _ => Some(FileStatusKind::Unknown), } } fn map_worktree_status(code: char) -> Option { match code { ' ' => None, 'M' => Some(FileStatusKind::Modified), 'A' => Some(FileStatusKind::Added), 'D' => Some(FileStatusKind::Deleted), 'R' => Some(FileStatusKind::Renamed), 'C' => Some(FileStatusKind::Added), _ => Some(FileStatusKind::Unknown), } } fn is_rename_or_copy(code: char) -> bool { matches!(code, 'R' | 'C') } fn is_conflict(staged_code: char, unstaged_code: char) -> bool { matches!( (staged_code, unstaged_code), ('D', 'D') | ('A', 'U') | ('U', 'D') | ('U', 'A') | ('D', 'U') | ('A', 'A') | ('U', 'U') ) } #[cfg(test)] mod tests { use super::*; use std::{ ffi::OsStr, fs, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; struct TempRepo { path: PathBuf, } impl Drop for TempRepo { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn init_temp_repo(name: &str) -> TempRepo { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after Unix epoch") .as_nanos(); let path = std::env::temp_dir().join(format!( "tauri_git_lite_{name}_{}_{}", std::process::id(), nanos )); fs::create_dir_all(&path).expect("temp repo directory should be created"); run_git_test(&path, ["init", "-q"]); run_git_test(&path, ["config", "user.email", "test@example.com"]); run_git_test(&path, ["config", "user.name", "Tester"]); TempRepo { path } } fn run_git_test(repo: &Path, args: I) where I: IntoIterator, S: AsRef, { let output = Command::new("git") .arg("-C") .arg(repo) .args(args) .output() .expect("git should start"); assert!( output.status.success(), "git command failed with status {}\nstdout: {}\nstderr: {}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } fn git_output_test(repo: &Path, args: I) -> String where I: IntoIterator, S: AsRef, { let output = Command::new("git") .arg("-C") .arg(repo) .args(args) .output() .expect("git should start"); assert!( output.status.success(), "git command failed with status {}\nstdout: {}\nstderr: {}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).trim().to_string() } fn commit_initial_file(repo: &Path) { fs::write(repo.join("old.txt"), "original\n").expect("initial file should be written"); run_git_test(repo, ["add", "old.txt"]); run_git_test(repo, ["commit", "-q", "-m", "init"]); } #[test] fn parses_branch_tracking_and_file_states() { let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0"; let (branch, files) = parse_status_output(raw).unwrap(); assert_eq!(branch.current_branch.as_deref(), Some("main")); assert_eq!(branch.upstream.as_deref(), Some("origin/main")); assert_eq!(branch.ahead, 2); assert_eq!(branch.behind, 1); assert_eq!( files, vec![ GitFileStatus { path: "changed.txt".to_string(), old_path: None, staged: None, unstaged: Some(FileStatusKind::Modified), }, GitFileStatus { path: "deleted.txt".to_string(), old_path: None, staged: Some(FileStatusKind::Deleted), unstaged: None, }, GitFileStatus { path: "new.txt".to_string(), old_path: None, staged: None, unstaged: Some(FileStatusKind::Untracked), }, ] ); } #[test] fn parses_z_renames_with_current_path_first() { let raw = b"## feature\0R new-name.txt\0old-name.txt\0"; let (_, files) = parse_status_output(raw).unwrap(); assert_eq!( files, vec![GitFileStatus { path: "new-name.txt".to_string(), old_path: Some("old-name.txt".to_string()), staged: Some(FileStatusKind::Renamed), unstaged: None, }] ); } #[test] fn parses_conflicts_as_conflicted() { let raw = b"## main\0UU conflicted.txt\0"; let (_, files) = parse_status_output(raw).unwrap(); assert_eq!(files[0].staged, Some(FileStatusKind::Conflicted)); assert_eq!(files[0].unstaged, Some(FileStatusKind::Conflicted)); } #[test] fn parses_initial_branch_header() { let branch = parse_branch_header("## No commits yet on main"); assert_eq!(branch.current_branch.as_deref(), Some("main")); assert_eq!(branch.upstream, None); assert_eq!(branch.ahead, 0); assert_eq!(branch.behind, 0); } #[test] fn parses_commit_history_records() { let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1fAdd history panel\x1e"; let commits = parse_commit_log_metadata(raw).unwrap(); assert_eq!( commits, vec![GitCommit { hash: "1111111111111111111111111111111111111111".to_string(), short_hash: "1111111".to_string(), author_name: "Ada Lovelace".to_string(), author_email: "ada@example.com".to_string(), date: "2026-06-26T12:34:56+02:00".to_string(), refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()], summary: "Add history panel".to_string(), files: Vec::new(), }] ); } #[test] fn parses_commit_file_changes_and_renames() { let raw = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0"; let files = parse_commit_files(raw).unwrap(); assert_eq!( files, vec![ GitCommitFile { path: "src/main.rs".to_string(), old_path: None, status: FileStatusKind::Modified, }, GitCommitFile { path: "README.md".to_string(), old_path: None, status: FileStatusKind::Added, }, GitCommitFile { path: "new.txt".to_string(), old_path: Some("old.txt".to_string()), status: FileStatusKind::Renamed, }, ] ); } #[test] fn restore_staged_added_file_removes_it_from_index_and_worktree() { let repo = init_temp_repo("restore_staged_added_file"); commit_initial_file(&repo.path); fs::write(repo.path.join("new.txt"), "new\n").expect("added file should be written"); run_git_test(&repo.path, ["add", "new.txt"]); let status = status_for_repo(&repo.path).unwrap(); assert_eq!(status.files[0].path, "new.txt"); assert_eq!(status.files[0].staged, Some(FileStatusKind::Added)); restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap(); let next_status = status_for_repo(&repo.path).unwrap(); assert!(next_status.clean, "{:?}", next_status.files); assert!(!repo.path.join("new.txt").exists()); } #[test] fn restore_staged_rename_restores_old_path_and_removes_new_path() { let repo = init_temp_repo("restore_staged_rename"); commit_initial_file(&repo.path); run_git_test(&repo.path, ["mv", "old.txt", "new.txt"]); let status = status_for_repo(&repo.path).unwrap(); assert_eq!(status.files[0].path, "new.txt"); assert_eq!(status.files[0].old_path.as_deref(), Some("old.txt")); assert_eq!(status.files[0].staged, Some(FileStatusKind::Renamed)); restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap(); let next_status = status_for_repo(&repo.path).unwrap(); assert!(next_status.clean, "{:?}", next_status.files); assert!(repo.path.join("old.txt").exists()); assert!(!repo.path.join("new.txt").exists()); } #[test] fn plans_remote_branch_checkout_as_tracking_branch() { let repo = init_temp_repo("remote_checkout_plan"); commit_initial_file(&repo.path); run_git_test( &repo.path, ["update-ref", "refs/remotes/origin/feature/demo", "HEAD"], ); let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap(); assert_eq!( plan, CheckoutPlan::TrackRemote { local: "feature/demo".to_string(), remote: "origin/feature/demo".to_string(), } ); } #[test] fn remote_checkout_prefers_existing_local_branch() { let repo = init_temp_repo("remote_checkout_existing_local"); commit_initial_file(&repo.path); run_git_test(&repo.path, ["branch", "feature/demo"]); run_git_test( &repo.path, ["update-ref", "refs/remotes/origin/feature/demo", "HEAD"], ); let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap(); assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string())); } #[test] fn restore_to_commit_resets_branch_to_selected_commit() { let repo = init_temp_repo("restore_to_commit"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("old.txt"), "second\n").expect("second file should be written"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); let status = restore_to_commit( repo.path.to_string_lossy().to_string(), first_commit.clone(), ) .unwrap(); let current_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let contents = fs::read_to_string(repo.path.join("old.txt")).unwrap(); assert_eq!(current_commit, first_commit); assert_eq!(contents.replace("\r\n", "\n"), "original\n"); assert!(status.clean, "{:?}", status.files); } #[test] fn restore_file_from_commit_restores_only_selected_file_to_worktree() { let repo = init_temp_repo("restore_file_from_commit"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("old.txt"), "second\n").expect("tracked file should be changed"); fs::write(repo.path.join("other.txt"), "other\n").expect("other file should be written"); run_git_test(&repo.path, ["add", "old.txt", "other.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); fs::write(repo.path.join("old.txt"), "working\n").expect("working file should change"); fs::write(repo.path.join("other.txt"), "working other\n") .expect("other working file should change"); let status = restore_file_from_commit( repo.path.to_string_lossy().to_string(), first_commit, "old.txt".to_string(), ) .unwrap(); let old_contents = fs::read_to_string(repo.path.join("old.txt")).unwrap(); let other_contents = fs::read_to_string(repo.path.join("other.txt")).unwrap(); assert_eq!(old_contents.replace("\r\n", "\n"), "original\n"); assert_eq!(other_contents.replace("\r\n", "\n"), "working other\n"); assert!(status.files.iter().any(|file| file.path == "old.txt")); } #[test] fn restore_file_from_commit_can_restore_a_folder_path() { let repo = init_temp_repo("restore_folder_from_commit"); fs::create_dir_all(repo.path.join("src")).expect("src directory should be created"); fs::create_dir_all(repo.path.join("docs")).expect("docs directory should be created"); fs::write(repo.path.join("src/a.txt"), "a1\n").expect("src a should be written"); fs::write(repo.path.join("src/b.txt"), "b1\n").expect("src b should be written"); fs::write(repo.path.join("docs/readme.txt"), "docs1\n").expect("docs should be written"); run_git_test(&repo.path, ["add", "."]); run_git_test(&repo.path, ["commit", "-q", "-m", "initial tree"]); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("src/a.txt"), "a2\n").expect("src a should change"); fs::write(repo.path.join("src/b.txt"), "b2\n").expect("src b should change"); fs::write(repo.path.join("docs/readme.txt"), "docs2\n").expect("docs should change"); restore_file_from_commit( repo.path.to_string_lossy().to_string(), first_commit, "src".to_string(), ) .unwrap(); let src_a = fs::read_to_string(repo.path.join("src/a.txt")).unwrap(); let src_b = fs::read_to_string(repo.path.join("src/b.txt")).unwrap(); let docs = fs::read_to_string(repo.path.join("docs/readme.txt")).unwrap(); assert_eq!(src_a.replace("\r\n", "\n"), "a1\n"); assert_eq!(src_b.replace("\r\n", "\n"), "b1\n"); assert_eq!(docs.replace("\r\n", "\n"), "docs2\n"); } #[test] fn repository_files_include_tracked_deleted_and_untracked_entries() { let repo = init_temp_repo("repository_files"); commit_initial_file(&repo.path); fs::remove_file(repo.path.join("old.txt")).expect("tracked file should be deleted"); fs::write(repo.path.join("new.txt"), "new\n").expect("untracked file should be written"); let files = repository_files(&repo.path).unwrap(); assert!(files.iter().any(|file| { file.path == "old.txt" && file.tracked && file.status == Some(FileStatusKind::Deleted) })); assert!(files.iter().any(|file| { file.path == "new.txt" && !file.tracked && file.status == Some(FileStatusKind::Untracked) })); } #[test] fn list_file_history_returns_commits_for_selected_file() { let repo = init_temp_repo("file_history"); commit_initial_file(&repo.path); fs::write(repo.path.join("old.txt"), "second\n").expect("tracked file should change"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "touch selected"]); fs::write(repo.path.join("other.txt"), "other\n").expect("other file should be written"); run_git_test(&repo.path, ["add", "other.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "touch other"]); let commits = list_file_history( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), Some(10), ) .unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0].summary, "touch selected"); assert_eq!(commits[1].summary, "init"); } #[test] fn list_file_history_returns_commits_for_selected_folder() { let repo = init_temp_repo("folder_history"); fs::create_dir_all(repo.path.join("src")).expect("src directory should be created"); fs::create_dir_all(repo.path.join("docs")).expect("docs directory should be created"); fs::write(repo.path.join("src/a.txt"), "a1\n").expect("src file should be written"); run_git_test(&repo.path, ["add", "."]); run_git_test(&repo.path, ["commit", "-q", "-m", "src initial"]); fs::write(repo.path.join("docs/readme.txt"), "docs\n") .expect("docs file should be written"); run_git_test(&repo.path, ["add", "."]); run_git_test(&repo.path, ["commit", "-q", "-m", "docs only"]); fs::write(repo.path.join("src/a.txt"), "a2\n").expect("src file should change"); run_git_test(&repo.path, ["add", "."]); run_git_test(&repo.path, ["commit", "-q", "-m", "src update"]); let commits = list_file_history( repo.path.to_string_lossy().to_string(), "src".to_string(), Some(10), ) .unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0].summary, "src update"); assert_eq!(commits[1].summary, "src initial"); } }