use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, env, ffi::{OsStr, OsString}, fs, path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, }, thread, time::Duration, }; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; #[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, pub rebase_in_progress: bool, pub cherry_pick_in_progress: bool, pub merge_in_progress: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitRemote { pub name: String, pub fetch_url: String, pub push_url: String, } #[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 GitWorktree { pub path: String, pub head: Option, pub short_head: Option, pub branch: Option, pub bare: bool, pub detached: bool, pub locked: bool, pub lock_reason: Option, pub prunable: bool, pub prune_reason: Option, pub missing: bool, pub is_main: bool, pub is_current: bool, pub clean: bool, pub changed_files: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitTag { pub name: String, pub hash: String, pub short_hash: String, pub message: Option, pub date: String, pub annotated: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitStash { pub selector: String, pub index: u32, pub hash: String, pub branch: Option, pub message: String, pub date: String, } #[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 parents: 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 GitDiffFile { pub path: String, pub old_path: Option, pub status: FileStatusKind, pub additions: u32, pub deletions: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitCommitComparison { pub from_hash: String, pub from_short: String, pub to_hash: String, pub to_short: String, pub files: Vec, pub patch: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitBlameLine { pub line_number: u32, pub content: String, pub commit_hash: String, pub short_hash: String, pub author_name: String, pub author_email: String, pub author_time: i64, pub summary: String, pub is_uncommitted: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitBlameResult { pub path: String, pub lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ConflictFile { pub path: String, pub content: String, pub ours: Option, pub theirs: Option, pub base: Option, pub binary: bool, pub ours_size: Option, pub theirs_size: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitRepositoryFile { pub path: String, pub tracked: bool, pub status: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitSearchHit { pub commit_hash: String, pub short_hash: String, pub summary: String, pub author_name: String, pub author_email: String, pub date: String, pub file: String, pub old_file: Option, pub line_number: Option, pub line: String, pub matches_added: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RebaseCommit { pub hash: String, pub short_hash: String, pub summary: String, pub author_name: String, pub date: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RebaseAction { Pick, Reword, Squash, Fixup, Drop, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct RebasePlanItem { pub hash: String, pub action: RebaseAction, pub message: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ReflogEntry { pub hash: String, pub short_hash: String, pub selector: String, pub action: String, pub author_name: String, pub date: String, } const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN"; const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE"; const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo"; const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages"; const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor"; const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor"; pub fn run_sequence_editor_if_requested() -> Option> { let executable = env::current_exe().ok()?; let executable_name = executable.file_name()?.to_string_lossy(); let target = env::args_os() .nth(1) .ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string()); if executable_name.contains("gitty-sequence-editor") { let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?; return Some(target.and_then(|target| { fs::copy(PathBuf::from(plan), PathBuf::from(target)) .map(|_| ()) .map_err(|err| format!("Could not write the interactive rebase plan: {err}")) })); } if executable_name.contains("gitty-commit-editor") { let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?; return Some( target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))), ); } None } fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> { let queue = fs::read_to_string(queue_path) .map_err(|err| format!("Could not read the reword queue: {err}"))?; if queue.is_empty() { return Ok(()); } let current = fs::read_to_string(message_path) .map_err(|err| format!("Could not read the commit message: {err}"))?; let current_subject = current .lines() .map(str::trim) .find(|line| !line.is_empty() && !line.starts_with('#')) .unwrap_or(""); let mut remaining = Vec::new(); let mut replacement = None; for record in queue.split('\x1e').filter(|record| !record.is_empty()) { let Some((old, new)) = record.split_once('\x1f') else { return Err("The reword queue is malformed.".to_string()); }; if replacement.is_none() && old == current_subject { replacement = Some(new.to_string()); } else { remaining.push(record); } } if let Some(message) = replacement { fs::write(message_path, format!("{message}\n")) .map_err(|err| format!("Could not update the commit message: {err}"))?; let mut next_queue = remaining.join("\x1e"); if !next_queue.is_empty() { next_queue.push('\x1e'); } fs::write(queue_path, next_queue) .map_err(|err| format!("Could not update the reword queue: {err}"))?; } Ok(()) } #[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), } const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; const SEARCH_CANCELLED_MESSAGE: &str = "Search was cancelled."; static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0); fn git_command() -> Command { let mut command = Command::new("git"); // Force English output regardless of the system locale, so is_auth_error() // and other message heuristics keep working (e.g. German git prints // "Authentifizierung fehlgeschlagen" instead of "Authentication failed"). command.env("LC_ALL", "C"); #[cfg(windows)] command.creation_flags(CREATE_NO_WINDOW); command } #[derive(Debug, Default, Clone)] pub struct SearchCancellationState { cancelled: Arc>>, } impl SearchCancellationState { fn cancel(&self, search_id: &str) -> Result<(), String> { self.cancelled .lock() .map_err(|_| "Search cancellation status is unavailable.".to_string())? .insert(search_id.to_string()); Ok(()) } fn clear(&self, search_id: &str) -> Result<(), String> { self.cancelled .lock() .map_err(|_| "Search cancellation status is unavailable.".to_string())? .remove(search_id); Ok(()) } fn is_cancelled(&self, search_id: &str) -> Result { Ok(self .cancelled .lock() .map_err(|_| "Search cancellation status is unavailable.".to_string())? .contains(search_id)) } } #[derive(Clone)] struct SearchCancellation { state: SearchCancellationState, search_id: String, } fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> { if let Some(cancellation) = cancellation { if cancellation.state.is_cancelled(&cancellation.search_id)? { return Err(SEARCH_CANCELLED_MESSAGE.to_string()); } } Ok(()) } #[tauri::command] pub fn open_repository(path: String) -> Result { let repo = resolve_repo(&path)?; status_for_repo(&repo) } #[tauri::command] pub fn init_repository(path: String, initial_branch: Option) -> Result { let path = PathBuf::from(path.trim()); if path.as_os_str().is_empty() { return Err("Repository path must not be empty.".to_string()); } fs::create_dir_all(&path) .map_err(|err| format!("Could not create repository folder: {err}"))?; let branch = initial_branch.unwrap_or_else(|| "main".to_string()); let branch = branch.trim(); if branch.is_empty() { return Err("Initial branch must not be empty.".to_string()); } let output = git_command() .arg("-C") .arg(&path) .args(["init", "-b", branch]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if !output.status.success() { return Err(format!( "Could not initialize repository: {}", command_output_details(&output) )); } status_for_repo(&path) } #[tauri::command] pub fn open_repo_in_explorer(path: String) -> Result<(), String> { let repo = resolve_repo(&path)?; open_path_in_file_manager(&repo) } #[tauri::command] pub fn open_repository_file(path: String, file: String) -> Result<(), String> { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let file_path = resolve_repo_child_path(&repo, &file)?; if !file_path.exists() { return Err(format!("File '{file}' does not exist in the working tree.")); } if !file_path.is_file() { return Err(format!("'{file}' is not a file.")); } reveal_path_in_file_manager(&file_path) } #[derive(Debug, Clone, Serialize)] pub struct RepositoryBundle { pub status: GitStatus, pub branches: Vec, pub tags: Vec, pub stashes: Vec, pub commits: Vec, pub files: Vec, } #[tauri::command] pub async fn clone_repository( remote_url: String, parent_path: String, directory_name: Option, username: Option, password: Option, commit_limit: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { clone_repository_core( &remote_url, &parent_path, directory_name.as_deref(), username.as_deref(), password.as_deref(), commit_limit, ) }) .await .map_err(|err| format!("Could not clone repository: {err}"))? } /// Opens a repository and gathers everything the UI needs in a single call. /// /// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves /// the repo and its status only once, instead of the previous four separate /// commands that each re-ran `git rev-parse` and `git status`. #[tauri::command] pub async fn open_repository_bundle( path: String, commit_limit: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; let branches = branches_for_repo(&repo)?; let tags = tags_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?; let commits = commits_for_repo(&repo, commit_limit)?; let files = repository_files_with_status(&repo, &status)?; Ok(RepositoryBundle { status, branches, tags, stashes, commits, files, }) }) .await .map_err(|err| format!("Could not load repository: {err}"))? } #[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)?; branches_for_repo(&repo) } #[tauri::command] pub fn list_remotes(path: String) -> Result, String> { log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}"); let repo = resolve_repo(&path)?; let names = run_git(&repo, ["remote"])?; Ok(String::from_utf8_lossy(&names) .lines() .filter_map(|line| { let name = line.trim(); if name.is_empty() { return None; } Some(GitRemote { name: name.to_string(), fetch_url: remote_url_for(&repo, name).unwrap_or_default(), push_url: remote_push_url_for(&repo, name).unwrap_or_default(), }) }) .collect()) } #[tauri::command] pub fn add_remote(path: String, name: String, url: String) -> Result, String> { let repo = resolve_repo(&path)?; let name = validate_remote_name(&repo, &name, false)?; let url = validate_remote_url(&url)?; run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?; list_remotes(path) } #[tauri::command] pub fn update_remote(path: String, name: String, url: String) -> Result, String> { let repo = resolve_repo(&path)?; let name = validate_remote_name(&repo, &name, true)?; let url = validate_remote_url(&url)?; run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?; list_remotes(path) } #[tauri::command] pub fn remove_remote(path: String, name: String) -> Result, String> { log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}"); let result = (|| { let repo = resolve_repo(&path)?; log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display()); let name = validate_remote_name(&repo, &name, true)?; log::info!(target: "gitty::remote", "remove_remote validated remote: {name}"); run_git(&repo, ["remote", "remove", name.as_str()])?; log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}"); list_remotes(path) })(); match &result { Ok(remotes) => { log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::>()) } Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"), } result } #[tauri::command] pub fn set_branch_upstream( path: String, branch: String, upstream: Option, ) -> Result { let repo = resolve_repo(&path)?; let branch = validate_existing_local_branch_name(&repo, &branch)?; match upstream .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) { Some(upstream) => { if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? { return Err(format!("Remote branch '{upstream}' was not found.")); } run_git( &repo, [ "branch", "--set-upstream-to", upstream.as_str(), branch.as_str(), ], )?; } None => { run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?; } } status_for_repo(&repo) } #[tauri::command] pub fn delete_remote_branch( path: String, remote: String, branch: String, ) -> Result { log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}"); let result = (|| { let repo = resolve_repo(&path)?; let remote = validate_remote_name(&repo, &remote, true)?; let branch = branch.trim(); if branch.is_empty() || branch.starts_with('-') { return Err("Invalid remote branch name.".to_string()); } run_git(&repo, ["check-ref-format", "--branch", branch])?; log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}"); run_git(&repo, ["push", remote.as_str(), "--delete", branch])?; log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}"); status_for_repo(&repo) })(); if let Err(error) = &result { log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}"); } result } #[tauri::command] pub fn list_stashes(path: String) -> Result, String> { let repo = resolve_repo(&path)?; stashes_for_repo(&repo) } #[tauri::command] pub fn list_tags(path: String) -> Result, String> { let repo = resolve_repo(&path)?; tags_for_repo(&repo) } fn tags_for_repo(repo: &Path) -> Result, String> { let output = run_git( repo, [ "for-each-ref", "--sort=-creatordate", "refs/tags", "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)", ], )?; let text = String::from_utf8_lossy(&output); let mut tags = Vec::new(); for line in text.lines() { let mut parts = line.splitn(7, '\0'); let name = parts.next().unwrap_or_default().trim(); let object_hash = parts.next().unwrap_or_default().trim(); let object_short = parts.next().unwrap_or_default().trim(); let deref_hash = parts.next().unwrap_or_default().trim(); let deref_short = parts.next().unwrap_or_default().trim(); let subject = parts.next().unwrap_or_default().trim(); let date = parts.next().unwrap_or_default().trim(); if name.is_empty() { continue; } // Annotated tags are their own object with a `taggerdate`/subject and // dereference (`*...`) to the commit they point at; lightweight tags // point straight at the commit, so the dereferenced fields are empty. let annotated = !deref_hash.is_empty(); let hash = if annotated { deref_hash } else { object_hash }; let short_hash = if annotated { deref_short } else { object_short }; tags.push(GitTag { name: name.to_string(), hash: hash.to_string(), short_hash: short_hash.to_string(), message: if annotated && !subject.is_empty() { Some(subject.to_string()) } else { None }, date: date.to_string(), annotated, }); } Ok(tags) } fn branches_for_repo(repo: &Path) -> Result, String> { 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) } fn stashes_for_repo(repo: &Path) -> Result, String> { let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?; let text = String::from_utf8_lossy(&output); let mut stashes = Vec::new(); for line in text.lines() { let mut parts = line.splitn(4, '\0'); let selector = parts.next().unwrap_or_default().trim(); let hash = parts.next().unwrap_or_default().trim(); let date = parts.next().unwrap_or_default().trim(); let subject = parts.next().unwrap_or_default().trim(); if selector.is_empty() { continue; } let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32); let (branch, message) = parse_stash_subject(subject); stashes.push(GitStash { selector: selector.to_string(), index, hash: hash.to_string(), branch, message, date: date.to_string(), }); } Ok(stashes) } fn stash_index_from_selector(selector: &str) -> Option { selector .strip_prefix("stash@{") .and_then(|value| value.strip_suffix('}')) .and_then(|value| value.parse::().ok()) } fn parse_stash_subject(subject: &str) -> (Option, String) { for prefix in ["WIP on ", "On "] { if let Some(value) = subject.strip_prefix(prefix) { if let Some((branch, rest)) = value.split_once(": ") { let message = if prefix == "WIP on " { rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest) } else { rest }; return (Some(branch.to_string()), message.to_string()); } } } (None, subject.to_string()) } #[tauri::command] pub fn stash_push( path: String, message: Option, include_untracked: bool, ) -> Result { let repo = resolve_repo(&path)?; let trimmed_message = message.unwrap_or_default().trim().to_string(); let mut args: Vec = vec![OsString::from("stash"), OsString::from("push")]; if include_untracked { args.push(OsString::from("--include-untracked")); } if !trimmed_message.is_empty() { args.push(OsString::from("-m")); args.push(OsString::from(trimmed_message)); } run_git(&repo, args)?; status_for_repo(&repo) } #[tauri::command] pub fn stash_apply(path: String, selector: String) -> Result { run_stash_update(path, "apply", selector) } #[tauri::command] pub fn stash_pop(path: String, selector: String) -> Result { run_stash_update(path, "pop", selector) } #[tauri::command] pub fn stash_drop(path: String, selector: String) -> Result { run_stash_update(path, "drop", selector) } fn run_stash_update(path: String, action: &str, selector: String) -> Result { let repo = resolve_repo(&path)?; let selector = validate_stash_selector(&selector)?; let output = git_command() .arg("-C") .arg(&repo) .args(["stash", action, selector.as_str()]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if output.status.success() { return status_for_repo(&repo); } let status = status_for_repo(&repo)?; if has_unresolved_conflicts(&status) { return Ok(status); } Err(format!( "Git command failed: {}", command_output_details(&output) )) } fn validate_stash_selector(selector: &str) -> Result { let selector = selector.trim(); let Some(index) = stash_index_from_selector(selector) else { return Err("Invalid stash selector.".to_string()); }; Ok(format!("stash@{{{index}}}")) } #[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 must not be empty.".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 create_branch( path: String, branch: String, start_point: Option, ) -> Result { let repo = resolve_repo(&path)?; let branch = validate_new_branch_name(&repo, &branch)?; match start_point { Some(start) if !start.trim().is_empty() => { // Resolve the requested commit first so we fail clearly if it is gone. let start = verify_commit(&repo, &start)?; run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?; } _ => { run_git(&repo, ["checkout", "-b", branch.as_str()])?; } } status_for_repo(&repo) } #[tauri::command] pub fn rename_branch( path: String, old_branch: String, new_branch: String, ) -> Result { let repo = resolve_repo(&path)?; let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?; let new_branch = validate_new_branch_name(&repo, &new_branch)?; run_git( &repo, [ "branch", "-m", "--", old_branch.as_str(), new_branch.as_str(), ], )?; status_for_repo(&repo) } #[tauri::command] pub fn delete_branch( path: String, branch: String, force: Option, ) -> Result { let repo = resolve_repo(&path)?; let branch = validate_existing_local_branch_name(&repo, &branch)?; let status = status_for_repo(&repo)?; if status.current_branch.as_deref() == Some(branch.as_str()) { return Err("The current branch cannot be deleted.".to_string()); } let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" }; run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?; status_for_repo(&repo) } fn worktree_path_matches(left: &Path, right: &Path) -> bool { match (fs::canonicalize(left), fs::canonicalize(right)) { (Ok(left), Ok(right)) => left == right, _ => left == right, } } fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec { #[derive(Default)] struct Record { path: Option, head: Option, branch: Option, bare: bool, detached: bool, locked: bool, lock_reason: Option, prunable: bool, prune_reason: Option, } fn finish_record(rows: &mut Vec, record: &mut Record, current_repo: &Path) { let Some(path) = record.path.take() else { *record = Record::default(); return; }; let path_buf = PathBuf::from(&path); let missing = !path_buf.exists(); let is_current = worktree_path_matches(&path_buf, current_repo); let head = record.head.take(); let short_head = head .as_ref() .map(|value| value.chars().take(8).collect::()); rows.push(GitWorktree { path, head, short_head, branch: record.branch.take(), bare: record.bare, detached: record.detached, locked: record.locked, lock_reason: record.lock_reason.take(), prunable: record.prunable, prune_reason: record.prune_reason.take(), missing, is_main: rows.is_empty(), is_current, clean: true, changed_files: 0, }); *record = Record::default(); } let mut rows = Vec::new(); let mut record = Record::default(); for field in output.split(|byte| *byte == 0) { if field.is_empty() { finish_record(&mut rows, &mut record, current_repo); continue; } let value = String::from_utf8_lossy(field); let (key, detail) = value .split_once(' ') .map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail))); match key { "worktree" => record.path = detail.map(str::to_string), "HEAD" => record.head = detail.map(str::to_string), "branch" => { record.branch = detail.map(|branch| { branch .strip_prefix("refs/heads/") .unwrap_or(branch) .to_string() }) } "bare" => record.bare = true, "detached" => record.detached = true, "locked" => { record.locked = true; record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); } "prunable" => { record.prunable = true; record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); } _ => {} } } finish_record(&mut rows, &mut record, current_repo); rows } fn worktrees_for_repo(repo: &Path) -> Result, String> { let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?; let mut worktrees = parse_worktree_porcelain(&output, repo); for worktree in &mut worktrees { if worktree.missing { worktree.clean = false; continue; } if worktree.bare { continue; } match run_git_at( Path::new(&worktree.path), ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], "Could not inspect worktree status", ) { Ok(status) => { worktree.changed_files = status .split(|byte| *byte == 0) .filter(|entry| entry.len() >= 3 && entry[2] == b' ') .count() as u32; worktree.clean = worktree.changed_files == 0; } Err(_) => { worktree.clean = false; } } } Ok(worktrees) } #[tauri::command] pub fn list_worktrees(path: String) -> Result, String> { let repo = resolve_repo(&path)?; worktrees_for_repo(&repo) } #[tauri::command] pub fn add_worktree( path: String, worktree_path: String, branch: Option, new_branch: Option, start_point: Option, detached: Option, lock: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; let destination = worktree_path.trim(); if destination.is_empty() { return Err("Choose a folder for the new worktree.".to_string()); } let branch = branch .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let new_branch = new_branch .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); if branch.is_some() && new_branch.is_some() { return Err("Choose either an existing branch or a new branch.".to_string()); } let mut args = vec![OsString::from("worktree"), OsString::from("add")]; if lock.unwrap_or(false) { args.push(OsString::from("--lock")); } if detached.unwrap_or(false) { args.push(OsString::from("--detach")); } let target = if let Some(new_branch) = new_branch { let new_branch = validate_new_branch_name(&repo, &new_branch)?; args.push(OsString::from("-b")); args.push(OsString::from(new_branch)); start_point .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| verify_commit(&repo, value)) .transpose()? } else if let Some(branch) = branch { Some(validate_existing_local_branch_name(&repo, &branch)?) } else { start_point .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| verify_commit(&repo, value)) .transpose()? }; args.push(OsString::from(destination)); if let Some(target) = target { args.push(OsString::from(target)); } run_git(&repo, args)?; worktrees_for_repo(&repo) } #[tauri::command] pub fn remove_worktree( path: String, worktree_path: String, force: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; let worktrees = worktrees_for_repo(&repo)?; let target = worktrees .iter() .find(|worktree| { worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path)) }) .ok_or_else(|| "The selected worktree is no longer registered.".to_string())?; if target.is_main { return Err("The main worktree cannot be removed.".to_string()); } if target.is_current { return Err("The currently open worktree cannot be removed.".to_string()); } if target.locked { return Err("Unlock this worktree before removing it.".to_string()); } let mut args = vec![OsString::from("worktree"), OsString::from("remove")]; if force.unwrap_or(false) { args.push(OsString::from("--force")); } args.push(OsString::from(worktree_path)); run_git(&repo, args)?; worktrees_for_repo(&repo) } #[tauri::command] pub fn move_worktree( path: String, worktree_path: String, destination: String, ) -> Result, String> { let repo = resolve_repo(&path)?; let destination = destination.trim(); if destination.is_empty() { return Err("Choose a new location for the worktree.".to_string()); } run_git( &repo, [ OsString::from("worktree"), OsString::from("move"), OsString::from(worktree_path), OsString::from(destination), ], )?; worktrees_for_repo(&repo) } #[tauri::command] pub fn lock_worktree( path: String, worktree_path: String, reason: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; let mut args = vec![OsString::from("worktree"), OsString::from("lock")]; if let Some(reason) = reason .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) { args.push(OsString::from("--reason")); args.push(OsString::from(reason)); } args.push(OsString::from(worktree_path)); run_git(&repo, args)?; worktrees_for_repo(&repo) } #[tauri::command] pub fn unlock_worktree(path: String, worktree_path: String) -> Result, String> { let repo = resolve_repo(&path)?; run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?; worktrees_for_repo(&repo) } #[tauri::command] pub fn prune_worktrees(path: String) -> Result, String> { let repo = resolve_repo(&path)?; run_git(&repo, ["worktree", "prune"])?; worktrees_for_repo(&repo) } #[tauri::command] pub fn repair_worktree(path: String, worktree_path: String) -> Result, String> { let repo = resolve_repo(&path)?; run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?; worktrees_for_repo(&repo) } #[tauri::command] pub fn create_tag( path: String, name: String, target: Option, message: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; let name = validate_new_tag_name(&repo, &name)?; let target = match target { Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?, _ => verify_commit(&repo, "HEAD")?, }; let message = message .map(|message| message.trim().to_string()) .filter(|message| !message.is_empty()); match message { Some(message) => { run_git( &repo, [ "tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str(), ], )?; } None => { run_git(&repo, ["tag", name.as_str(), target.as_str()])?; } } tags_for_repo(&repo) } #[tauri::command] pub fn delete_tag(path: String, name: String) -> Result, String> { let repo = resolve_repo(&path)?; let name = validate_existing_tag_name(&repo, &name)?; run_git(&repo, ["tag", "-d", name.as_str()])?; tags_for_repo(&repo) } #[tauri::command] pub fn push_tag( path: String, name: String, username: Option, password: Option, ) -> Result<(), String> { let repo = resolve_repo(&path)?; let name = validate_existing_tag_name(&repo, &name)?; let remote = initial_push_remote_name(&repo)?; let tag_ref = format!("refs/tags/{name}"); let push_args = ["push", remote.as_str(), tag_ref.as_str()]; match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated(&repo, push_args, u, p)?; } _ => { run_git(&repo, push_args)?; } } Ok(()) } fn validate_new_tag_name(repo: &Path, name: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err("Tag name must not be empty.".to_string()); } let normalized = validate_tag_ref_name(name)?; if ref_exists(repo, &format!("refs/tags/{normalized}"))? { return Err(format!("Tag '{normalized}' already exists.")); } Ok(normalized) } fn validate_existing_tag_name(repo: &Path, name: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err("Tag name must not be empty.".to_string()); } let normalized = validate_tag_ref_name(name)?; if !ref_exists(repo, &format!("refs/tags/{normalized}"))? { return Err(format!("Tag '{normalized}' was not found.")); } Ok(normalized) } fn validate_tag_ref_name(name: &str) -> Result { let output = git_command() .args([ "check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}"), ]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if !output.status.success() { let details = command_output_details(&output); return Err(format!("Invalid tag name: {details}")); } let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); let normalized = normalized .strip_prefix("refs/tags/") .map(str::to_string) .unwrap_or(normalized); Ok(if normalized.is_empty() { name.to_string() } else { normalized }) } #[tauri::command] pub async fn stage_files(path: String, files: Vec) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; if !files.is_empty() { // A file we've detected as an unstaged rename (see `detect_worktree_renames`) has // to be staged with both its old and new path so `git add` records it as a rename // instead of leaving the old path's deletion unstaged. let current_status = status_for_repo(&repo)?; let mut add_paths: Vec = Vec::new(); for file in &files { match find_status(¤t_status.files, file) { Some(entry) => { if let Some(old_path) = &entry.old_path { add_paths.push(old_path.clone()); } add_paths.push(entry.path.clone()); } None => add_paths.push(file.clone()), } } run_git_with_paths(&repo, &["add"], &add_paths)?; } status_for_repo(&repo) }) .await .map_err(|err| format!("Could not stage files: {err}"))? } #[tauri::command] pub async fn unstage_files(path: String, files: Vec) -> Result { tauri::async_runtime::spawn_blocking(move || -> 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) }) .await .map_err(|err| format!("Could not unstage files: {err}"))? } #[tauri::command] pub async fn restore_files( path: String, files: Vec, staged: bool, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> 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) }) .await .map_err(|err| format!("Could not restore files: {err}"))? } #[tauri::command] pub fn get_file_patch(path: String, file: String, staged: bool) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let base_args = if staged { &[ "diff", "--cached", "--no-ext-diff", "--no-textconv", "--unified=3", ][..] } else { &["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..] }; let output = run_git_with_paths(&repo, base_args, &[file])?; Ok(String::from_utf8_lossy(&output).to_string()) } #[tauri::command] pub fn commit_ai_local_models() -> Vec { commit_ai::LOCAL_MODELS.to_vec() } #[tauri::command] pub async fn commit_ai_status( engine: tauri::State<'_, commit_ai::CommitAiEngine>, ) -> Result { Ok(engine.status().await) } /// Kicks off the (first-run-only) download and model load in the background and returns /// immediately; the frontend polls `commit_ai_status` to know when it's ready. #[tauri::command] pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) { let engine = engine.inner().clone(); tauri::async_runtime::spawn(async move { engine.ensure_loaded(&model_id).await; }); } // The prompt is built from `git diff --cached` only, i.e. exactly the staged changes — // unstaged edits and untracked files never influence the generated message. fn staged_diff(repo: &Path) -> Result { // Full staged file list (nothing excluded) so the model knows the complete scope // even when the detailed diff below is filtered or truncated for context size. let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?; let file_list = String::from_utf8_lossy(&name_status).trim().to_string(); // Generated lockfiles say nothing useful about intent but easily blow the small // context window of local models, so keep them out of the detailed diff. let diff = run_git( repo, [ "diff", "--cached", "--no-ext-diff", "--no-textconv", "--unified=3", "--", ".", ":(exclude)*package-lock.json", ":(exclude)*pnpm-lock.yaml", ":(exclude)*yarn.lock", ":(exclude)*bun.lockb", ":(exclude)*Cargo.lock", ":(exclude)*composer.lock", ":(exclude)*Gemfile.lock", ":(exclude)*poetry.lock", ":(exclude)*go.sum", ], )?; let diff = String::from_utf8_lossy(&diff); if file_list.is_empty() { return Ok(diff.to_string()); } Ok(format!("Staged files:\n{file_list}\n\n{diff}")) } fn staged_diff_local( repo: &Path, profile: commit_ai::LocalGenerationProfile, ) -> Result { let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?; let file_list = String::from_utf8_lossy(&name_status).trim().to_string(); let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?; let stat = String::from_utf8_lossy(&stat).trim().to_string(); let diff_args = vec![ "diff", "--cached", "--no-ext-diff", "--no-textconv", profile.diff_unified_context(), "--", ".", ":(exclude)*package-lock.json", ":(exclude)*pnpm-lock.yaml", ":(exclude)*yarn.lock", ":(exclude)*bun.lockb", ":(exclude)*Cargo.lock", ":(exclude)*composer.lock", ":(exclude)*Gemfile.lock", ":(exclude)*poetry.lock", ":(exclude)*go.sum", ]; let diff = run_git(repo, diff_args)?; let diff = String::from_utf8_lossy(&diff).trim().to_string(); let mut sections = Vec::new(); if !file_list.is_empty() { sections.push(format!("Staged files:\n{file_list}")); } if !stat.is_empty() { sections.push(format!("Diff stat:\n{stat}")); } if !diff.is_empty() { sections.push(format!("Detailed diff:\n{diff}")); } Ok(sections.join("\n\n")) } #[tauri::command] pub async fn commit_ai_generate( path: String, notes: Option, provider: String, model: Option, api_key: Option, base_url: Option, local_profile: Option, engine: tauri::State<'_, commit_ai::CommitAiEngine>, ) -> Result { let repo = resolve_repo(&path)?; let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref()); let diff = if provider == "local" { staged_diff_local(&repo, local_profile)? } else { staged_diff(&repo)? }; let notes = notes.as_deref(); let model = model.filter(|value| !value.trim().is_empty()); let api_key = api_key.filter(|value| !value.trim().is_empty()); let base_url = base_url.filter(|value| !value.trim().is_empty()); match provider.as_str() { "local" => { engine .generate_commit_message(&diff, notes, local_profile) .await } "openai" => { let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?; let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); commit_ai::generate_openai(&api_key, &model, &diff, notes).await } "anthropic" => { let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?; let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string()); commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await } "custom" => { let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?; let model = model.ok_or_else(|| "Model name is missing.".to_string())?; commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await } other => Err(format!("Unknown AI provider: {other}")), } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum AiReviewRisk { Low, Medium, High, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum AiReviewSeverity { Critical, Warning, Info, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AiReviewFinding { pub severity: AiReviewSeverity, pub title: String, pub description: String, pub file: Option, pub line: Option, pub suggestion: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AiReviewResult { pub summary: String, pub risk: AiReviewRisk, pub findings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiCommitGroup { pub message: String, pub reason: String, pub files: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiCommitPlan { pub summary: String, pub groups: Vec, } fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result { use std::collections::HashSet; let trimmed = raw.trim().trim_matches('`').trim(); let json = match (trimmed.find('{'), trimmed.rfind('}')) { (Some(start), Some(end)) if start <= end => &trimmed[start..=end], _ => return Err("The AI response did not contain a valid commit plan.".to_string()), }; let mut plan: AiCommitPlan = serde_json::from_str(json) .map_err(|error| format!("Could not process the commit plan: {error}"))?; plan.groups .retain(|group| !group.message.trim().is_empty() && !group.files.is_empty()); if plan.groups.len() < 2 { return Err("The staged changes do not appear to benefit from splitting.".to_string()); } if plan.groups.len() > 12 { return Err("The AI proposed too many commit groups.".to_string()); } let expected = staged_files.iter().cloned().collect::>(); let mut seen = HashSet::new(); for group in &mut plan.groups { group.message = group.message.trim().to_string(); group.reason = group.reason.trim().to_string(); group .files .retain(|file| expected.contains(file) && seen.insert(file.clone())); if group.files.is_empty() { return Err("The AI returned an empty or duplicate commit group.".to_string()); } } if seen != expected { return Err("The AI plan did not assign every staged file exactly once.".to_string()); } plan.summary = plan.summary.trim().to_string(); Ok(plan) } #[tauri::command] pub async fn commit_ai_split( path: String, provider: String, model: Option, api_key: Option, base_url: Option, ) -> Result { let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; let staged_files = status .files .iter() .filter(|file| file.staged.is_some()) .map(|file| file.path.clone()) .collect::>(); if staged_files.len() < 2 { return Err("Stage at least two files before creating a split plan.".to_string()); } if status .files .iter() .any(|file| file.staged.is_some() && file.unstaged.is_some()) { return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string()); } let diff = staged_diff(&repo)?; let model = model.filter(|value| !value.trim().is_empty()); let api_key = api_key.filter(|value| !value.trim().is_empty()); let base_url = base_url.filter(|value| !value.trim().is_empty()); let raw = match provider.as_str() { "openai" => { commit_ai::split_openai( api_key .as_deref() .ok_or_else(|| "OpenAI API key is missing.".to_string())?, model.as_deref().unwrap_or("gpt-4o-mini"), &diff, ) .await? } "anthropic" => { commit_ai::split_anthropic( api_key .as_deref() .ok_or_else(|| "Anthropic API key is missing.".to_string())?, model.as_deref().unwrap_or("claude-3-5-haiku-latest"), &diff, ) .await? } "custom" => { commit_ai::split_custom( base_url .as_deref() .ok_or_else(|| "Endpoint URL is missing.".to_string())?, api_key.as_deref(), model .as_deref() .ok_or_else(|| "Model name is missing.".to_string())?, &diff, ) .await? } "local" => return Err("Commit splitting currently requires an API provider.".to_string()), other => return Err(format!("Unknown AI provider: {other}")), }; parse_ai_commit_plan(&raw, &staged_files) } #[derive(Deserialize)] struct AiReviewWireFinding { severity: String, title: String, description: String, file: Option, line: Option, suggestion: String, } #[derive(Deserialize)] struct AiReviewWireResult { summary: String, risk: String, #[serde(default)] findings: Vec, } fn parse_ai_review(raw: &str) -> Result { let trimmed = raw.trim().trim_matches('`').trim(); let json = match (trimmed.find('{'), trimmed.rfind('}')) { (Some(start), Some(end)) if start <= end => &trimmed[start..=end], _ => return Err("The AI review did not contain valid JSON.".to_string()), }; let wire: AiReviewWireResult = serde_json::from_str(json) .map_err(|error| format!("Could not process the AI review: {error}"))?; let risk = match wire.risk.trim().to_ascii_lowercase().as_str() { "high" => AiReviewRisk::High, "medium" => AiReviewRisk::Medium, _ => AiReviewRisk::Low, }; let mut findings = wire .findings .into_iter() .filter(|finding| { !finding.title.trim().is_empty() && !finding.description.trim().is_empty() }) .map(|finding| AiReviewFinding { severity: match finding.severity.trim().to_ascii_lowercase().as_str() { "critical" | "error" | "high" => AiReviewSeverity::Critical, "warning" | "warn" | "medium" => AiReviewSeverity::Warning, _ => AiReviewSeverity::Info, }, title: finding.title.trim().to_string(), description: finding.description.trim().to_string(), file: finding.file.filter(|file| !file.trim().is_empty()), line: finding.line, suggestion: finding.suggestion.trim().to_string(), }) .collect::>(); findings.sort_by_key(|finding| match finding.severity { AiReviewSeverity::Critical => 0, AiReviewSeverity::Warning => 1, AiReviewSeverity::Info => 2, }); findings.truncate(12); Ok(AiReviewResult { summary: if wire.summary.trim().is_empty() { "Review completed.".to_string() } else { wire.summary.trim().to_string() }, risk, findings, }) } #[tauri::command] pub async fn commit_ai_review( path: String, provider: String, model: Option, api_key: Option, base_url: Option, ) -> Result { let repo = resolve_repo(&path)?; let diff = staged_diff(&repo)?; let model = model.filter(|value| !value.trim().is_empty()); let api_key = api_key.filter(|value| !value.trim().is_empty()); let base_url = base_url.filter(|value| !value.trim().is_empty()); let raw = match provider.as_str() { "openai" => { let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?; let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); commit_ai::review_openai(&api_key, &model, &diff).await? } "anthropic" => { let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?; let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string()); commit_ai::review_anthropic(&api_key, &model, &diff).await? } "custom" => { let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?; let model = model.ok_or_else(|| "Model name is missing.".to_string())?; commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await? } "local" => return Err("Pre-commit review currently requires an API provider.".to_string()), other => return Err(format!("Unknown AI provider: {other}")), }; parse_ai_review(&raw) } #[tauri::command] pub fn apply_file_patch( path: String, file: String, patch: String, action: String, ) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; if patch.trim().is_empty() { return Err("No patch selected.".to_string()); } let patch_path = write_temp_patch(&patch)?; let result = match action.as_str() { "stage" => check_apply_patch(&repo, &patch_path, &["--cached"]) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])), "unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])), "discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"]) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])), "discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]) .and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"])) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])) .and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])), _ => Err("Invalid patch action.".to_string()), }; let _ = std::fs::remove_file(&patch_path); result?; status_for_repo(&repo) } #[tauri::command] pub async fn commit(path: String, message: String) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; if message.trim().is_empty() { return Err("Commit message must not be empty.".to_string()); } let current_status = status_for_repo(&repo)?; if has_unresolved_conflicts(¤t_status) { return Err("Merge conflicts must be resolved before you can commit.".to_string()); } run_git(&repo, ["commit", "-m", message.as_str()])?; status_for_repo(&repo) }) .await .map_err(|err| format!("Could not commit: {err}"))? } #[tauri::command] pub async fn amend_commit(path: String, message: Option) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { return Err("There is no commit to amend.".to_string()); } let current_status = status_for_repo(&repo)?; if has_unresolved_conflicts(¤t_status) { return Err("Merge conflicts must be resolved before you can commit.".to_string()); } let message = message .map(|message| message.trim().to_string()) .filter(|message| !message.is_empty()); match message { Some(message) => { run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?; } None => { run_git(&repo, ["commit", "--amend", "--no-edit"])?; } } status_for_repo(&repo) }) .await .map_err(|err| format!("Could not amend commit: {err}"))? } #[tauri::command] pub fn last_commit_message(path: String) -> Result, String> { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { return Ok(None); } let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?; let message = String::from_utf8_lossy(&output).trim_end().to_string(); Ok(if message.is_empty() { None } else { Some(message) }) } #[tauri::command] pub fn undo_last_commit(path: String) -> Result { let repo = resolve_repo(&path)?; if verify_commit(&repo, "HEAD").is_err() { return Err("There is no commit to undo.".to_string()); } if verify_commit(&repo, "HEAD~1").is_err() { return Err("This is the first commit; there is nothing to undo to.".to_string()); } // Mixed reset: moves HEAD back one commit and unstages the difference, but // leaves the working tree files untouched, so the undone commit's changes // reappear as ordinary uncommitted changes instead of being discarded. run_git(&repo, ["reset", "HEAD~1"])?; status_for_repo(&repo) } #[tauri::command] pub async fn pull( path: String, username: Option, password: Option, strategy: Option, remote: Option, branch: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let strategy = strategy.as_deref().unwrap_or("merge"); let mut pull_args = vec![OsString::from("pull")]; match strategy { "merge" => { pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]) } "rebase" => pull_args.push(OsString::from("--rebase")), "ff-only" => pull_args.push(OsString::from("--ff-only")), _ => return Err("Unknown pull strategy.".to_string()), } if let Some(remote) = remote .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) { validate_remote_name(&repo, &remote, true)?; pull_args.push(remote.into()); if let Some(branch) = branch .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) { pull_args.push(branch.into()); } } let output = match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated_output(&repo, pull_args.clone(), u, p)? } _ => git_command() .arg("-C") .arg(&repo) .args(&pull_args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, }; if output.status.success() { return status_for_repo(&repo); } let status = status_for_repo(&repo)?; if has_unresolved_conflicts(&status) { return Ok(status); } let details = command_output_details(&output); if is_auth_error(&details) { return Err(format!("AUTH_FAILED:{details}")); } Err(format!("Git command failed: {details}")) }) .await .map_err(|err| format!("Could not pull: {err}"))? } #[tauri::command] pub async fn fetch( path: String, username: Option, password: Option, prune: Option, remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let mut fetch_args = vec![OsString::from("fetch")]; if prune.unwrap_or(false) { fetch_args.push(OsString::from("--prune")); } if let Some(remote) = remote .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) { validate_remote_name(&repo, &remote, true)?; fetch_args.push(remote.into()); } let output = match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated_output(&repo, fetch_args.clone(), u, p)? } _ => git_command() .arg("-C") .arg(&repo) .args(&fetch_args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, }; if output.status.success() { return status_for_repo(&repo); } let details = command_output_details(&output); if is_auth_error(&details) { return Err(format!("AUTH_FAILED:{details}")); } Err(format!("Git command failed: {details}")) }) .await .map_err(|err| format!("Could not fetch repository: {err}"))? } #[tauri::command] pub async fn push( path: String, username: Option, password: Option, force_with_lease: Option, remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?; if force_with_lease.unwrap_or(false) { push_args.insert(1, OsString::from("--force-with-lease")); } match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated(&repo, push_args, u, p)?; } _ => { run_git(&repo, push_args)?; } } status_for_repo(&repo) }) .await .map_err(|err| format!("Could not push: {err}"))? } // ── Credential storage (OS keychain) ──────────────────────────────────────── const CRED_SERVICE: &str = "tauri_git_lite"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredCredential { pub username: String, pub password: String, #[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")] pub expires_at: Option, } fn cred_entry(key: &str) -> Result { let key = key.trim(); if key.is_empty() { return Err("No key provided for the credentials.".to_string()); } keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}")) } /// Returns the remote URL used for auth key derivation (upstream remote of the /// current branch, falling back to `origin`, then the first configured remote). #[tauri::command] pub fn get_remote_url(path: String) -> Result, String> { let repo = resolve_repo(&path)?; let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()); if let Some(url) = remote_url_for(&repo, &remote) { return Ok(Some(url)); } // origin missing → try the first configured remote if let Some(first) = first_remote_name(&repo) { if first != remote { if let Some(url) = remote_url_for(&repo, &first) { return Ok(Some(url)); } } } Ok(None) } fn remote_url_for(repo: &Path, remote: &str) -> Option { let out = git_command() .arg("-C") .arg(repo) .args(["remote", "get-url", remote]) .output() .ok()?; if !out.status.success() { return None; } let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); if url.is_empty() { None } else { Some(url) } } fn remote_push_url_for(repo: &Path, remote: &str) -> Option { let out = git_command() .arg("-C") .arg(repo) .args(["remote", "get-url", "--push", remote]) .output() .ok()?; if !out.status.success() { return None; } let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); if url.is_empty() { None } else { Some(url) } } fn validate_remote_url(url: &str) -> Result { let url = url.trim(); if url.is_empty() || url.starts_with('-') { return Err("Remote URL must not be empty.".to_string()); } Ok(url.to_string()) } fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result { let name = name.trim(); if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) { return Err("Invalid remote name.".to_string()); } let exists = remote_url_for(repo, name).is_some(); if must_exist && !exists { return Err(format!("Remote '{name}' was not found.")); } if !must_exist && exists { return Err(format!("Remote '{name}' already exists.")); } Ok(name.to_string()) } fn upstream_remote_name(repo: &Path) -> Option { let branch = current_branch_name(repo).ok()?; let out = git_command() .arg("-C") .arg(repo) .args(["config", &format!("branch.{branch}.remote")]) .output() .ok()?; if !out.status.success() { return None; } let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); if name.is_empty() { None } else { Some(name) } } fn first_remote_name(repo: &Path) -> Option { let out = run_git(repo, ["remote"]).ok()?; String::from_utf8_lossy(&out) .lines() .map(str::trim) .find(|line| !line.is_empty()) .map(str::to_string) } fn current_branch_name(repo: &Path) -> Result { let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?; let branch = String::from_utf8_lossy(&branch).trim().to_string(); if branch.is_empty() || branch == "HEAD" { return Err("Could not determine current branch.".to_string()); } Ok(branch) } fn branch_has_upstream(repo: &Path) -> bool { git_command() .arg("-C") .arg(repo) .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) .output() .map(|output| output.status.success()) .unwrap_or(false) } fn initial_push_remote_name(repo: &Path) -> Result { if remote_url_for(repo, "origin").is_some() { return Ok("origin".to_string()); } first_remote_name(repo) .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string()) } #[cfg(test)] fn push_args_for_repo(repo: &Path) -> Result, String> { push_args_for_repo_to(repo, None) } fn push_args_for_repo_to( repo: &Path, requested_remote: Option<&str>, ) -> Result, String> { if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) { let remote = validate_remote_name(repo, remote, true)?; let branch = current_branch_name(repo)?; return Ok(vec![ OsString::from("push"), OsString::from("--set-upstream"), remote.into(), branch.into(), ]); } if branch_has_upstream(repo) { return Ok(vec![OsString::from("push")]); } let branch = current_branch_name(repo)?; let remote = initial_push_remote_name(repo)?; Ok(vec![ OsString::from("push"), OsString::from("--set-upstream"), OsString::from(remote), OsString::from(branch), ]) } #[tauri::command] pub fn cred_load(key: String) -> Result, String> { let entry = cred_entry(&key)?; match entry.get_password() { Ok(json) => { let cred = serde_json::from_str::(&json) .map_err(|err| format!("Stored credentials unreadable: {err}"))?; Ok(Some(cred)) } Err(keyring::Error::NoEntry) => Ok(None), Err(err) => Err(format!("Keychain access failed: {err}")), } } #[tauri::command] pub fn cred_save( key: String, username: String, password: String, expires_at: Option, ) -> Result<(), String> { let entry = cred_entry(&key)?; let expires_at = expires_at.filter(|value| !value.trim().is_empty()); let cred = StoredCredential { username, password, expires_at, }; let json = serde_json::to_string(&cred) .map_err(|err| format!("Could not serialize credentials: {err}"))?; entry .set_password(&json) .map_err(|err| format!("Saving to keychain failed: {err}")) } #[tauri::command] pub fn cred_delete(key: String) -> Result<(), String> { let entry = cred_entry(&key)?; match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(err) => Err(format!("Deleting from keychain failed: {err}")), } } #[tauri::command] pub async fn merge_branch( path: String, branch: String, strategy: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim(); if branch.is_empty() { return Err("Branch name must not be empty.".to_string()); } let mut args = vec!["merge", "--no-edit"]; match strategy.as_deref().unwrap_or("default") { "default" => {} "squash" => args.push("--squash"), "ff-only" => args.push("--ff-only"), "no-ff" => args.push("--no-ff"), _ => return Err("Unknown merge strategy.".to_string()), } args.push(branch); let output = git_command() .arg("-C") .arg(&repo) .args(args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if output.status.success() { return status_for_repo(&repo); } // A merge that stops on conflicts leaves unmerged paths in the work tree. // Surface those through the status so the UI can offer conflict resolution // instead of treating the conflict as a hard error. let status = status_for_repo(&repo)?; if has_unresolved_conflicts(&status) { return Ok(status); } 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 { "unknown error" }; Err(format!("Merge failed: {details}")) }) .await .map_err(|err| format!("Could not merge: {err}"))? } #[tauri::command] pub fn merge_continue(path: String) -> Result { let repo = resolve_repo(&path)?; if !merge_in_progress(&repo) { return Err("No merge is in progress.".to_string()); } if has_unresolved_conflicts(&status_for_repo(&repo)?) { return Err("Resolve all conflicts before continuing the merge.".to_string()); } run_git(&repo, ["commit", "--no-edit"])?; status_for_repo(&repo) } #[tauri::command] pub fn merge_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !merge_in_progress(&repo) { return Err("No merge is in progress.".to_string()); } run_git(&repo, ["merge", "--abort"])?; status_for_repo(&repo) } #[tauri::command] pub fn revert_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; let commit = verify_commit(&repo, &commit)?; let output = git_command() .arg("-C") .arg(&repo) .args(["revert", "--no-edit", commit.as_str()]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if output.status.success() { return status_for_repo(&repo); } let status = status_for_repo(&repo)?; if has_unresolved_conflicts(&status) { return Ok(status); } Err(format!( "Revert failed: {}", command_output_details(&output) )) } #[tauri::command] pub async fn rebase_branch(path: String, branch: String) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let branch = branch.trim(); if branch.is_empty() { return Err("Branch name must not be empty.".to_string()); } let output = git_command() .arg("-C") .arg(&repo) .args(["rebase", branch]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; rebase_status_or_error(&repo, output, "Rebase failed", true) }) .await .map_err(|err| format!("Could not rebase: {err}"))? } #[tauri::command] pub fn list_interactive_rebase_commits( path: String, base: String, ) -> Result, String> { let repo = resolve_repo(&path)?; interactive_rebase_commits_for_repo(&repo, &base) } #[tauri::command] pub async fn start_interactive_rebase( path: String, base: String, plan: Vec, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; if !status.clean { return Err( "Commit or stash working tree changes before starting an interactive rebase." .to_string(), ); } if status.rebase_in_progress || status.cherry_pick_in_progress { return Err( "Finish the current Git operation before starting an interactive rebase." .to_string(), ); } let base_hash = verify_commit(&repo, &base)?; let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?; validate_rebase_plan(&available, &plan)?; let todo = build_rebase_todo(&available, &plan)?; let reword_queue = build_reword_queue(&available, &plan)?; let git_dir = git_dir_for_repo(&repo)?; cleanup_interactive_rebase_helpers(&repo); let todo_path = git_dir.join(REBASE_TODO_FILE); let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE); fs::write(&todo_path, todo) .map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?; fs::write(&reword_queue_path, reword_queue) .map_err(|err| format!("Could not prepare reword messages: {err}"))?; let sequence_helper_path = repo.join(sequence_helper_name()); let commit_helper_path = repo.join(commit_helper_name()); let current_exe = env::current_exe() .map_err(|err| format!("Could not locate the Gitty executable: {err}"))?; fs::copy(¤t_exe, &sequence_helper_path) .and_then(|_| fs::copy(¤t_exe, &commit_helper_path)) .map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?; let sequence_editor_command = format!("./{}", sequence_helper_name()); let commit_editor_command = format!("./{}", commit_helper_name()); let output = git_command() .arg("-C") .arg(&repo) .args(["rebase", "-i", base_hash.as_str()]) .env("GIT_SEQUENCE_EDITOR", sequence_editor_command) .env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path) .env("GIT_EDITOR", commit_editor_command) .env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}")); let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true); let _ = fs::remove_file(&todo_path); let _ = fs::remove_file(&sequence_helper_path); if !matches!(&result, Ok(status) if status.rebase_in_progress) { let _ = fs::remove_file(&reword_queue_path); let _ = fs::remove_file(&commit_helper_path); } result }) .await .map_err(|err| format!("Could not run interactive rebase: {err}"))? } #[tauri::command] pub fn rebase_continue(path: String) -> Result { let repo = resolve_repo(&path)?; if !rebase_in_progress(&repo) { return Err("No rebase is currently in progress.".to_string()); } let git_dir = git_dir_for_repo(&repo)?; let queue_path = git_dir.join(REWORD_QUEUE_FILE); let helper_path = repo.join(commit_helper_name()); let mut command = git_command(); command.arg("-C").arg(&repo).args(["rebase", "--continue"]); if queue_path.exists() && helper_path.exists() { command .env("GIT_EDITOR", format!("./{}", commit_helper_name())) .env(COMMIT_EDITOR_QUEUE_ENV, &queue_path); } else { command.env("GIT_EDITOR", "true"); } let output = command .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false); if !matches!(&result, Ok(status) if status.rebase_in_progress) { cleanup_interactive_rebase_helpers(&repo); } result } #[tauri::command] pub fn rebase_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !rebase_in_progress(&repo) { return Err("No rebase is currently in progress.".to_string()); } run_git(&repo, ["rebase", "--abort"])?; cleanup_interactive_rebase_helpers(&repo); status_for_repo(&repo) } #[tauri::command] pub fn list_reflog(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string(); let output = run_git( &repo, [ "reflog", "show", "--date=iso-strict", "--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e", "-n", limit.as_str(), ], )?; parse_reflog(&output) } #[tauri::command] pub fn restore_reflog_entry( path: String, commit: String, branch: String, ) -> Result { let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; if !status.clean { return Err( "Commit or stash working tree changes before restoring from the reflog.".to_string(), ); } let commit_hash = verify_commit(&repo, &commit)?; let branch = validate_new_branch_name(&repo, &branch)?; run_git( &repo, ["checkout", "-b", branch.as_str(), commit_hash.as_str()], )?; status_for_repo(&repo) } fn interactive_rebase_commits_for_repo( repo: &Path, base: &str, ) -> Result, String> { let base_hash = verify_commit(repo, base)?; let range = format!("{base_hash}..HEAD"); let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?; if !String::from_utf8_lossy(&merges).trim().is_empty() { return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string()); } let output = run_git( repo, [ "log", "--reverse", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e", range.as_str(), ], )?; let mut commits = Vec::new(); for raw in output.split(|byte| *byte == 0x1e) { let record = String::from_utf8_lossy(raw); let record = record.trim_matches(['\r', '\n', ' ']); if record.is_empty() { continue; } let fields = record.split('\x1f').collect::>(); if fields.len() != 5 { return Err("Git returned an unexpected interactive rebase record.".to_string()); } commits.push(RebaseCommit { hash: fields[0].to_string(), short_hash: fields[1].to_string(), summary: fields[2].to_string(), author_name: fields[3].to_string(), date: fields[4].to_string(), }); } Ok(commits) } fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> { if commits.is_empty() { return Err("There are no commits to rebase onto the selected base.".to_string()); } if commits.len() != plan.len() { return Err("The rebase plan must include every commit exactly once.".to_string()); } let expected = commits .iter() .map(|item| item.hash.as_str()) .collect::>(); let actual = plan .iter() .map(|item| item.hash.as_str()) .collect::>(); if actual.len() != plan.len() || actual != expected { return Err( "The rebase plan contains missing, duplicate, or unexpected commits.".to_string(), ); } let mut has_kept_commit = false; for item in plan { match item.action { RebaseAction::Drop => {} RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => { return Err("Squash and fixup need an earlier picked commit.".to_string()); } RebaseAction::Reword => { let message = item.message.as_deref().unwrap_or("").trim(); if message.is_empty() || message.contains(['\r', '\n']) { return Err("Reword messages must be a single non-empty line.".to_string()); } has_kept_commit = true; } _ => has_kept_commit = true, } } if !has_kept_commit { return Err("Keep at least one commit in the interactive rebase plan.".to_string()); } Ok(()) } fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { let by_hash = commits .iter() .map(|commit| (commit.hash.as_str(), commit)) .collect::>(); let mut todo = String::new(); for item in plan { let commit = by_hash .get(item.hash.as_str()) .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; let summary = commit.summary.replace(['\r', '\n'], " "); match item.action { RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)), RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)), RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)), RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)), RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)), } } Ok(todo) } fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { let by_hash = commits .iter() .map(|commit| (commit.hash.as_str(), commit)) .collect::>(); let mut queue = String::new(); for item in plan .iter() .filter(|item| item.action == RebaseAction::Reword) { let commit = by_hash .get(item.hash.as_str()) .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; let message = item.message.as_deref().unwrap_or("").trim(); queue.push_str(&commit.summary.replace(['\r', '\n'], " ")); queue.push('\x1f'); queue.push_str(message); queue.push('\x1e'); } Ok(queue) } fn git_dir_for_repo(repo: &Path) -> Result { let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?; let path = String::from_utf8_lossy(&output).trim().to_string(); if path.is_empty() { Err("Git could not determine its metadata directory.".to_string()) } else { Ok(PathBuf::from(path)) } } fn sequence_helper_name() -> &'static str { if cfg!(windows) { ".gitty-sequence-editor.exe" } else { SEQUENCE_HELPER_STEM } } fn commit_helper_name() -> &'static str { if cfg!(windows) { ".gitty-commit-editor.exe" } else { COMMIT_HELPER_STEM } } fn cleanup_interactive_rebase_helpers(repo: &Path) { if let Ok(git_dir) = git_dir_for_repo(repo) { let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE)); let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE)); } let _ = fs::remove_file(repo.join(sequence_helper_name())); let _ = fs::remove_file(repo.join(commit_helper_name())); } fn is_interactive_rebase_helper_path(path: &str) -> bool { matches!( path.replace('\\', "/").rsplit('/').next(), Some(name) if name == sequence_helper_name() || name == commit_helper_name() ) } fn parse_reflog(output: &[u8]) -> Result, String> { let mut entries = Vec::new(); for raw in output.split(|byte| *byte == 0x1e) { let record = String::from_utf8_lossy(raw); let record = record.trim_matches(['\r', '\n', ' ']); if record.is_empty() { continue; } let fields = record.split('\x1f').collect::>(); if fields.len() != 6 { return Err("Git returned an unexpected reflog record.".to_string()); } entries.push(ReflogEntry { hash: fields[0].to_string(), short_hash: fields[1].to_string(), selector: fields[2].to_string(), action: fields[3].to_string(), author_name: fields[4].to_string(), date: fields[5].to_string(), }); } Ok(entries) } fn rebase_status_or_error( repo: &Path, output: Output, context: &str, ok_if_rebase_in_progress: bool, ) -> Result { if output.status.success() { return status_for_repo(repo); } let status = status_for_repo(repo)?; if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress) { return Ok(status); } Err(format!("{context}: {}", command_output_details(&output))) } #[tauri::command] pub async fn cherry_pick_commit(path: String, commit: String) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; let commit_hash = verify_commit(&repo, &commit)?; let output = git_command() .arg("-C") .arg(&repo) .args(["cherry-pick", commit_hash.as_str()]) .env("GIT_EDITOR", "true") .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; cherry_pick_status_or_error(&repo, output, "Cherry-pick failed") }) .await .map_err(|err| format!("Could not cherry-pick: {err}"))? } #[tauri::command] pub fn cherry_pick_continue(path: String) -> Result { let repo = resolve_repo(&path)?; if !cherry_pick_in_progress(&repo) { return Err("No cherry-pick is currently in progress.".to_string()); } let output = git_command() .arg("-C") .arg(&repo) .args(["cherry-pick", "--continue"]) .env("GIT_EDITOR", "true") .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed") } #[tauri::command] pub fn cherry_pick_abort(path: String) -> Result { let repo = resolve_repo(&path)?; if !cherry_pick_in_progress(&repo) { return Err("No cherry-pick is currently in progress.".to_string()); } run_git(&repo, ["cherry-pick", "--abort"])?; status_for_repo(&repo) } fn cherry_pick_status_or_error( repo: &Path, output: Output, context: &str, ) -> Result { if output.status.success() { return status_for_repo(repo); } let status = status_for_repo(repo)?; if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress { return Ok(status); } Err(format!("{context}: {}", command_output_details(&output))) } #[tauri::command] pub fn list_commits( path: String, limit: Option, skip: Option, ) -> Result, String> { let repo = resolve_repo(&path)?; commit_page_for_repo(&repo, limit, skip) } fn commits_for_repo(repo: &Path, limit: Option) -> Result, String> { let bounded_limit = limit.unwrap_or(100).clamp(1, 500); commit_page_for_repo(repo, Some(bounded_limit), None) } fn commit_page_for_repo( repo: &Path, limit: Option, skip: Option, ) -> Result, String> { if verify_commit(repo, "HEAD").is_err() { return Ok(Vec::new()); } let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string(); let skip = skip.unwrap_or(0).min(10_000_000).to_string(); // Fetch the per-commit changed files inline via `--name-status` in a single // `git log` process, instead of spawning one `git diff-tree` per commit // (which was ~100 extra processes and the main cost of opening a repo). let output = run_git( repo, [ "log", "--all", "--topo-order", "--decorate=short", "--name-status", "-M", "-z", "--root", "--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f", "--skip", skip.as_str(), "-n", limit.as_str(), ], )?; parse_commit_log_inline(&output) } #[tauri::command] pub fn list_repository_files(path: String) -> Result, String> { let repo = resolve_repo(&path)?; repository_files(&repo) } #[tauri::command] pub async fn list_file_history( path: String, file: String, limit: Option, request_id: Option, state: tauri::State<'_, SearchCancellationState>, ) -> Result, String> { let state = state.inner().clone(); tauri::async_runtime::spawn_blocking(move || { let repo = resolve_repo(&path)?; let request_id = request_id .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()); let cancellation = request_id.as_ref().map(|request_id| SearchCancellation { state: state.clone(), search_id: request_id.clone(), }); let result = list_file_history_core(&repo, file, limit, cancellation.as_ref()); if let Some(request_id) = request_id.as_deref() { let _ = state.clear(request_id); } result }) .await .map_err(|err| format!("Could not load file history: {err}"))? } #[tauri::command] pub fn cancel_file_history( request_id: String, state: tauri::State<'_, SearchCancellationState>, ) -> Result<(), String> { let request_id = request_id.trim(); if request_id.is_empty() { return Ok(()); } state.cancel(request_id) } const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000"; #[tauri::command] pub fn get_file_blame(path: String, file: String) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; if verify_commit(&repo, "HEAD").is_err() { return Err("Repository has no commits yet.".to_string()); } let args = vec![ OsString::from("blame"), OsString::from("--line-porcelain"), OsString::from("--"), OsString::from(file.clone()), ]; let output = run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?; Ok(GitBlameResult { path: file, lines: parse_blame_porcelain(&output), }) } fn parse_blame_porcelain(output: &[u8]) -> Vec { #[derive(Default, Clone)] struct BlameMeta { author_name: String, author_email: String, author_time: i64, summary: String, } let text = String::from_utf8_lossy(output); let mut lines_out: Vec = Vec::new(); let mut commit_meta: BTreeMap = BTreeMap::new(); let mut current_hash = String::new(); let mut current_final_line: u32 = 0; for line in text.split('\n') { if let Some(content) = line.strip_prefix('\t') { let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default(); lines_out.push(GitBlameLine { line_number: current_final_line, content: content.to_string(), commit_hash: current_hash.clone(), short_hash: short_hash(¤t_hash), author_name: meta.author_name, author_email: meta.author_email, author_time: meta.author_time, summary: meta.summary, is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH, }); continue; } let mut parts = line.splitn(2, ' '); let head = parts.next().unwrap_or(""); let tail = parts.next().unwrap_or(""); if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) { if let Some(final_line) = tail.split_whitespace().nth(1) { current_final_line = final_line.parse().unwrap_or(current_final_line); } current_hash = head.to_string(); commit_meta.entry(current_hash.clone()).or_default(); continue; } match head { "author" => { commit_meta .entry(current_hash.clone()) .or_default() .author_name = tail.to_string() } "author-mail" => { let email = tail.trim_matches(|c| c == '<' || c == '>').to_string(); commit_meta .entry(current_hash.clone()) .or_default() .author_email = email; } "author-time" => { commit_meta .entry(current_hash.clone()) .or_default() .author_time = tail.parse().unwrap_or(0); } "summary" => { commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string() } _ => {} } } lines_out } fn list_file_history_core( repo: &Path, file: String, limit: Option, cancellation: Option<&SearchCancellation>, ) -> Result, String> { check_search_cancelled(cancellation)?; 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%P%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_cancellable(repo, args, cancellation, "Git file history failed")?; check_search_cancelled(cancellation)?; parse_commit_log(repo, &output) } #[tauri::command] pub async fn search_code_introductions( path: String, query: String, case_sensitive: Option, limit: Option, search_id: Option, state: tauri::State<'_, SearchCancellationState>, ) -> Result, String> { let state = state.inner().clone(); tauri::async_runtime::spawn_blocking(move || { let repo = resolve_repo(&path)?; let query = normalize_newlines(&query); let query = query.trim_matches('\n').to_string(); if query.trim().is_empty() { return Err("Search text must not be empty.".to_string()); } if verify_commit(&repo, "HEAD").is_err() { return Ok(Vec::new()); } let case_sensitive = case_sensitive.unwrap_or(false); let limit = limit.unwrap_or(250).clamp(1, 1000) as usize; let search_id = search_id .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()); let cancellation = search_id.as_ref().map(|search_id| SearchCancellation { state: state.clone(), search_id: search_id.clone(), }); let result = search_code_introductions_core( &repo, query, case_sensitive, limit, cancellation.as_ref(), ); if let Some(search_id) = search_id.as_deref() { let _ = state.clear(search_id); } result }) .await .map_err(|err| format!("Could not complete search task: {err}"))? } #[tauri::command] pub fn cancel_code_search( search_id: String, state: tauri::State<'_, SearchCancellationState>, ) -> Result<(), String> { let search_id = search_id.trim(); if search_id.is_empty() { return Ok(()); } state.cancel(search_id) } fn search_code_introductions_core( repo: &Path, query: String, case_sensitive: bool, limit: usize, cancellation: Option<&SearchCancellation>, ) -> Result, String> { check_search_cancelled(cancellation)?; let candidates = search_candidate_commits(&repo, &query, case_sensitive, cancellation)?; let mut hits = Vec::new(); for commit in candidates { check_search_cancelled(cancellation)?; if hits.len() >= limit { break; } let files = commit_files(&repo, &commit)?; if files.is_empty() { continue; } let parents = commit_parents(&repo, &commit)?; let mut metadata: Option = None; for file in files { check_search_cancelled(cancellation)?; if hits.len() >= limit { break; } if matches!(file.status, FileStatusKind::Deleted) { continue; } let Some(after_content) = read_text_blob(&repo, &commit, &file.path)? else { continue; }; let after_count = count_matches(&after_content, &query, case_sensitive); if after_count == 0 { continue; } let before_count = max_parent_match_count( &repo, &parents, file.old_path.as_deref().unwrap_or(&file.path), &query, case_sensitive, )?; if after_count <= before_count { continue; } let match_line = first_added_match_line( &repo, parents.first().map(String::as_str), &commit, &file.path, &query, case_sensitive, cancellation, )? .or_else(|| first_match_line(&after_content, &query, case_sensitive)); let Some((line_number, line)) = match_line else { continue; }; let info = metadata .get_or_insert_with(|| { commit_search_metadata(&repo, &commit).unwrap_or_else(|_| { GitSearchCommitMetadata { commit_hash: commit.clone(), short_hash: short_hash(&commit), author_name: String::new(), author_email: String::new(), date: String::new(), summary: String::new(), } }) }) .clone(); hits.push(GitSearchHit { commit_hash: info.commit_hash, short_hash: info.short_hash, summary: info.summary, author_name: info.author_name, author_email: info.author_email, date: info.date, file: file.path, old_file: file.old_path, line_number: Some(line_number), line, matches_added: after_count.saturating_sub(before_count) as u32, }); } } Ok(hits) } #[tauri::command] pub fn restore_to_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; let commit_hash = verify_commit(&repo, &commit)?; // Non-destructive: bring the working tree back to how it looked at `commit` without // moving the branch pointer (unlike `git reset --hard`, which would rewrite history and // hide any newer commits from the log). The result lands as ordinary unstaged changes // that the user reviews in the status panel and stages/commits or discards explicitly. run_git( &repo, [ "restore", "--source", commit_hash.as_str(), "--worktree", "--", ".", ], )?; 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) } #[tauri::command] pub fn compare_commits( path: String, from: String, to: String, ) -> Result { let repo = resolve_repo(&path)?; let from_hash = verify_commit(&repo, &from)?; let to_hash = verify_commit(&repo, &to)?; let name_status = run_git( &repo, [ "diff", "--name-status", "-M", "-z", from_hash.as_str(), to_hash.as_str(), ], )?; let numstat = run_git( &repo, [ "diff", "--numstat", "-M", "-z", from_hash.as_str(), to_hash.as_str(), ], )?; let patch_output = run_git( &repo, [ "diff", "--no-ext-diff", "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, from_hash.as_str(), to_hash.as_str(), ], )?; let files = parse_diff_files(&name_status, &numstat)?; let patch = String::from_utf8_lossy(&patch_output).to_string(); Ok(GitCommitComparison { from_short: short_hash(&from_hash), to_short: short_hash(&to_hash), from_hash, to_hash, files, patch, }) } #[tauri::command] pub fn diff_file_against_working_tree( 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)?; let name_status = run_git_with_paths( &repo, &["diff", "--name-status", "-M", "-z", commit_hash.as_str()], std::slice::from_ref(&file), )?; let numstat = run_git_with_paths( &repo, &["diff", "--numstat", "-M", "-z", commit_hash.as_str()], std::slice::from_ref(&file), )?; let patch_output = run_git_with_paths( &repo, &[ "diff", "--no-ext-diff", "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str(), ], std::slice::from_ref(&file), )?; let files = parse_diff_files(&name_status, &numstat)?; let patch = String::from_utf8_lossy(&patch_output).to_string(); Ok(GitCommitComparison { from_short: short_hash(&commit_hash), to_short: "working tree".to_string(), from_hash: commit_hash, to_hash: String::new(), files, patch, }) } #[tauri::command] pub fn compare_file_to_head( 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)?; let head_hash = verify_commit(&repo, "HEAD")?; let name_status = run_git_with_paths( &repo, &[ "diff", "--name-status", "-M", "-z", commit_hash.as_str(), head_hash.as_str(), ], std::slice::from_ref(&file), )?; let numstat = run_git_with_paths( &repo, &[ "diff", "--numstat", "-M", "-z", commit_hash.as_str(), head_hash.as_str(), ], std::slice::from_ref(&file), )?; let patch_output = run_git_with_paths( &repo, &[ "diff", "--no-ext-diff", "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str(), head_hash.as_str(), ], std::slice::from_ref(&file), )?; let files = parse_diff_files(&name_status, &numstat)?; let patch = String::from_utf8_lossy(&patch_output).to_string(); Ok(GitCommitComparison { from_short: short_hash(&commit_hash), to_short: "HEAD".to_string(), from_hash: commit_hash, to_hash: head_hash, files, patch, }) } #[tauri::command] pub fn compare_file_to_parent( path: String, commit: String, file: String, old_file: Option, ) -> Result { let repo = resolve_repo(&path)?; let mut pathspecs = Vec::new(); if let Some(old_file) = old_file.filter(|value| !value.trim().is_empty() && value != &file) { pathspecs.push(old_file); } pathspecs.push(file); validate_files(&pathspecs)?; let commit_hash = verify_commit(&repo, &commit)?; let parents = commit_parents(&repo, &commit_hash)?; let (from_hash, from_short) = if let Some(parent) = parents.first() { (parent.clone(), short_hash(parent)) } else { (EMPTY_TREE_HASH.to_string(), "empty tree".to_string()) }; let name_status = run_git_with_paths( &repo, &[ "diff", "--name-status", "-M", "-z", from_hash.as_str(), commit_hash.as_str(), ], &pathspecs, )?; let numstat = run_git_with_paths( &repo, &[ "diff", "--numstat", "-M", "-z", from_hash.as_str(), commit_hash.as_str(), ], &pathspecs, )?; let patch_output = run_git_with_paths( &repo, &[ "diff", "--no-ext-diff", "--no-textconv", "-M", FULL_FILE_DIFF_CONTEXT, from_hash.as_str(), commit_hash.as_str(), ], &pathspecs, )?; let files = parse_diff_files(&name_status, &numstat)?; let patch = String::from_utf8_lossy(&patch_output).to_string(); Ok(GitCommitComparison { from_short, to_short: short_hash(&commit_hash), from_hash, to_hash: commit_hash, files, patch, }) } #[tauri::command] pub fn read_conflict(path: String, file: String) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let bytes = std::fs::read(repo.join(&file)) .map_err(|err| format!("Could not read conflict file: {err}"))?; let binary = is_binary_bytes(&bytes); // For binary files we cannot offer a text merge, so we only report the side // sizes; the UI lets the user pick which side to keep wholesale. if binary { return Ok(ConflictFile { path: file.clone(), content: String::new(), ours: None, theirs: None, base: None, binary: true, ours_size: index_stage_size(&repo, 2, &file), theirs_size: index_stage_size(&repo, 3, &file), }); } Ok(ConflictFile { base: read_index_stage(&repo, 1, &file), ours: read_index_stage(&repo, 2, &file), theirs: read_index_stage(&repo, 3, &file), binary: false, ours_size: index_stage_size(&repo, 2, &file), theirs_size: index_stage_size(&repo, 3, &file), path: file, content: String::from_utf8_lossy(&bytes).to_string(), }) } #[tauri::command] pub fn resolve_conflict_side( path: String, file: String, side: String, ) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let flag = match side.as_str() { "ours" => "--ours", "theirs" => "--theirs", _ => return Err("Invalid side. Allowed values are 'ours' or 'theirs'.".to_string()), }; run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?; run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?; status_for_repo(&repo) } fn is_binary_bytes(bytes: &[u8]) -> bool { // Git's own heuristic: a NUL byte within the first 8000 bytes marks the // blob as binary. bytes.iter().take(8000).any(|byte| *byte == 0) } fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option { let spec = format!(":{stage}:{file}"); let output = git_command() .arg("-C") .arg(repo) .args(["cat-file", "-s", spec.as_str()]) .output() .ok()?; if output.status.success() { String::from_utf8_lossy(&output.stdout).trim().parse().ok() } else { None } } #[tauri::command] pub fn resolve_conflict(path: String, file: String, content: String) -> Result { let repo = resolve_repo(&path)?; validate_files(std::slice::from_ref(&file))?; let target = repo.join(&file); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent) .map_err(|err| format!("Could not create directory: {err}"))?; } std::fs::write(&target, content) .map_err(|err| format!("Could not write conflict file: {err}"))?; run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?; status_for_repo(&repo) } fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option { let spec = format!(":{stage}:{file}"); let output = git_command() .arg("-C") .arg(repo) .args(["show", spec.as_str()]) .output() .ok()?; if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).to_string()) } else { None } } fn short_hash(hash: &str) -> String { hash.chars().take(7).collect() } fn resolve_repo(path: &str) -> Result { if path.trim().is_empty() { return Err("Repository path must not be empty.".to_string()); } let input = PathBuf::from(path); let output = run_git_at( &input, ["rev-parse", "--show-toplevel"], "Not a Git repository or unreachable", )?; let top_level = String::from_utf8_lossy(&output).trim().to_string(); if top_level.is_empty() { return Err("Git could not determine the repository root path.".to_string()); } Ok(PathBuf::from(top_level)) } #[cfg(windows)] fn open_path_in_file_manager(path: &Path) -> Result<(), String> { let native_path = path.to_string_lossy().replace('/', "\\"); let mut command = Command::new("explorer.exe"); command.arg(native_path); command.creation_flags(CREATE_NO_WINDOW); command .spawn() .map_err(|err| format!("Could not launch Explorer: {err}"))?; Ok(()) } #[cfg(target_os = "macos")] fn open_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("open") .arg(path) .spawn() .map_err(|err| format!("Could not launch Finder: {err}"))?; Ok(()) } #[cfg(all(unix, not(target_os = "macos")))] fn open_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("xdg-open") .arg(path) .spawn() .map_err(|err| format!("Could not launch file manager: {err}"))?; Ok(()) } fn resolve_repo_child_path(repo: &Path, child: &str) -> Result { let child_path = Path::new(child); if child_path.is_absolute() || child_path.components().any(|component| { matches!( component, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) ) }) { return Err("File path must stay within the repository.".to_string()); } let candidate = repo.join(child_path); let repo = repo .canonicalize() .map_err(|err| format!("Could not resolve repository path: {err}"))?; let candidate = candidate .canonicalize() .map_err(|err| format!("Could not resolve file path: {err}"))?; if !candidate.starts_with(&repo) { return Err("File path lies outside the repository.".to_string()); } Ok(candidate) } #[cfg(windows)] fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { let native_path = path.to_string_lossy().replace('/', "\\"); let mut command = Command::new("explorer.exe"); command.arg(format!("/select,{native_path}")); command.creation_flags(CREATE_NO_WINDOW); command .spawn() .map_err(|err| format!("Could not launch Explorer: {err}"))?; Ok(()) } #[cfg(target_os = "macos")] fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { Command::new("open") .arg("-R") .arg(path) .spawn() .map_err(|err| format!("Could not launch Finder: {err}"))?; Ok(()) } #[cfg(all(unix, not(target_os = "macos")))] fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> { // No universal "select this file" flag across Linux file managers; open its folder instead. let target = path.parent().unwrap_or(path); Command::new("xdg-open") .arg(target) .spawn() .map_err(|err| format!("Could not launch file manager: {err}"))?; Ok(()) } fn verify_commit(repo: &Path, commit: &str) -> Result { let commit = commit.trim(); if commit.is_empty() { return Err("Commit must not be empty.".to_string()); } let rev = format!("{commit}^{{commit}}"); let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()]) .map_err(|err| format!("Could not find commit: {err}"))?; let hash = String::from_utf8_lossy(&output).trim().to_string(); if hash.is_empty() { return Err("Commit could not be found.".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, mut files) = parse_status_output(&output)?; detect_worktree_renames(repo, &mut files); files.retain(|file| !is_interactive_rebase_helper_path(&file.path)); 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, rebase_in_progress: rebase_in_progress(repo), cherry_pick_in_progress: cherry_pick_in_progress(repo), merge_in_progress: merge_in_progress(repo), }) } fn rebase_in_progress(repo: &Path) -> bool { git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply") } fn cherry_pick_in_progress(repo: &Path) -> bool { git_path_exists(repo, "CHERRY_PICK_HEAD") } fn merge_in_progress(repo: &Path) -> bool { git_path_exists(repo, "MERGE_HEAD") } fn git_path_exists(repo: &Path, name: &str) -> bool { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { return false; }; let value = String::from_utf8_lossy(&output).trim().to_string(); if value.is_empty() { return false; } let path = PathBuf::from(value); if path.is_absolute() { path.exists() } else { repo.join(path).exists() } } // `git status` only auto-detects renames between HEAD and the index (staged changes). // A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked // pair instead. We detect that case ourselves by comparing content hashes: if an unstaged // deletion and an untracked file share the same blob hash (and the match is unambiguous), // we merge them into a single unstaged "renamed" entry, mirroring how git reports staged // renames. This is intentionally content-hash based (not similarity-based) so it never // mutates the repository's real index. const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300; fn detect_worktree_renames(repo: &Path, files: &mut Vec) { let deleted_paths: Vec = files .iter() .filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted)) .map(|f| f.path.clone()) .collect(); let untracked_paths: Vec = files .iter() .filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked)) .map(|f| f.path.clone()) .collect(); if deleted_paths.is_empty() || untracked_paths.is_empty() || deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT || untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT { return; } let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else { return; }; let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else { return; }; let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for (path, hash) in &deleted_hashes { hash_to_deleted.entry(hash).or_default().push(path); } let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); for (path, hash) in &untracked_hashes { hash_to_untracked.entry(hash).or_default().push(path); } let mut renames: Vec<(String, String)> = Vec::new(); for (hash, olds) in &hash_to_deleted { if olds.len() != 1 { continue; } if let Some(news) = hash_to_untracked.get(hash) { if news.len() == 1 { renames.push((olds[0].to_string(), news[0].to_string())); } } } for (old_path, new_path) in renames { files.retain(|f| { !(f.path == old_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted)) && !(f.path == new_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked)) }); files.push(GitFileStatus { path: new_path, old_path: Some(old_path), staged: None, unstaged: Some(FileStatusKind::Renamed), }); } } // Batched via `git ls-files -s -z` (one process for every deleted path) rather than one // `git rev-parse` call per file, since this runs on every status refresh. fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result, String> { let mut args: Vec = vec![ OsString::from("ls-files"), OsString::from("-s"), OsString::from("-z"), OsString::from("--"), ]; args.extend(paths.iter().map(OsString::from)); let output = run_git(repo, args)?; let mut result = Vec::new(); for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) { let text = String::from_utf8_lossy(entry); let Some((meta, path)) = text.split_once('\t') else { continue; }; let Some(hash) = meta.split_whitespace().nth(1) else { continue; }; result.push((path.to_string(), hash.to_string())); } Ok(result) } // Batched via `git hash-object --stdin-paths` (one process for every untracked path). fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result, String> { let stdin_data = paths.join("\n"); let output = run_git_with_stdin( repo, ["hash-object", "--stdin-paths"], stdin_data.as_bytes(), )?; let hashes: Vec = String::from_utf8_lossy(&output) .lines() .map(|line| line.trim().to_string()) .filter(|line| !line.is_empty()) .collect(); Ok(paths.iter().cloned().zip(hashes).collect()) } fn repository_files(repo: &Path) -> Result, String> { let status = status_for_repo(repo)?; repository_files_with_status(repo, &status) } fn repository_files_with_status( repo: &Path, status: &GitStatus, ) -> Result, String> { 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 clone_repository_core( remote_url: &str, parent_path: &str, directory_name: Option<&str>, username: Option<&str>, password: Option<&str>, commit_limit: Option, ) -> Result { let target = clone_target_path(remote_url, parent_path, directory_name)?; run_git_clone(remote_url.trim(), &target, username, password)?; let repo = resolve_repo(&target.to_string_lossy())?; let status = status_for_repo(&repo)?; let branches = branches_for_repo(&repo)?; let tags = tags_for_repo(&repo)?; let stashes = stashes_for_repo(&repo)?; let commits = commits_for_repo(&repo, commit_limit)?; let files = repository_files_with_status(&repo, &status)?; Ok(RepositoryBundle { status, branches, tags, stashes, commits, files, }) } fn clone_target_path( remote_url: &str, parent_path: &str, directory_name: Option<&str>, ) -> Result { let remote = remote_url.trim(); if remote.is_empty() { return Err("Remote URL must not be empty.".to_string()); } if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) { return Err("Remote URL contains invalid characters.".to_string()); } let parent = PathBuf::from(parent_path.trim()); if parent_path.trim().is_empty() { return Err("Destination folder must not be empty.".to_string()); } if !parent.exists() { return Err("Destination folder does not exist.".to_string()); } if !parent.is_dir() { return Err("Destination path must be a folder.".to_string()); } let raw_name = directory_name .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .unwrap_or_else(|| infer_clone_directory_name(remote)); let name = validate_clone_directory_name(&raw_name)?; let target = parent.join(name); if target.exists() { if !target.is_dir() { return Err("Clone destination already exists and is not a folder.".to_string()); } let mut entries = target .read_dir() .map_err(|err| format!("Could not inspect clone destination: {err}"))?; if entries.next().is_some() { return Err("Clone destination already exists and is not empty.".to_string()); } } Ok(target) } fn infer_clone_directory_name(remote_url: &str) -> String { let trimmed = remote_url .trim() .split(['?', '#']) .next() .unwrap_or(remote_url) .trim_end_matches(['/', '\\']); let last_segment = trimmed .rsplit(['/', '\\', ':']) .find(|part| !part.trim().is_empty()) .unwrap_or("") .trim(); last_segment .strip_suffix(".git") .unwrap_or(last_segment) .trim() .to_string() } fn validate_clone_directory_name(name: &str) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { return Err("Folder name could not be inferred. Enter a folder name.".to_string()); } if trimmed == "." || trimmed == ".." { return Err("Folder name is not valid.".to_string()); } if trimmed.chars().any(|c| { c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') }) { return Err("Folder name contains invalid characters.".to_string()); } if Path::new(trimmed).is_absolute() { return Err("Folder name must be relative.".to_string()); } Ok(trimmed.to_string()) } fn run_git_clone( remote_url: &str, target: &Path, username: Option<&str>, password: Option<&str>, ) -> Result<(), String> { let mut command = git_command(); command .arg("clone") .arg("--") .arg(remote_url) .arg(target) .env("GIT_TERMINAL_PROMPT", "0"); let askpass = match (username, password) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { let askpass = write_askpass_script()?; command .env("GIT_ASKPASS", &askpass) .env("GIT_CRED_USER", u) .env("GIT_CRED_PASS", p); Some(askpass) } _ => None, }; let output = command .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}")); if let Some(path) = askpass { let _ = std::fs::remove_file(path); } let output = output?; if output.status.success() { return Ok(()); } let details = command_output_details(&output); if is_auth_error(&details) { return Err(format!("AUTH_FAILED:{details}")); } Err(format!("Git clone failed: {}", details)) } 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 has_unresolved_conflicts(status: &GitStatus) -> bool { status.files.iter().any(|file| { matches!(file.staged, Some(FileStatusKind::Conflicted)) || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) }) } #[derive(Debug, Clone, PartialEq, Eq)] struct GitSearchCommitMetadata { commit_hash: String, short_hash: String, author_name: String, author_email: String, date: String, summary: String, } fn search_candidate_commits( repo: &Path, query: &str, case_sensitive: bool, cancellation: Option<&SearchCancellation>, ) -> Result, String> { let output = if query.contains('\n') { run_git_cancellable( repo, ["rev-list", "--all", "--reverse"], cancellation, "Git search failed", )? } else { let mut args = vec![ OsString::from("log"), OsString::from("--all"), OsString::from("--reverse"), OsString::from("--format=%H"), // Skip textconv diff drivers so git does not extract binary files // (e.g. .docx / Office temp "~$" lock files) to temp files, which can // fail with "unsupported filetype" and abort the whole search. OsString::from("--no-textconv"), ]; if !case_sensitive { args.push(OsString::from("-i")); } args.push(OsString::from(format!("-S{query}"))); run_git_cancellable(repo, args, cancellation, "Git search failed")? }; Ok(String::from_utf8_lossy(&output) .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect()) } fn commit_parents(repo: &Path, commit: &str) -> Result, String> { let output = run_git(repo, ["rev-list", "--parents", "-n", "1", commit])?; let text = String::from_utf8_lossy(&output); Ok(text .split_whitespace() .skip(1) .map(ToString::to_string) .collect()) } fn max_parent_match_count( repo: &Path, parents: &[String], file: &str, query: &str, case_sensitive: bool, ) -> Result { let mut max_count = 0; for parent in parents { if let Some(content) = read_text_blob(repo, parent, file)? { max_count = max_count.max(count_matches(&content, query, case_sensitive)); } } Ok(max_count) } fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result, String> { let spec = format!("{commit}:{file}"); let output = git_command() .arg("-C") .arg(repo) .args(["show", spec.as_str()]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if !output.status.success() { return Ok(None); } if is_binary_bytes(&output.stdout) { return Ok(None); } Ok(Some(normalize_newlines(&String::from_utf8_lossy( &output.stdout, )))) } fn normalize_newlines(value: &str) -> String { value.replace("\r\n", "\n").replace('\r', "\n") } fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize { let content = if case_sensitive { content.to_string() } else { content.to_ascii_lowercase() }; let query = if case_sensitive { query.to_string() } else { query.to_ascii_lowercase() }; if query.is_empty() { return 0; } let mut count = 0; let mut start = 0; while let Some(index) = content[start..].find(&query) { count += 1; start += index + query.len(); } count } fn first_added_match_line( repo: &Path, parent: Option<&str>, commit: &str, file: &str, query: &str, case_sensitive: bool, cancellation: Option<&SearchCancellation>, ) -> Result, String> { let Some(parent) = parent else { return Ok(None); }; check_search_cancelled(cancellation)?; let output = run_git_with_paths_cancellable( repo, &["diff", "--no-textconv", "--unified=0", parent, commit], &[file.to_string()], cancellation, "Git diff for search result failed", )?; let patch = String::from_utf8_lossy(&output); let line_query = first_query_line(query); let mut new_line = 0u32; for line in patch.lines() { check_search_cancelled(cancellation)?; if line.starts_with("@@") { if let Some(start) = parse_new_hunk_start(line) { new_line = start; } continue; } if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") { continue; } if let Some(added) = line.strip_prefix('+') { if count_matches(added, line_query, case_sensitive) > 0 { return Ok(Some((new_line.max(1), compact_search_line(added)))); } new_line = new_line.saturating_add(1); } else if line.starts_with('-') { continue; } else if line.starts_with(' ') { new_line = new_line.saturating_add(1); } } Ok(None) } fn first_query_line(query: &str) -> &str { query .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or(query) } fn parse_new_hunk_start(line: &str) -> Option { let plus = line.split_whitespace().find(|part| part.starts_with('+'))?; let number = plus .trim_start_matches('+') .split_once(',') .map(|(start, _)| start) .unwrap_or_else(|| plus.trim_start_matches('+')); number.parse().ok() } fn first_match_line(content: &str, query: &str, case_sensitive: bool) -> Option<(u32, String)> { let haystack = if case_sensitive { content.to_string() } else { content.to_ascii_lowercase() }; let needle = if case_sensitive { query.to_string() } else { query.to_ascii_lowercase() }; let index = haystack.find(&needle)?; let line_number = content[..index] .bytes() .filter(|byte| *byte == b'\n') .count() as u32 + 1; let line_start = content[..index].rfind('\n').map(|pos| pos + 1).unwrap_or(0); let line_end = content[index..] .find('\n') .map(|pos| index + pos) .unwrap_or(content.len()); Some(( line_number, compact_search_line(&content[line_start..line_end]), )) } fn compact_search_line(line: &str) -> String { const MAX_LEN: usize = 240; let compact = line.trim().replace('\t', " "); if compact.chars().count() <= MAX_LEN { return compact; } let mut shortened: String = compact.chars().take(MAX_LEN).collect(); shortened.push_str("..."); shortened } fn commit_search_metadata(repo: &Path, commit: &str) -> Result { let output = run_git( repo, [ "show", "-s", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s", commit, ], )?; let text = String::from_utf8_lossy(&output); let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect(); if fields.len() != 6 { return Err(format!("Unexpected Git commit entry: {text}")); } Ok(GitSearchCommitMetadata { commit_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(), summary: fields[5].to_string(), }) } /// Parses `git log --name-status -z` output where each commit's changed files /// are embedded inline (see `commits_for_repo`), so no per-commit git process is /// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then /// git's newline, then the NUL-separated name-status entries. fn parse_commit_log_inline(output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: u8 = 0x1f; const RECORD_SEPARATOR: u8 = 0x1e; let mut commits = Vec::new(); for record in output.split(|byte| *byte == RECORD_SEPARATOR) { // Skip the empty leading chunk and any stray separators left by `-z`. if record .iter() .all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t')) { continue; } let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect(); if parts.len() < 8 { return Err(format!( "Unexpected Git log entry: {}", String::from_utf8_lossy(record) )); } // Field 8 (if present) holds the name-status list, preceded by the newline // git inserts between the pretty-format output and the diff. let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]); while let Some((&first, rest)) = files_bytes.split_first() { if matches!(first, b'\n' | b'\r') { files_bytes = rest; } else { break; } } let refs = String::from_utf8_lossy(parts[5]) .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect(); let parents = String::from_utf8_lossy(parts[6]) .split_whitespace() .map(ToString::to_string) .collect(); commits.push(GitCommit { hash: String::from_utf8_lossy(parts[0]).trim().to_string(), short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(), author_name: String::from_utf8_lossy(parts[2]).to_string(), author_email: String::from_utf8_lossy(parts[3]).to_string(), date: String::from_utf8_lossy(parts[4]).trim().to_string(), refs, parents, summary: String::from_utf8_lossy(parts[7]).to_string(), files: parse_commit_files(files_bytes)?, }); } Ok(commits) } 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(8, FIELD_SEPARATOR).collect(); if fields.len() != 8 { return Err(format!("Unexpected Git log entry: {record}")); } let refs = fields[5] .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect(); let parents = fields[6] .split_whitespace() .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, parents, summary: fields[7].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(8, FIELD_SEPARATOR).collect(); if fields.len() != 8 { return Err(format!("Unexpected Git log entry: {record}")); } let refs = fields[5] .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToString::to_string) .collect(); let parents = fields[6] .split_whitespace() .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, parents, summary: fields[7].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 entry without path: {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 parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result, String> { let status_files = parse_commit_files(name_status)?; let counts = parse_numstat_z(numstat); let files = status_files .into_iter() .map(|file| { let (additions, deletions) = counts .iter() .find(|(path, _, _)| *path == file.path) .map(|(_, additions, deletions)| (*additions, *deletions)) .unwrap_or((0, 0)); GitDiffFile { path: file.path, old_path: file.old_path, status: file.status, additions, deletions, } }) .collect(); Ok(files) } fn parse_numstat_z(output: &[u8]) -> Vec<(String, u32, u32)> { let tokens: Vec = output .split(|byte| *byte == 0) .filter(|entry| !entry.is_empty()) .map(|entry| String::from_utf8_lossy(entry).to_string()) .collect(); let mut result = Vec::new(); let mut index = 0; while index < tokens.len() { let mut parts = tokens[index].splitn(3, '\t'); let additions = parse_numstat_count(parts.next().unwrap_or("")); let deletions = parse_numstat_count(parts.next().unwrap_or("")); let rest = parts.next().unwrap_or("").to_string(); index += 1; // For renames/copies, `--numstat -z` leaves the path empty and emits the // old and new paths as two separate NUL-terminated tokens. let path = if rest.is_empty() { if index + 1 >= tokens.len() { break; } let new_path = tokens[index + 1].clone(); index += 2; new_path } else { rest }; result.push((path, additions, deletions)); } result } fn parse_numstat_count(value: &str) -> u32 { value.trim().parse().unwrap_or(0) } 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 validate_new_branch_name(repo: &Path, branch: &str) -> Result { let branch = branch.trim(); if branch.is_empty() { return Err("Branch name must not be empty.".to_string()); } let output = git_command() .arg("-C") .arg(repo) .args(["check-ref-format", "--branch", branch]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if !output.status.success() { let details = command_output_details(&output); return Err(format!("Invalid branch name: {details}")); } let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); let normalized = if normalized.is_empty() { branch.to_string() } else { normalized }; if ref_exists(repo, &format!("refs/heads/{normalized}"))? { return Err(format!("Branch '{normalized}' already exists.")); } Ok(normalized) } fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result { let branch = branch.trim(); if branch.is_empty() { return Err("Branch name must not be empty.".to_string()); } let normalized = validate_branch_ref_name(branch)?; if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { return Err(format!("Local branch '{normalized}' was not found.")); } Ok(normalized) } fn validate_branch_ref_name(branch: &str) -> Result { let output = git_command() .args(["check-ref-format", "--branch", branch]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if !output.status.success() { let details = command_output_details(&output); return Err(format!("Invalid branch name: {details}")); } let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(if normalized.is_empty() { branch.to_string() } else { normalized }) } fn ref_exists(repo: &Path, ref_name: &str) -> Result { let output = git_command() .arg("-C") .arg(repo) .args(["show-ref", "--verify", "--quiet", ref_name]) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {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() { "unknown error".to_string() } else { stderr.trim().to_string() }; Err(format!("Could not verify Git ref: {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); match status.and_then(|entry| entry.unstaged) { Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()), // An unstaged rename (see `detect_worktree_renames`) has no index entry for the // new path, so `git restore` can't act on it directly: restore the original // content at the old path and drop the untracked new file instead. Some(FileStatusKind::Renamed) => { if let Some(entry) = status { if let Some(old_path) = entry.old_path.clone() { restore_paths.push(old_path); } clean_paths.push(entry.path.clone()); } else { restore_paths.push(file.clone()); } } _ => 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("File list contains an empty path.".to_string()); } Ok(()) } fn write_temp_patch(patch: &str) -> Result { let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "gitlite_patch_{}_{}.patch", std::process::id(), counter )); std::fs::write(&path, patch.as_bytes()) .map_err(|err| format!("Could not write patch file: {err}"))?; Ok(path) } fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> { run_apply_patch_command(repo, patch_path, options, true) } fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> { run_apply_patch_command(repo, patch_path, options, false) } fn run_apply_patch_command( repo: &Path, patch_path: &Path, options: &[&str], check_only: bool, ) -> Result<(), String> { let mut args = Vec::with_capacity(options.len() + 5); args.push(OsString::from("apply")); if check_only { args.push(OsString::from("--check")); } args.extend(options.iter().map(OsString::from)); args.push(OsString::from("--recount")); args.push(OsString::from("--whitespace=nowarn")); args.push(patch_path.as_os_str().to_os_string()); run_git(repo, args).map(|_| ()) } #[cfg(unix)] fn write_askpass_script() -> Result { use std::os::unix::fs::PermissionsExt; let path = std::env::temp_dir().join("gitlite_askpass.sh"); let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n"; std::fs::write(&path, script) .map_err(|e| format!("Could not write authentication script: {e}"))?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)) .map_err(|e| format!("Could not set script permissions: {e}"))?; Ok(path) } #[cfg(not(unix))] fn write_askpass_script() -> Result { let path = std::env::temp_dir().join("gitlite_askpass.bat"); let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n"; std::fs::write(&path, script) .map_err(|e| format!("Could not write authentication script: {e}"))?; Ok(path) } fn run_git_authenticated( repo: &Path, args: I, username: &str, password: &str, ) -> Result, String> where I: IntoIterator, S: AsRef, { let output = run_git_authenticated_output(repo, args, username, password)?; if output.status.success() { return Ok(output.stdout); } let details = command_output_details(&output); if is_auth_error(&details) { return Err(format!("AUTH_FAILED:{details}")); } Err(format!("Git command failed: {details}")) } fn run_git_authenticated_output( repo: &Path, args: I, username: &str, password: &str, ) -> Result where I: IntoIterator, S: AsRef, { let askpass = write_askpass_script()?; let result = git_command() .arg("-C") .arg(repo) .args(args) .env("GIT_ASKPASS", &askpass) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_CRED_USER", username) .env("GIT_CRED_PASS", password) .output() .map_err(|err| format!("Could not start Git: {err}")); let _ = std::fs::remove_file(&askpass); result } fn command_output_details(output: &Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); if !stderr.trim().is_empty() { stderr.trim().to_string() } else if !stdout.trim().is_empty() { stdout.trim().to_string() } else { "Unknown error".to_string() } } /// Heuristic: did git fail because the credentials were rejected/expired, /// as opposed to a network or merge error? Used by the frontend to drop the /// stored credential and re-prompt the login. fn is_auth_error(details: &str) -> bool { let d = details.to_lowercase(); d.contains("authentication failed") || d.contains("could not read username") || d.contains("could not read password") || d.contains("invalid username or password") || d.contains("terminal prompts disabled") || d.contains("permission denied") || d.contains("access denied") || d.contains("403 forbidden") || d.contains(" 403") || d.contains(" 401") || d.contains("authorization failed") || d.contains("authentication required") // Server-side message (e.g. Gitea/Forgejo: "remote: Failed to // authenticate user"), independent of the local git locale. || d.contains("failed to authenticate") } // Windows' CreateProcess rejects command lines longer than ~32K chars with // "os error 206" (filename or extension too long). Staging/restoring a large // number of files can easily exceed that, so split the paths across multiple // invocations and concatenate their output. const MAX_PATH_ARGS_CHARS: usize = 8_000; fn run_git_with_paths( repo: &Path, base_args: &[&str], files: &[String], ) -> Result, String> { if files.is_empty() { let args: Vec = base_args.iter().map(OsString::from).collect(); return run_git(repo, args); } let mut combined = Vec::new(); let mut start = 0; while start < files.len() { let mut end = start; let mut chunk_chars = 0usize; while end < files.len() { let len = files[end].len() + 1; if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS { break; } chunk_chars += len; end += 1; } let chunk = &files[start..end]; let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1); args.extend(base_args.iter().map(OsString::from)); args.push(OsString::from("--")); args.extend(chunk.iter().map(OsString::from)); combined.extend(run_git(repo, args)?); start = end; } Ok(combined) } fn run_git_with_paths_cancellable( repo: &Path, base_args: &[&str], files: &[String], cancellation: Option<&SearchCancellation>, context: &str, ) -> 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_cancellable(repo, args, cancellation, context) } fn run_git(repo: &Path, args: I) -> Result, String> where I: IntoIterator, S: AsRef, { run_git_at(repo, args, "Git command failed") } fn run_git_cancellable( repo: &Path, args: I, cancellation: Option<&SearchCancellation>, context: &str, ) -> Result, String> where I: IntoIterator, S: AsRef, { check_search_cancelled(cancellation)?; let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); let temp_dir = std::env::temp_dir(); let stdout_path = temp_dir.join(format!( "gitlite_search_{}_{}.out", std::process::id(), counter )); let stderr_path = temp_dir.join(format!( "gitlite_search_{}_{}.err", std::process::id(), counter )); let stdout_file = std::fs::File::create(&stdout_path) .map_err(|err| format!("Could not create Git output file: {err}"))?; let stderr_file = std::fs::File::create(&stderr_path) .map_err(|err| format!("Could not create Git error file: {err}"))?; let mut child = git_command() .arg("-C") .arg(repo) .args(args) .stdout(Stdio::from(stdout_file)) .stderr(Stdio::from(stderr_file)) .spawn() .map_err(|err| { let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stderr_path); format!("Could not start Git. Is Git installed? {err}") })?; let status = loop { if let Err(err) = check_search_cancelled(cancellation) { let _ = child.kill(); let _ = child.wait(); let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stderr_path); return Err(err); } if let Some(status) = child .try_wait() .map_err(|err| format!("Could not check Git process: {err}"))? { break status; } thread::sleep(Duration::from_millis(60)); }; let stdout = std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?; let stderr = std::fs::read(&stderr_path) .map_err(|err| format!("Could not read Git error output: {err}"))?; let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stderr_path); if status.success() { return Ok(stdout); } let stderr_text = String::from_utf8_lossy(&stderr); let stdout_text = String::from_utf8_lossy(&stdout); let details = if !stderr_text.trim().is_empty() { stderr_text.trim() } else if !stdout_text.trim().is_empty() { stdout_text.trim() } else { "unknown error" }; Err(format!("{context}: {details}")) } fn run_git_at(path: &Path, args: I, context: &str) -> Result, String> where I: IntoIterator, S: AsRef, { let output = git_command() .arg("-C") .arg(path) .args(args) .output() .map_err(|err| format!("Could not start Git. Is Git installed? {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 { "unknown error" }; Err(format!("{context}: {details}")) } fn run_git_with_stdin(repo: &Path, args: I, stdin_data: &[u8]) -> Result, String> where I: IntoIterator, S: AsRef, { use std::io::Write; let mut child = git_command() .arg("-C") .arg(repo) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(stdin_data) .map_err(|err| format!("Could not send input to Git: {err}"))?; } let output = child .wait_with_output() .map_err(|err| format!("Could not read Git output: {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 { "unknown error" }; Err(format!("Git command failed: {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 repo = temp_dir(name); run_git_test(&repo.path, ["init", "-q"]); run_git_test(&repo.path, ["config", "user.email", "test@example.com"]); run_git_test(&repo.path, ["config", "user.name", "Tester"]); repo } fn init_bare_temp_repo(name: &str) -> TempRepo { let repo = temp_dir(name); run_git_test(&repo.path, ["init", "--bare", "-q"]); repo } fn temp_dir(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"); TempRepo { path } } fn run_git_test(repo: &Path, args: I) where I: IntoIterator, S: AsRef, { let output = git_command() .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 = git_command() .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 clone_directory_name_is_inferred_from_common_remote_urls() { assert_eq!( infer_clone_directory_name("https://github.com/example/project.git"), "project" ); assert_eq!( infer_clone_directory_name("git@github.com:example/project.git"), "project" ); assert_eq!( infer_clone_directory_name("ssh://git@example.com/example/project.git/"), "project" ); } #[test] fn clone_repository_core_clones_and_returns_repository_bundle() { let source = init_temp_repo("clone_source"); commit_initial_file(&source.path); let parent = temp_dir("clone_parent"); let bundle = clone_repository_core( source.path.to_str().expect("source path should be UTF-8"), parent.path.to_str().expect("parent path should be UTF-8"), Some("local-copy"), None, None, Some(100), ) .expect("repository should clone"); let cloned_repo = parent.path.join("local-copy"); assert_eq!( PathBuf::from(bundle.status.repo_path), cloned_repo .canonicalize() .expect("clone path should resolve") ); assert!(cloned_repo.join("old.txt").exists()); assert!(bundle.status.clean); assert_eq!(bundle.commits.len(), 1); assert!(bundle.files.iter().any(|file| file.path == "old.txt")); } #[test] fn search_code_introductions_finds_added_string() { let repo = init_temp_repo("search_added_string"); fs::create_dir_all(repo.path.join("src")).expect("src directory should be created"); fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n") .expect("initial source file should be written"); run_git_test(&repo.path, ["add", "src/app.ts"]); run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]); fs::write( repo.path.join("src/app.ts"), "export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n", ) .expect("updated source file should be written"); run_git_test(&repo.path, ["add", "src/app.ts"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]); let hits = search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None) .expect("search should succeed"); assert_eq!(hits.len(), 1); assert_eq!(hits[0].file, "src/app.ts"); assert_eq!(hits[0].summary, "add render widget"); assert_eq!(hits[0].line_number, Some(3)); } #[test] fn search_code_introductions_finds_multiline_function_block() { let repo = init_temp_repo("search_multiline_function"); fs::write(repo.path.join("module.ts"), "export const ready = true;\n") .expect("initial module should be written"); run_git_test(&repo.path, ["add", "module.ts"]); run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]); let function_body = "export function parseThing() {\n return \"needle-token\";\n}"; fs::write( repo.path.join("module.ts"), format!("export const ready = true;\n\n{function_body}\n"), ) .expect("updated module should be written"); run_git_test(&repo.path, ["add", "module.ts"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]); let hits = search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None) .expect("search should succeed"); assert_eq!(hits.len(), 1); assert_eq!(hits[0].file, "module.ts"); assert_eq!(hits[0].summary, "add parser"); assert_eq!(hits[0].line_number, Some(3)); } #[test] fn search_code_introductions_can_be_cancelled() { let repo = init_temp_repo("search_cancelled"); fs::write( repo.path.join("module.ts"), "export const value = \"needle\";\n", ) .expect("module should be written"); run_git_test(&repo.path, ["add", "module.ts"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]); let state = SearchCancellationState::default(); state .cancel("test-search") .expect("cancel flag should be set"); let result = search_code_introductions_core( &repo.path, "needle".to_string(), false, 20, Some(&SearchCancellation { state: state.clone(), search_id: "test-search".to_string(), }), ); assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE); } #[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_reflog_records() { let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e"; let entries = parse_reflog(raw).expect("reflog should parse"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].selector, "HEAD@{0}"); assert_eq!(entries[0].action, "commit: Add feature"); } #[test] fn commit_editor_applies_only_the_matching_reword_message() { let temp = temp_dir("commit_editor"); let queue = temp.path.join("queue"); let message = temp.path.join("COMMIT_EDITMSG"); fs::write( &queue, "first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e", ) .expect("queue should be written"); fs::write( &message, "first commit\n\n# Please enter the commit message\n", ) .expect("message should be written"); apply_reword_message(&queue, &message).expect("message should be applied"); assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n"); assert_eq!( fs::read_to_string(&queue).unwrap(), "second commit\x1frenamed second\x1e" ); } #[test] fn interactive_rebase_builds_a_valid_reword_and_squash_plan() { let repo = init_temp_repo("interactive_rebase"); commit_initial_file(&repo.path); let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written"); run_git_test(&repo.path, ["add", "first.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]); fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written"); run_git_test(&repo.path, ["add", "second.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]); let commits = interactive_rebase_commits_for_repo(&repo.path, &base) .expect("rebase commits should load"); assert_eq!(commits.len(), 2); let plan = vec![ RebasePlanItem { hash: commits[0].hash.clone(), action: RebaseAction::Reword, message: Some("combined feature".to_string()), }, RebasePlanItem { hash: commits[1].hash.clone(), action: RebaseAction::Squash, message: None, }, ]; validate_rebase_plan(&commits, &plan).expect("plan should be valid"); let todo = build_rebase_todo(&commits, &plan).expect("todo should build"); assert!(todo.contains(&format!("reword {} first commit", commits[0].hash))); assert!(todo.contains(&format!("squash {} second commit", commits[1].hash))); assert_eq!( build_reword_queue(&commits, &plan).expect("queue should build"), "first commit\x1fcombined feature\x1e" ); } #[test] fn interactive_rebase_accepts_a_diverged_base_branch() { let repo = init_temp_repo("interactive_rebase_diverged"); commit_initial_file(&repo.path); let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); fs::write(repo.path.join("feature.txt"), "feature\n") .expect("feature file should be written"); run_git_test(&repo.path, ["add", "feature.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]); run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written"); run_git_test(&repo.path, ["add", "main.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]); run_git_test(&repo.path, ["checkout", "-q", "feature"]); let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch) .expect("diverged base should be accepted"); assert_eq!(commits.len(), 1); assert_eq!(commits[0].summary, "feature commit"); } #[test] fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() { let repo = init_temp_repo("reflog_restore"); commit_initial_file(&repo.path); let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]); fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written"); run_git_test(&repo.path, ["add", "later.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "later"]); let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20)) .expect("reflog should load"); assert!(entries.len() >= 2); let status = restore_reflog_entry( repo.path.to_string_lossy().to_string(), initial.clone(), "recovery/initial".to_string(), ) .expect("recovery branch should be created"); assert_eq!(status.current_branch.as_deref(), Some("recovery/initial")); assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial); assert!( ref_exists(&repo.path, &format!("refs/heads/{original_branch}")) .expect("original branch should still exist") ); } #[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\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\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()], parents: vec![ "2222222222222222222222222222222222222222".to_string(), "3333333333333333333333333333333333333333".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 parse_diff_files_merges_name_status_with_numstat_counts() { let name_status = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0"; let numstat = b"4\t2\tsrc/main.rs\010\t0\tREADME.md\00\t0\t\0old.txt\0new.txt\0"; let files = parse_diff_files(name_status, numstat).unwrap(); assert_eq!( files, vec![ GitDiffFile { path: "src/main.rs".to_string(), old_path: None, status: FileStatusKind::Modified, additions: 4, deletions: 2, }, GitDiffFile { path: "README.md".to_string(), old_path: None, status: FileStatusKind::Added, additions: 10, deletions: 0, }, GitDiffFile { path: "new.txt".to_string(), old_path: Some("old.txt".to_string()), status: FileStatusKind::Renamed, additions: 0, deletions: 0, }, ] ); } #[test] fn parse_numstat_treats_binary_dashes_as_zero() { let counts = parse_numstat_z(b"-\t-\tlogo.png\0"); assert_eq!(counts, vec![("logo.png".to_string(), 0, 0)]); } #[test] fn compare_commits_reports_changes_between_two_commits() { let repo = init_temp_repo("compare_commits"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("old.txt"), "original\nsecond line\n") .expect("tracked file should change"); fs::write(repo.path.join("added.txt"), "brand new\n") .expect("added file should be written"); run_git_test(&repo.path, ["add", "old.txt", "added.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "second"]); let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let comparison = compare_commits( repo.path.to_string_lossy().to_string(), first_commit, second_commit, ) .unwrap(); assert!( comparison .files .iter() .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified) ); assert!( comparison .files .iter() .any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added) ); assert!(comparison.patch.contains("second line")); } #[test] fn compare_commits_includes_full_file_context() { let repo = init_temp_repo("compare_full_context"); let before = (1..=60) .map(|line| format!("line {line}\n")) .collect::(); fs::write(repo.path.join("context.txt"), &before).expect("context file should be written"); run_git_test(&repo.path, ["add", "context.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write( repo.path.join("context.txt"), before.replace("line 30\n", "line 30 changed\n"), ) .expect("context file should be changed"); run_git_test(&repo.path, ["add", "context.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "change"]); let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let comparison = compare_commits( repo.path.to_string_lossy().to_string(), first_commit, second_commit, ) .unwrap(); assert!(comparison.patch.contains(" line 1\n")); assert!(comparison.patch.contains(" line 60\n")); assert!(comparison.patch.contains("-line 30\n")); assert!(comparison.patch.contains("+line 30 changed\n")); } #[test] fn diff_file_against_working_tree_reports_uncommitted_changes() { let repo = init_temp_repo("diff_against_working_tree"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); // Change the file on disk without committing. fs::write(repo.path.join("old.txt"), "working tree change\n") .expect("working tree file should change"); let comparison = diff_file_against_working_tree( repo.path.to_string_lossy().to_string(), first_commit, "old.txt".to_string(), ) .unwrap(); assert_eq!(comparison.to_short, "working tree"); assert!(comparison.to_hash.is_empty()); assert!( comparison .files .iter() .any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified) ); assert!(comparison.patch.contains("working tree change")); } #[test] fn compare_file_to_head_reports_selected_file_against_current_commit() { let repo = init_temp_repo("compare_file_to_head"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("old.txt"), "original\nsecond line\n") .expect("tracked file should change"); fs::write(repo.path.join("other.txt"), "other file\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"]); let head_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let comparison = compare_file_to_head( repo.path.to_string_lossy().to_string(), first_commit, "old.txt".to_string(), ) .unwrap(); assert_eq!(comparison.to_hash, head_commit); assert_eq!(comparison.to_short, "HEAD"); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, "old.txt"); assert!(comparison.patch.contains("second line")); assert!(!comparison.patch.contains("other file")); } #[test] fn compare_file_to_parent_reports_selected_file_change_in_commit() { let repo = init_temp_repo("compare_file_to_parent"); commit_initial_file(&repo.path); let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); fs::write(repo.path.join("old.txt"), "original\nsecond line\n") .expect("tracked file should change"); fs::write(repo.path.join("other.txt"), "other file\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"]); let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let comparison = compare_file_to_parent( repo.path.to_string_lossy().to_string(), second_commit.clone(), "old.txt".to_string(), None, ) .unwrap(); assert_eq!(comparison.from_hash, first_commit); assert_eq!(comparison.to_hash, second_commit); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, "old.txt"); assert!(comparison.patch.contains("second line")); assert!(!comparison.patch.contains("other file")); } #[test] fn compare_file_to_parent_uses_empty_tree_for_initial_commit() { let repo = init_temp_repo("compare_file_to_parent_initial"); commit_initial_file(&repo.path); let initial_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let comparison = compare_file_to_parent( repo.path.to_string_lossy().to_string(), initial_commit.clone(), "old.txt".to_string(), None, ) .unwrap(); assert_eq!(comparison.from_hash, EMPTY_TREE_HASH); assert_eq!(comparison.from_short, "empty tree"); assert_eq!(comparison.to_hash, initial_commit); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, "old.txt"); assert!(comparison.patch.contains("+original")); } #[test] fn commits_for_repo_includes_all_branch_tips_for_graph() { let repo = init_temp_repo("commits_all_branches"); commit_initial_file(&repo.path); let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]); fs::write(repo.path.join("feature.txt"), "feature\n") .expect("feature file should be written"); run_git_test(&repo.path, ["add", "feature.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]); let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]); fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written"); run_git_test(&repo.path, ["add", "main.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]); let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load"); assert!(commits.iter().any(|commit| commit.hash == main_commit)); assert!(commits.iter().any(|commit| { commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph")) })); } #[test] fn commit_pages_use_stable_non_overlapping_offsets() { let repo = init_temp_repo("commit_pages"); commit_initial_file(&repo.path); for index in 1..=3 { fs::write(repo.path.join("old.txt"), format!("version {index}\n")) .expect("tracked file should change"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test( &repo.path, ["commit", "-q", "-m", format!("commit {index}").as_str()], ); } let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load"); let first = commit_page_for_repo(&repo.path, Some(2), Some(0)) .expect("first commit page should load"); let second = commit_page_for_repo(&repo.path, Some(2), Some(2)) .expect("second commit page should load"); assert_eq!(first.len(), 2); assert_eq!(second.len(), 2); assert_eq!(first[0].hash, all[0].hash); assert_eq!(first[1].hash, all[1].hash); assert_eq!(second[0].hash, all[2].hash); assert_eq!(second[1].hash, all[3].hash); assert!( first .iter() .all(|commit| second.iter().all(|other| other.hash != commit.hash)) ); } #[tokio::test] #[cfg_attr( windows, ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" )] async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() { let repo = init_temp_repo("pull_diverged"); commit_initial_file(&repo.path); let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "remote-change"]); fs::write(repo.path.join("remote.txt"), "remote change\n") .expect("remote file should be written"); run_git_test(&repo.path, ["add", "remote.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "remote change"]); run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]); fs::write(repo.path.join("local.txt"), "local change\n") .expect("local file should be written"); run_git_test(&repo.path, ["add", "local.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "local change"]); run_git_test(&repo.path, ["remote", "add", "origin", "."]); run_git_test( &repo.path, ["config", &format!("branch.{base_branch}.remote"), "origin"], ); run_git_test( &repo.path, [ "config", &format!("branch.{base_branch}.merge"), "refs/heads/remote-change", ], ); let status = pull( repo.path.to_string_lossy().to_string(), None, None, None, None, None, ) .await .unwrap(); assert!(status.clean, "{:?}", status.files); assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("local.txt").exists()); let parent_count = git_output_test(&repo.path, ["rev-list", "--parents", "-n", "1", "HEAD"]) .split_whitespace() .count() - 1; assert_eq!(parent_count, 2); } #[test] fn push_args_use_set_upstream_for_branch_without_tracking_remote() { let repo = init_temp_repo("push_sets_upstream"); let remote = init_bare_temp_repo("push_sets_upstream_remote"); commit_initial_file(&repo.path); run_git_test( &repo.path, ["checkout", "-q", "-b", "features/ai_commits_local"], ); fs::write(repo.path.join("feature.txt"), "feature\n") .expect("feature file should be written"); run_git_test(&repo.path, ["add", "feature.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]); run_git_test( &repo.path, ["remote", "add", "origin", remote.path.to_str().unwrap()], ); let args = push_args_for_repo(&repo.path).unwrap(); let args = args .iter() .map(|arg| arg.to_string_lossy().to_string()) .collect::>(); assert_eq!( args, vec![ "push", "--set-upstream", "origin", "features/ai_commits_local" ] ); } #[tokio::test] #[cfg_attr( windows, ignore = "Git for Windows can fail local push tests with a sh signal pipe error" )] async fn push_sets_upstream_for_branch_without_tracking_remote() { let repo = init_temp_repo("push_sets_upstream_integration"); let remote = init_bare_temp_repo("push_sets_upstream_integration_remote"); commit_initial_file(&repo.path); run_git_test( &repo.path, ["checkout", "-q", "-b", "features/ai_commits_local"], ); fs::write(repo.path.join("feature.txt"), "feature\n") .expect("feature file should be written"); run_git_test(&repo.path, ["add", "feature.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]); run_git_test( &repo.path, ["remote", "add", "origin", remote.path.to_str().unwrap()], ); let status = push( repo.path.to_string_lossy().to_string(), None, None, None, None, ) .await .unwrap(); assert_eq!( status.upstream.as_deref(), Some("origin/features/ai_commits_local") ); let local_head = git_output_test(&repo.path, ["rev-parse", "HEAD"]); let remote_head = git_output_test( &remote.path, ["rev-parse", "refs/heads/features/ai_commits_local"], ); assert_eq!(remote_head, local_head); } #[test] fn read_and_resolve_conflict_round_trip() { let repo = init_temp_repo("resolve_conflict"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); run_git_test(&repo.path, ["add", "file.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change"); run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]); run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change"); run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]); // The merge is expected to fail with a conflict, so run git directly. let _ = git_command() .arg("-C") .arg(&repo.path) .args(["merge", "--no-edit", "feature"]) .output() .expect("git merge should start"); let conflict = read_conflict( repo.path.to_string_lossy().to_string(), "file.txt".to_string(), ) .unwrap(); assert_eq!( conflict.ours.unwrap().replace("\r\n", "\n"), "ours change\n" ); assert_eq!( conflict.theirs.unwrap().replace("\r\n", "\n"), "theirs change\n" ); assert!(conflict.content.contains("<<<<<<<")); let status = resolve_conflict( repo.path.to_string_lossy().to_string(), "file.txt".to_string(), "resolved\n".to_string(), ) .unwrap(); assert!(!status.files.iter().any(|file| { matches!(file.staged, Some(FileStatusKind::Conflicted)) || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) })); let contents = fs::read_to_string(repo.path.join("file.txt")).unwrap(); assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); } #[tokio::test] async fn commit_rejects_unresolved_merge_conflicts() { let repo = init_temp_repo("commit_rejects_conflicts"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); run_git_test(&repo.path, ["add", "file.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change"); run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]); run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change"); run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]); let _ = git_command() .arg("-C") .arg(&repo.path) .args(["merge", "--no-edit", "feature"]) .output() .expect("git merge should start"); let err = commit( repo.path.to_string_lossy().to_string(), "should not commit".to_string(), ) .await .unwrap_err(); assert!(err.contains("Merge conflicts")); let status = status_for_repo(&repo.path).unwrap(); assert!(has_unresolved_conflicts(&status)); } #[test] fn detects_binary_content_by_nul_byte() { assert!(is_binary_bytes(&[0u8, 1, 2, 3])); assert!(!is_binary_bytes(b"plain text content\n")); } #[test] fn binary_conflict_can_be_resolved_by_side() { let repo = init_temp_repo("binary_conflict"); fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3]) .expect("base binary should be written"); run_git_test(&repo.path, ["add", "img.bin"]); run_git_test(&repo.path, ["commit", "-q", "-m", "base"]); let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); fs::write(repo.path.join("img.bin"), [0u8, 9, 9, 9, 9, 9]).expect("feature binary"); run_git_test(&repo.path, ["commit", "-q", "-am", "feature bin"]); run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary"); run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]); let _ = git_command() .arg("-C") .arg(&repo.path) .args(["merge", "--no-edit", "feature"]) .output() .expect("git merge should start"); let conflict = read_conflict( repo.path.to_string_lossy().to_string(), "img.bin".to_string(), ) .unwrap(); assert!(conflict.binary); assert!(conflict.content.is_empty()); assert_eq!(conflict.ours_size, Some(3)); assert_eq!(conflict.theirs_size, Some(6)); let status = resolve_conflict_side( repo.path.to_string_lossy().to_string(), "img.bin".to_string(), "ours".to_string(), ) .unwrap(); assert!(!status.files.iter().any(|file| { matches!(file.staged, Some(FileStatusKind::Conflicted)) || matches!(file.unstaged, Some(FileStatusKind::Conflicted)) })); let bytes = fs::read(repo.path.join("img.bin")).unwrap(); assert_eq!(bytes, vec![0u8, 7, 7]); } #[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 create_branch_creates_and_checks_out_local_branch() { let repo = init_temp_repo("create_branch"); commit_initial_file(&repo.path); let status = create_branch( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), None, ) .unwrap(); assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel")); assert!( ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(), "new branch should exist" ); let err = create_branch( repo.path.to_string_lossy().to_string(), "feature/new-panel".to_string(), None, ) .unwrap_err(); assert!(err.contains("already exists")); } #[test] fn rename_branch_renames_existing_local_branch() { let repo = init_temp_repo("rename_branch"); commit_initial_file(&repo.path); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["branch", "feature/old-panel"]); let status = rename_branch( repo.path.to_string_lossy().to_string(), "feature/old-panel".to_string(), "feature/new-panel".to_string(), ) .unwrap(); assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert!( !ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(), "old branch should be gone" ); assert!( ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(), "new branch should exist" ); } #[test] fn delete_branch_removes_local_branch_but_rejects_current_branch() { let repo = init_temp_repo("delete_branch"); commit_initial_file(&repo.path); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["branch", "stale"]); let status = delete_branch( repo.path.to_string_lossy().to_string(), "stale".to_string(), None, ) .unwrap(); assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert!( !ref_exists(&repo.path, "refs/heads/stale").unwrap(), "deleted branch should be gone" ); let err = delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err(); assert!(err.contains("current branch")); } #[test] fn delete_branch_can_force_delete_unmerged_branch() { let repo = init_temp_repo("delete_branch_force"); commit_initial_file(&repo.path); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]); fs::write(repo.path.join("feature.txt"), "feature\n") .expect("feature file should be written"); run_git_test(&repo.path, ["add", "feature.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]); run_git_test(&repo.path, ["checkout", "-q", current.as_str()]); let err = delete_branch( repo.path.to_string_lossy().to_string(), "feature/unmerged".to_string(), Some(false), ) .unwrap_err(); assert!(err.contains("not fully merged")); delete_branch( repo.path.to_string_lossy().to_string(), "feature/unmerged".to_string(), Some(true), ) .unwrap(); assert!( !ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(), "force-deleted branch should be gone" ); } #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n") .expect("initial file should be written"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "init"]); fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n") .expect("changed file should be written"); let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"; let status = apply_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), selected_patch.to_string(), "stage".to_string(), ) .expect("selected line should stage"); assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified)); assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified)); assert_eq!( git_output_test(&repo.path, ["show", ":old.txt"]), "one\nTWO\nthree" ); assert_eq!( fs::read_to_string(repo.path.join("old.txt")) .expect("working tree should be readable") .replace("\r\n", "\n"), "one\nTWO\nthree\nfour\n" ); let staged_patch = get_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), true, ) .expect("staged patch should load"); let status = apply_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), staged_patch, "unstage".to_string(), ) .expect("selected staged lines should unstage"); assert_eq!(status.files[0].staged, None); assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified)); assert_eq!( git_output_test(&repo.path, ["show", ":old.txt"]), "one\ntwo\nthree" ); let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"; apply_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), selected_patch.to_string(), "stage".to_string(), ) .expect("selected line should stage again"); let unstaged_patch = get_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), false, ) .expect("unstaged patch should load"); assert!(unstaged_patch.contains("+four")); let status = apply_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), unstaged_patch, "discard-unstaged".to_string(), ) .expect("unstaged line should discard"); assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified)); assert_eq!(status.files[0].unstaged, None); assert_eq!( fs::read_to_string(repo.path.join("old.txt")) .expect("working tree should be readable") .replace("\r\n", "\n"), "one\nTWO\nthree\n" ); } #[test] fn get_file_patch_splits_distant_changes_like_interactive_diff() { let repo = init_temp_repo("file_patch_hunks"); let original = (1..=30) .map(|line| format!("line {line}\n")) .collect::(); fs::write(repo.path.join("old.txt"), original).expect("initial file should be written"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "init"]); let changed = (1..=30) .map(|line| match line { 5 => "line five changed\n".to_string(), 20 => "line twenty changed\n".to_string(), _ => format!("line {line}\n"), }) .collect::(); fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written"); let patch = get_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), false, ) .expect("patch should load"); let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count(); assert_eq!(hunk_count, 2, "{patch}"); } #[test] fn restore_to_commit_leaves_branch_untouched_and_stages_change_as_unstaged() { 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 second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); 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(); // The branch must stay exactly where it was: no commit is rewritten or hidden. assert_eq!(current_commit, second_commit); assert_ne!(current_commit, first_commit); // The old content lands in the worktree as a reviewable, unstaged change. assert_eq!(contents.replace("\r\n", "\n"), "original\n"); assert!(!status.clean, "{:?}", status.files); assert_eq!( status .files .iter() .find(|f| f.path == "old.txt") .and_then(|f| f.unstaged), Some(FileStatusKind::Modified) ); } #[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_core(&repo.path, "old.txt".to_string(), Some(10), None).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_core(&repo.path, "src".to_string(), Some(10), None).unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0].summary, "src update"); assert_eq!(commits[1].summary, "src initial"); } #[test] fn list_file_history_can_be_cancelled() { let repo = init_temp_repo("file_history_cancelled"); commit_initial_file(&repo.path); let state = SearchCancellationState::default(); state .cancel("file-history-test") .expect("cancel flag should be set"); let result = list_file_history_core( &repo.path, "old.txt".to_string(), Some(10), Some(&SearchCancellation { state, search_id: "file-history-test".to_string(), }), ); assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE); } #[test] fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() { let repo = init_temp_repo("file_blame"); commit_initial_file(&repo.path); fs::write(repo.path.join("old.txt"), "original\ntwo\n") .expect("tracked file should change"); run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]); let result = get_file_blame( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), ) .unwrap(); assert_eq!(result.path, "old.txt"); assert_eq!(result.lines.len(), 2); assert_eq!(result.lines[0].content, "original"); assert_eq!(result.lines[0].summary, "init"); assert_eq!(result.lines[1].content, "two"); assert_eq!(result.lines[1].summary, "add second line"); assert!(!result.lines[1].author_name.is_empty()); assert!(!result.lines[1].is_uncommitted); } #[test] fn get_file_blame_marks_uncommitted_working_tree_changes() { let repo = init_temp_repo("file_blame_uncommitted"); commit_initial_file(&repo.path); fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change"); let result = get_file_blame( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), ) .unwrap(); assert_eq!(result.lines.len(), 1); assert!(result.lines[0].is_uncommitted); } #[test] fn parse_worktree_porcelain_preserves_flags_and_reasons() { let current = Path::new("/repos/main"); let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0"; let rows = parse_worktree_porcelain(output, current); assert_eq!(rows.len(), 2); assert!(rows[0].is_main); assert_eq!(rows[0].branch.as_deref(), Some("main")); assert_eq!(rows[0].short_head.as_deref(), Some("12345678")); assert!(rows[1].detached); assert!(rows[1].locked); assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive")); assert!(rows[1].prunable); } #[test] fn worktree_add_list_and_remove_round_trip() { let repo = init_temp_repo("worktree_round_trip"); let destination = temp_dir("worktree_round_trip_destination"); commit_initial_file(&repo.path); run_git_test(&repo.path, ["branch", "feature"]); let rows = add_worktree( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some("feature".to_string()), None, None, Some(false), Some(false), ) .expect("worktree should be created"); let linked = rows .iter() .find(|row| row.branch.as_deref() == Some("feature")) .expect("linked worktree should be listed"); assert!(!linked.is_main); assert!(linked.clean); let rows = lock_worktree( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some("test lock".to_string()), ) .expect("worktree should lock"); let linked = rows .iter() .find(|row| row.branch.as_deref() == Some("feature")) .expect("linked worktree should remain listed"); assert!(linked.locked); assert_eq!(linked.lock_reason.as_deref(), Some("test lock")); unlock_worktree( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), ) .expect("worktree should unlock"); let rows = remove_worktree( repo.path.to_string_lossy().to_string(), destination.path.to_string_lossy().to_string(), Some(false), ) .expect("worktree should be removed"); assert_eq!(rows.len(), 1); } #[test] fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() { let raw = r#"```json {"summary":"One issue found","risk":"HIGH","findings":[{"severity":"warn","title":"Unchecked result","description":"The new call ignores an error.","file":"src/main.rs","line":42,"suggestion":"Propagate the error."}]} ```"#; let review = parse_ai_review(raw).expect("review JSON should parse"); assert_eq!(review.risk, AiReviewRisk::High); assert_eq!(review.findings.len(), 1); assert_eq!(review.findings[0].severity, AiReviewSeverity::Warning); assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs")); assert_eq!(review.findings[0].line, Some(42)); } #[test] fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() { let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()]; let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#; let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse"); assert_eq!(plan.groups.len(), 2); let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#; assert!(parse_ai_commit_plan(duplicate, &files).is_err()); } }