Files
GitLite/src-tauri/src/git.rs
T
Christoph Brandau e3df75cc38 add new Context Menu for file Explorer
fix blocking UI
when select the file in Status also select in the file History
2026-07-02 16:24:38 +02:00

4233 lines
135 KiB
Rust

use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
path::{Path, PathBuf},
process::{Command, Output, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
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<String>,
pub staged: Option<FileStatusKind>,
pub unstaged: Option<FileStatusKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStatus {
pub repo_path: String,
pub current_branch: Option<String>,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub files: Vec<GitFileStatus>,
pub clean: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitBranch {
pub name: String,
pub current: bool,
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub date: String,
pub refs: Vec<String>,
pub parents: Vec<String>,
pub files: Vec<GitCommitFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommitFile {
pub path: String,
pub old_path: Option<String>,
pub status: FileStatusKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitDiffFile {
pub path: String,
pub old_path: Option<String>,
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<GitDiffFile>,
pub patch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConflictFile {
pub path: String,
pub content: String,
pub ours: Option<String>,
pub theirs: Option<String>,
pub base: Option<String>,
pub binary: bool,
pub ours_size: Option<u64>,
pub theirs_size: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitRepositoryFile {
pub path: String,
pub tracked: bool,
pub status: Option<FileStatusKind>,
}
#[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<String>,
pub line_number: Option<u32>,
pub line: String,
pub matches_added: u32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo {
current_branch: Option<String>,
upstream: Option<String>,
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 = "Suche wurde abgebrochen.";
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn git_command() -> Command {
let mut command = Command::new("git");
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command
}
#[derive(Debug, Default, Clone)]
pub struct SearchCancellationState {
cancelled: Arc<Mutex<BTreeSet<String>>>,
}
impl SearchCancellationState {
fn cancel(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.insert(search_id.to_string());
Ok(())
}
fn clear(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.remove(search_id);
Ok(())
}
fn is_cancelled(&self, search_id: &str) -> Result<bool, String> {
Ok(self
.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".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<GitStatus, String> {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
}
#[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!("Datei '{file}' existiert im Working Tree nicht."));
}
if !file_path.is_file() {
return Err(format!("'{file}' ist keine Datei."));
}
reveal_path_in_file_manager(&file_path)
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
}
/// 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<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
commits,
files,
})
})
.await
.map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))?
}
#[tauri::command]
pub fn get_status(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
let repo = resolve_repo(&path)?;
branches_for_repo(&repo)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, 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)
}
#[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim().to_string();
if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string());
}
match checkout_plan(&repo, &branch)? {
CheckoutPlan::Local(local) => {
run_git(&repo, ["checkout", local.as_str()])?;
}
CheckoutPlan::TrackRemote { local, remote } => {
run_git(
&repo,
["checkout", "--track", "-b", local.as_str(), remote.as_str()],
)?;
}
CheckoutPlan::Raw(target) => {
run_git(&repo, ["checkout", target.as_str()])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn create_branch(
path: String,
branch: String,
start_point: Option<String>,
) -> Result<GitStatus, String> {
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<GitStatus, String> {
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) -> Result<GitStatus, String> {
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("Der aktuelle Branch kann nicht geloescht werden.".to_string());
}
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
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<String> = Vec::new();
for file in &files {
match find_status(&current_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)
}
#[tauri::command]
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() {
let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
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, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
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 apply_file_patch(
path: String,
file: String,
patch: String,
action: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
if patch.trim().is_empty() {
return Err("Kein Patch ausgewaehlt.".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("Ungueltige Patch-Aktion.".to_string()),
};
let _ = std::fs::remove_file(&patch_path);
result?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if message.trim().is_empty() {
return Err("Commit-Message darf nicht leer sein.".to_string());
}
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err(
"Merge-Konflikte muessen geloest werden, bevor du committen kannst.".to_string(),
);
}
run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn pull(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
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, u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(pull_args)
.output()
.map_err(|err| {
format!("Git konnte nicht gestartet werden. Ist Git installiert? {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-Befehl fehlgeschlagen: {details}"))
}
#[tauri::command]
pub fn push(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, ["push"], u, p)?;
}
_ => {
run_git(&repo, ["push"])?;
}
}
status_for_repo(&repo)
}
// ── 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<String>,
}
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
let key = key.trim();
if key.is_empty() {
return Err("Kein Schlüssel für die Zugangsdaten angegeben.".to_string());
}
keyring::Entry::new(CRED_SERVICE, key)
.map_err(|err| format!("Schlüsselbund nicht verfügbar: {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<Option<String>, 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<String> {
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 upstream_remote_name(repo: &Path) -> Option<String> {
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return None;
}
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<String> {
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)
}
#[tauri::command]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(&key)?;
match entry.get_password() {
Ok(json) => {
let cred = serde_json::from_str::<StoredCredential>(&json)
.map_err(|err| format!("Gespeicherte Zugangsdaten unlesbar: {err}"))?;
Ok(Some(cred))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Schlüsselbund-Zugriff fehlgeschlagen: {err}")),
}
}
#[tauri::command]
pub fn cred_save(
key: String,
username: String,
password: String,
expires_at: Option<String>,
) -> 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!("Zugangsdaten konnten nicht serialisiert werden: {err}"))?;
entry
.set_password(&json)
.map_err(|err| format!("Speichern im Schlüsselbund fehlgeschlagen: {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!("Löschen im Schlüsselbund fehlgeschlagen: {err}")),
}
}
#[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["merge", "--no-edit", branch])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {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 {
"unbekannter Fehler"
};
Err(format!("Merge fehlgeschlagen: {details}"))
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
commits_for_repo(&repo, limit)
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
if verify_commit(repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let limit = limit.unwrap_or(100).clamp(1, 500).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",
"--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",
"-n",
limit.as_str(),
],
)?;
parse_commit_log_inline(&output)
}
#[tauri::command]
pub fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
let repo = resolve_repo(&path)?;
repository_files(&repo)
}
#[tauri::command]
pub async fn list_file_history(
path: String,
file: String,
limit: Option<u32>,
request_id: Option<String>,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<Vec<GitCommit>, 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!("Dateihistorie konnte nicht geladen werden: {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)
}
fn list_file_history_core(
repo: &Path,
file: String,
limit: Option<u32>,
cancellation: Option<&SearchCancellation>,
) -> Result<Vec<GitCommit>, 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-Dateihistorie fehlgeschlagen")?;
check_search_cancelled(cancellation)?;
parse_commit_log(repo, &output)
}
#[tauri::command]
pub async fn search_code_introductions(
path: String,
query: String,
case_sensitive: Option<bool>,
limit: Option<u32>,
search_id: Option<String>,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<Vec<GitSearchHit>, 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("Suchtext darf nicht leer sein.".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!("Such-Task konnte nicht abgeschlossen werden: {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<Vec<GitSearchHit>, 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<GitSearchCommitMetadata> = 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<GitStatus, String> {
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<GitStatus, String> {
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<GitCommitComparison, String> {
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",
"-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<GitCommitComparison, String> {
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", "-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<GitCommitComparison, String> {
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",
"-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<String>,
) -> Result<GitCommitComparison, String> {
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",
"-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<ConflictFile, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
let bytes = std::fs::read(repo.join(&file))
.map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {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<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
let flag = match side.as_str() {
"ours" => "--ours",
"theirs" => "--theirs",
_ => return Err("Ungueltige Seite. Erlaubt sind 'ours' oder '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<u64> {
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<GitStatus, String> {
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!("Verzeichnis konnte nicht erstellt werden: {err}"))?;
}
std::fs::write(&target, content)
.map_err(|err| format!("Konfliktdatei konnte nicht geschrieben werden: {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<String> {
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<PathBuf, String> {
if path.trim().is_empty() {
return Err("Repository-Pfad darf nicht leer sein.".to_string());
}
let input = PathBuf::from(path);
let output = run_git_at(
&input,
["rev-parse", "--show-toplevel"],
"Kein Git-Repository oder nicht erreichbar",
)?;
let top_level = String::from_utf8_lossy(&output).trim().to_string();
if top_level.is_empty() {
return Err("Git konnte keinen Repository-Wurzelpfad ermitteln.".to_string());
}
Ok(PathBuf::from(top_level))
}
#[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!("Explorer konnte nicht gestartet werden: {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!("Finder konnte nicht gestartet werden: {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!("Dateimanager konnte nicht gestartet werden: {err}"))?;
Ok(())
}
fn resolve_repo_child_path(repo: &Path, child: &str) -> Result<PathBuf, String> {
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("Dateipfad muss innerhalb des Repositorys liegen.".to_string());
}
let candidate = repo.join(child_path);
let repo = repo
.canonicalize()
.map_err(|err| format!("Repository-Pfad konnte nicht aufgeloest werden: {err}"))?;
let candidate = candidate
.canonicalize()
.map_err(|err| format!("Dateipfad konnte nicht aufgeloest werden: {err}"))?;
if !candidate.starts_with(&repo) {
return Err("Dateipfad liegt ausserhalb des Repositorys.".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!("Explorer konnte nicht gestartet werden: {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!("Finder konnte nicht gestartet werden: {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!("Dateimanager konnte nicht gestartet werden: {err}"))?;
Ok(())
}
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
let commit = commit.trim();
if commit.is_empty() {
return Err("Commit darf nicht leer sein.".to_string());
}
let rev = format!("{commit}^{{commit}}");
let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()])
.map_err(|err| format!("Commit konnte nicht gefunden werden: {err}"))?;
let hash = String::from_utf8_lossy(&output).trim().to_string();
if hash.is_empty() {
return Err("Commit konnte nicht gefunden werden.".to_string());
}
Ok(hash)
}
fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
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);
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,
})
}
// `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<GitFileStatus>) {
let deleted_paths: Vec<String> = files
.iter()
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
.map(|f| f.path.clone())
.collect();
let untracked_paths: Vec<String> = 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<Vec<(String, String)>, String> {
let mut args: Vec<OsString> = 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<Vec<(String, String)>, 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> = 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<Vec<GitRepositoryFile>, String> {
let status = status_for_repo(repo)?;
repository_files_with_status(repo, &status)
}
fn repository_files_with_status(
repo: &Path,
status: &GitStatus,
) -> Result<Vec<GitRepositoryFile>, String> {
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
for path in parse_nul_paths(&tracked_output) {
let status = status_for_file(&status.files, &path);
files.insert(
path.clone(),
GitRepositoryFile {
path,
tracked: true,
status,
},
);
}
let untracked_output = run_git(repo, ["ls-files", "-z", "--others", "--exclude-standard"])?;
for path in parse_nul_paths(&untracked_output) {
let status = status_for_file(&status.files, &path).or(Some(FileStatusKind::Untracked));
files.insert(
path.clone(),
GitRepositoryFile {
path,
tracked: false,
status,
},
);
}
Ok(files.into_values().collect())
}
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
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<String> {
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<FileStatusKind> {
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<Vec<String>, String> {
let output = if query.contains('\n') {
run_git_cancellable(
repo,
["rev-list", "--all", "--reverse"],
cancellation,
"Git-Suche fehlgeschlagen",
)?
} 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-Suche fehlgeschlagen")?
};
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<Vec<String>, 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<usize, String> {
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<Option<String>, String> {
let spec = format!("{commit}:{file}");
let output = git_command()
.arg("-C")
.arg(repo)
.args(["show", spec.as_str()])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {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<Option<(u32, String)>, 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 fuer Suchtreffer fehlgeschlagen",
)?;
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<u32> {
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<GitSearchCommitMetadata, String> {
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!("Unerwarteter Git-Commit-Eintrag: {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<Vec<GitCommit>, 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!(
"Unerwarteter Git-Log-Eintrag: {}",
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<Vec<GitCommit>, 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!("Unerwarteter Git-Log-Eintrag: {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<Vec<GitCommit>, 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!("Unerwarteter Git-Log-Eintrag: {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<Vec<GitCommitFile>, 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<Vec<GitCommitFile>, String> {
let entries: Vec<&[u8]> = output
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
.collect();
let mut files = Vec::new();
let mut index = 0;
while index < entries.len() {
let status_text = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
let status = map_name_status(&status_text);
if index >= entries.len() {
return Err(format!("Git-Diff-Eintrag ohne Pfad: {status_text}"));
}
if matches!(status, FileStatusKind::Renamed) {
let old_path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
if index >= entries.len() {
return Err(format!("Git-Rename ohne Zielpfad: {old_path}"));
}
let path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
files.push(GitCommitFile {
path,
old_path: Some(old_path),
status,
});
} else {
let path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
files.push(GitCommitFile {
path,
old_path: None,
status,
});
}
}
Ok(files)
}
fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result<Vec<GitDiffFile>, 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<String> = 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<CheckoutPlan, String> {
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<String, String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string());
}
let output = git_command()
.arg("-C")
.arg(repo)
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Ungueltiger 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}' existiert bereits."));
}
Ok(normalized)
}
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string());
}
let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!(
"Lokaler Branch '{normalized}' wurde nicht gefunden."
));
}
Ok(normalized)
}
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
let output = git_command()
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Ungueltiger 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<bool, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", ref_name])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => {
let stderr = String::from_utf8_lossy(&output.stderr);
let details = if stderr.trim().is_empty() {
"unbekannter Fehler".to_string()
} else {
stderr.trim().to_string()
};
Err(format!("Git-Ref konnte nicht geprueft werden: {details}"))
}
}
}
fn restore_worktree_files(
repo: &Path,
statuses: &[GitFileStatus],
files: &[String],
) -> Result<(), String> {
let mut restore_paths = Vec::new();
let mut clean_paths = Vec::new();
for file in files {
let status = find_status(statuses, file);
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("Dateiliste enthaelt einen leeren Pfad.".to_string());
}
Ok(())
}
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
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!("Patch-Datei konnte nicht geschrieben werden: {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<std::path::PathBuf, String> {
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!("Konnte Authentifizierungsskript nicht schreiben: {e}"))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Konnte Skriptrechte nicht setzen: {e}"))?;
Ok(path)
}
#[cfg(not(unix))]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
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!("Konnte Authentifizierungsskript nicht schreiben: {e}"))?;
Ok(path)
}
fn run_git_authenticated<I, S>(
repo: &Path,
args: I,
username: &str,
password: &str,
) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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-Befehl fehlgeschlagen: {details}"))
}
fn run_git_authenticated_output<I, S>(
repo: &Path,
args: I,
username: &str,
password: &str,
) -> Result<Output, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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!("Git konnte nicht gestartet werden: {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 {
"Unbekannter Fehler".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")
}
fn run_git_with_paths(
repo: &Path,
base_args: &[&str],
files: &[String],
) -> Result<Vec<u8>, String> {
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(files.iter().map(OsString::from));
run_git(repo, args)
}
fn run_git_with_paths_cancellable(
repo: &Path,
base_args: &[&str],
files: &[String],
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, 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<I, S>(repo: &Path, args: I) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_git_at(repo, args, "Git-Befehl fehlgeschlagen")
}
fn run_git_cancellable<I, S>(
repo: &Path,
args: I,
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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!("Git-Ausgabedatei konnte nicht erstellt werden: {err}"))?;
let stderr_file = std::fs::File::create(&stderr_path)
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {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!("Git konnte nicht gestartet werden. Ist Git installiert? {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!("Git-Prozess konnte nicht geprueft werden: {err}"))?
{
break status;
}
thread::sleep(Duration::from_millis(60));
};
let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Git-Fehlerausgabe konnte nicht gelesen werden: {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 {
"unbekannter Fehler"
};
Err(format!("{context}: {details}"))
}
fn run_git_at<I, S>(path: &Path, args: I, context: &str) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = git_command()
.arg("-C")
.arg(path)
.args(args)
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() {
stderr.trim()
} else if !stdout.trim().is_empty() {
stdout.trim()
} else {
"unbekannter Fehler"
};
Err(format!("{context}: {details}"))
}
fn run_git_with_stdin<I, S>(repo: &Path, args: I, stdin_data: &[u8]) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_data)
.map_err(|err| format!("Eingabe konnte nicht an Git gesendet werden: {err}"))?;
}
let output = child
.wait_with_output()
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() {
stderr.trim()
} else if !stdout.trim().is_empty() {
stdout.trim()
} else {
"unbekannter Fehler"
};
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
}
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), 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<FileStatusKind>, Option<FileStatusKind>) {
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<FileStatusKind> {
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<FileStatusKind> {
match code {
' ' => None,
'M' => Some(FileStatusKind::Modified),
'A' => Some(FileStatusKind::Added),
'D' => Some(FileStatusKind::Deleted),
'R' => Some(FileStatusKind::Renamed),
'C' => Some(FileStatusKind::Added),
_ => Some(FileStatusKind::Unknown),
}
}
fn is_rename_or_copy(code: char) -> bool {
matches!(code, 'R' | 'C')
}
fn is_conflict(staged_code: char, unstaged_code: char) -> bool {
matches!(
(staged_code, unstaged_code),
('D', 'D') | ('A', 'U') | ('U', 'D') | ('U', 'A') | ('D', 'U') | ('A', 'A') | ('U', 'U')
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
ffi::OsStr,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
struct TempRepo {
path: PathBuf,
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn init_temp_repo(name: &str) -> TempRepo {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after Unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"tauri_git_lite_{name}_{}_{}",
std::process::id(),
nanos
));
fs::create_dir_all(&path).expect("temp repo directory should be created");
run_git_test(&path, ["init", "-q"]);
run_git_test(&path, ["config", "user.email", "test@example.com"]);
run_git_test(&path, ["config", "user.name", "Tester"]);
TempRepo { path }
}
fn run_git_test<I, S>(repo: &Path, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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<I, S>(repo: &Path, args: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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 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_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::<String>();
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]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
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).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 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");
}
#[test]
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(),
)
.unwrap_err();
assert!(err.contains("Merge-Konflikte"));
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("existiert bereits"));
}
#[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()).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).unwrap_err();
assert!(err.contains("aktuelle Branch"));
}
#[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 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::<String>();
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::<String>();
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);
}
}