Files
GitLite/src-tauri/src/git.rs
T
Christoph c3762ae7a3
publish / Build and publish Windows installer (release) Successful in 10m2s
publish / Build and publish Ubuntu AppImage (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 22m1s
feat(git): add opt-in for unrelated histories during pull
Allow pulling repositories with unrelated commit histories when the user
explicitly opts in. The pull argument construction was refactored and
branch resolution made more robust so the backend can include the
--allow-unrelated-histories flag when requested.

- Extract pull argument logic and add support for allowing unrelated histories.
- Prompt users in the UI to confirm merging separate histories and retry pull.
- Restyle and improve the update toast UI for better layout and responsiveness.
2026-08-30 20:25:15 +02:00

9800 lines
320 KiB
Rust

use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
env,
ffi::{OsStr, OsString},
fs,
io::Write,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
thread,
time::Duration,
};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FileStatusKind {
Modified,
Added,
Deleted,
Renamed,
Untracked,
Conflicted,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GitIgnoreKind {
File,
Extension,
Folder,
}
#[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,
pub rebase_in_progress: bool,
pub cherry_pick_in_progress: bool,
pub merge_in_progress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitRemote {
pub name: String,
pub fetch_url: String,
pub push_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitBranch {
pub name: String,
pub current: bool,
pub remote: bool,
pub upstream: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitWorktree {
pub path: String,
pub head: Option<String>,
pub short_head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
pub lock_reason: Option<String>,
pub prunable: bool,
pub prune_reason: Option<String>,
pub missing: bool,
pub is_main: bool,
pub is_current: bool,
pub clean: bool,
pub changed_files: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitTag {
pub name: String,
pub hash: String,
pub short_hash: String,
pub message: Option<String>,
pub date: String,
pub annotated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash {
pub selector: String,
pub index: u32,
pub hash: String,
pub branch: Option<String>,
pub message: String,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub date: String,
pub refs: Vec<String>,
pub parents: Vec<String>,
pub files: Vec<GitCommitFile>,
pub has_note: bool,
}
#[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 GitBlameLine {
pub line_number: u32,
pub content: String,
pub commit_hash: String,
pub short_hash: String,
pub author_name: String,
pub author_email: String,
pub author_time: i64,
pub summary: String,
pub is_uncommitted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitBlameResult {
pub path: String,
pub lines: Vec<GitBlameLine>,
}
#[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, Deserialize)]
pub struct GitLfsPattern {
pub pattern: String,
pub source: String,
#[serde(default)]
pub lockable: bool,
#[serde(default = "default_true")]
pub tracked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitLfsFile {
pub name: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub checkout: bool,
#[serde(default)]
pub downloaded: bool,
#[serde(default)]
pub oid_type: String,
#[serde(default)]
pub oid: String,
#[serde(default)]
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitLfsStatus {
pub available: bool,
pub bundled: bool,
pub version: Option<String>,
pub filters_configured: bool,
pub hook_installed: bool,
pub repository_uses_lfs: bool,
pub patterns: Vec<GitLfsPattern>,
pub files: Vec<GitLfsFile>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsPatternsOutput {
#[serde(default)]
patterns: Vec<GitLfsPattern>,
}
#[derive(Debug, Default, Deserialize)]
struct GitLfsFilesOutput {
#[serde(default)]
files: Option<Vec<GitLfsFile>>,
}
const fn default_true() -> bool {
true
}
#[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, Clone, PartialEq, Eq, Serialize)]
pub struct RebaseCommit {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub date: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RebaseAction {
Pick,
Reword,
Squash,
Fixup,
Drop,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RebasePlanItem {
pub hash: String,
pub action: RebaseAction,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReflogEntry {
pub hash: String,
pub short_hash: String,
pub selector: String,
pub action: String,
pub author_name: String,
pub date: String,
}
const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN";
const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE";
const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo";
const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages";
const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor";
const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor";
pub fn run_sequence_editor_if_requested() -> Option<Result<(), String>> {
let executable = env::current_exe().ok()?;
let executable_name = executable.file_name()?.to_string_lossy();
let target = env::args_os()
.nth(1)
.ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string());
if executable_name.contains("gitty-sequence-editor") {
let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?;
return Some(target.and_then(|target| {
fs::copy(PathBuf::from(plan), PathBuf::from(target))
.map(|_| ())
.map_err(|err| format!("Could not write the interactive rebase plan: {err}"))
}));
}
if executable_name.contains("gitty-commit-editor") {
let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?;
return Some(
target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))),
);
}
None
}
fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> {
let queue = fs::read_to_string(queue_path)
.map_err(|err| format!("Could not read the reword queue: {err}"))?;
if queue.is_empty() {
return Ok(());
}
let current = fs::read_to_string(message_path)
.map_err(|err| format!("Could not read the commit message: {err}"))?;
let current_subject = current
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.unwrap_or("");
let mut remaining = Vec::new();
let mut replacement = None;
for record in queue.split('\x1e').filter(|record| !record.is_empty()) {
let Some((old, new)) = record.split_once('\x1f') else {
return Err("The reword queue is malformed.".to_string());
};
if replacement.is_none() && old == current_subject {
replacement = Some(new.to_string());
} else {
remaining.push(record);
}
}
if let Some(message) = replacement {
fs::write(message_path, format!("{message}\n"))
.map_err(|err| format!("Could not update the commit message: {err}"))?;
let mut next_queue = remaining.join("\x1e");
if !next_queue.is_empty() {
next_queue.push('\x1e');
}
fs::write(queue_path, next_queue)
.map_err(|err| format!("Could not update the reword queue: {err}"))?;
}
Ok(())
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo {
current_branch: Option<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 = "Search was cancelled.";
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn git_command() -> Command {
let mut command = Command::new("git");
// Force English output regardless of the system locale, so is_auth_error()
// and other message heuristics keep working (e.g. German git prints
// "Authentifizierung fehlgeschlagen" instead of "Authentication failed").
command.env("LC_ALL", "C");
if let Some(path) = command_path_with_bundled_lfs() {
command.env("PATH", path);
}
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command
}
fn bundled_git_lfs_path() -> Option<PathBuf> {
#[cfg(windows)]
const EXECUTABLE_NAME: &str = "git-lfs.exe";
#[cfg(not(windows))]
const EXECUTABLE_NAME: &str = "git-lfs";
if let Ok(executable) = env::current_exe()
&& let Some(directory) = executable.parent()
{
let candidate = directory.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
if cfg!(debug_assertions) {
let candidate = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("binaries")
.join(EXECUTABLE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn command_path_with_bundled_lfs() -> Option<OsString> {
let directory = bundled_git_lfs_path()?.parent()?.to_path_buf();
let mut paths = vec![directory];
if let Some(current) = env::var_os("PATH") {
paths.extend(env::split_paths(&current));
}
env::join_paths(paths).ok()
}
#[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(|_| "Search cancellation status is unavailable.".to_string())?
.insert(search_id.to_string());
Ok(())
}
fn clear(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Search cancellation status is unavailable.".to_string())?
.remove(search_id);
Ok(())
}
fn is_cancelled(&self, search_id: &str) -> Result<bool, String> {
Ok(self
.cancelled
.lock()
.map_err(|_| "Search cancellation status is unavailable.".to_string())?
.contains(search_id))
}
}
#[derive(Clone)]
struct SearchCancellation {
state: SearchCancellationState,
search_id: String,
}
fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> {
if let Some(cancellation) = cancellation {
if cancellation.state.is_cancelled(&cancellation.search_id)? {
return Err(SEARCH_CANCELLED_MESSAGE.to_string());
}
}
Ok(())
}
#[tauri::command(async)]
pub fn open_repository(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
let path = PathBuf::from(path.trim());
if path.as_os_str().is_empty() {
return Err("Repository path must not be empty.".to_string());
}
fs::create_dir_all(&path)
.map_err(|err| format!("Could not create repository folder: {err}"))?;
let branch = initial_branch.unwrap_or_else(|| "main".to_string());
let branch = branch.trim();
if branch.is_empty() {
return Err("Initial branch must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&path)
.args(["init", "-b", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
return Err(format!(
"Could not initialize repository: {}",
command_output_details(&output)
));
}
status_for_repo(&path)
}
#[tauri::command(async)]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
open_path_in_file_manager(&repo)
}
#[tauri::command(async)]
pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
let file_path = resolve_repo_child_path(&repo, &file)?;
if !file_path.exists() {
return Err(format!("File '{file}' does not exist in the working tree."));
}
if !file_path.is_file() {
return Err(format!("'{file}' is not a file."));
}
reveal_path_in_file_manager(&file_path)
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub tags: Vec<GitTag>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
pub warning: Option<String>,
}
async fn run_git_task<T, F>(context: &'static str, task: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static,
{
tauri::async_runtime::spawn_blocking(task)
.await
.map_err(|error| format!("{context}: {error}"))?
}
fn join_git_worker<T>(name: &str, result: thread::Result<Result<T, String>>) -> Result<T, String> {
result.map_err(|_| format!("The {name} Git worker stopped unexpectedly."))?
}
fn repository_bundle_for_repo(
repo: &Path,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
// Status is needed by the file tree. Once it is available, all remaining
// reads are independent and can run concurrently. Each worker only starts
// read-only Git processes, so this is safe while cutting the former
// branches -> tags -> stashes -> commits -> files waterfall down to the
// duration of its slowest member.
let status = status_for_repo(repo)?;
// A freshly initialized or cloned empty repository has a symbolic HEAD,
// but it does not resolve to a commit yet (an "unborn" HEAD). Some Git
// commands and Git extensions treat that as a hard revision error. Keep
// the repository usable and still report any untracked working-tree files
// without starting commit-dependent workers.
if verify_commit(repo, "HEAD").is_err() {
let files = repository_files_with_status(repo, &status)?;
return Ok(RepositoryBundle {
status,
branches: Vec::new(),
tags: Vec::new(),
stashes: Vec::new(),
commits: Vec::new(),
files,
warning: None,
});
}
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
let branches = scope.spawn(|| branches_for_repo(repo));
let tags = scope.spawn(|| tags_for_repo(repo));
let stashes = scope.spawn(|| stashes_for_repo(repo));
let commits = scope.spawn(|| commits_for_repo(repo, commit_limit));
let files = scope.spawn(|| repository_files_with_status(repo, &status));
Ok((
join_git_worker("branch", branches.join())?,
join_git_worker("tag", tags.join())?,
join_git_worker("stash", stashes.join())?,
join_git_worker("history", commits.join())?,
join_git_worker("file tree", files.join())?,
))
})?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
warning: None,
})
}
#[tauri::command]
pub async fn clone_repository(
remote_url: String,
parent_path: String,
directory_name: Option<String>,
username: Option<String>,
password: Option<String>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || {
clone_repository_core(
&remote_url,
&parent_path,
directory_name.as_deref(),
username.as_deref(),
password.as_deref(),
commit_limit,
)
})
.await
.map_err(|err| format!("Could not clone repository: {err}"))?
}
/// Opens a repository and gathers everything the UI needs in a single call.
///
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
/// the repo and its status only once, instead of the previous four separate
/// commands that each re-ran `git rev-parse` and `git status`.
#[tauri::command]
pub async fn open_repository_bundle(
path: String,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
run_git_task("Could not load repository", move || {
let repo = resolve_repo(&path)?;
repository_bundle_for_repo(&repo, commit_limit)
})
.await
}
#[tauri::command]
pub async fn get_status(path: String) -> Result<GitStatus, String> {
run_git_task("Could not refresh repository status", move || {
let repo = resolve_repo(&path)?;
status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_status(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not inspect Git LFS", move || {
let repo = resolve_repo(&path)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_install(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not activate Git LFS", move || {
let repo = resolve_repo(&path)?;
activate_git_lfs_for_repo(&repo)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_track(
path: String,
pattern: String,
lockable: Option<bool>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not add Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
ensure_root_gitattributes_not_ignored(&repo)?;
let mut args = vec![OsString::from("lfs"), OsString::from("track")];
if lockable.unwrap_or(false) {
args.push(OsString::from("--lockable"));
}
args.push(pattern.into());
run_git(&repo, args)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_untrack(path: String, pattern: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not remove Git LFS pattern", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
let pattern = validate_lfs_pattern(&pattern)?;
run_git(&repo, ["lfs", "untrack", pattern.as_str()])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_pull(
path: String,
remote: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitLfsStatus, String> {
run_git_task("Could not download Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
pull_git_lfs_objects(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
)?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn git_lfs_prune(path: String) -> Result<GitLfsStatus, String> {
run_git_task("Could not prune Git LFS objects", move || {
let repo = resolve_repo(&path)?;
ensure_git_lfs_available()?;
run_git(&repo, ["lfs", "prune"])?;
git_lfs_status_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
run_git_task("Could not load branches", move || {
let repo = resolve_repo(&path)?;
branches_for_repo(&repo)
})
.await
}
#[tauri::command(async)]
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
let repo = resolve_repo(&path)?;
let names = run_git(&repo, ["remote"])?;
Ok(String::from_utf8_lossy(&names)
.lines()
.filter_map(|line| {
let name = line.trim();
if name.is_empty() {
return None;
}
Some(GitRemote {
name: name.to_string(),
fetch_url: remote_url_for(&repo, name).unwrap_or_default(),
push_url: remote_push_url_for(&repo, name).unwrap_or_default(),
})
})
.collect())
}
#[tauri::command(async)]
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, false)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command(async)]
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, true)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command(async)]
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
let result = (|| {
let repo = resolve_repo(&path)?;
log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display());
let name = validate_remote_name(&repo, &name, true)?;
log::info!(target: "gitty::remote", "remove_remote validated remote: {name}");
run_git(&repo, ["remote", "remove", name.as_str()])?;
log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}");
list_remotes(path)
})();
match &result {
Ok(remotes) => {
log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::<Vec<_>>())
}
Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"),
}
result
}
#[tauri::command(async)]
pub fn set_branch_upstream(
path: String,
branch: String,
upstream: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?;
match upstream
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
Some(upstream) => {
if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? {
return Err(format!("Remote branch '{upstream}' was not found."));
}
run_git(
&repo,
[
"branch",
"--set-upstream-to",
upstream.as_str(),
branch.as_str(),
],
)?;
}
None => {
// Saving sync settings with "No upstream" must be idempotent. Git
// exits with a fatal error when --unset-upstream is used on a
// branch that never had tracking information, which is the normal
// state immediately after adding the first remote.
if git_config_value(&repo, &format!("branch.{branch}.merge")).is_some() {
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
}
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub async fn delete_remote_branch(
path: String,
remote: String,
branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete remote branch", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
vec![branch],
username.as_deref().zip(password.as_deref()),
)
})
.await
}
#[tauri::command]
pub async fn delete_remote_branches(
path: String,
remote: String,
branches: Vec<String>,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete remote branch folder", move || {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
delete_remote_branches_core(
&repo,
&remote,
branches,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
fn delete_remote_branches_core(
repo: &Path,
remote: &str,
branches: Vec<String>,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let run_remote = |args: Vec<String>| match credentials {
Some((user, pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)
}
_ => run_git(repo, args),
};
let mut branch_names = Vec::new();
for branch in branches {
let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string());
}
run_git(repo, ["check-ref-format", "--branch", branch])?;
if !branch_names.iter().any(|existing| existing == branch) {
branch_names.push(branch.to_string());
}
}
if branch_names.is_empty() {
return Err("No remote branches were selected.".to_string());
}
// Local remote-tracking refs can be stale when a branch was deleted by
// another client. Query the server first so deletion remains idempotent.
let mut query_args = vec![
"ls-remote".to_string(),
"--heads".to_string(),
remote.to_string(),
];
query_args.extend(
branch_names
.iter()
.map(|branch| format!("refs/heads/{branch}")),
);
let remote_refs = String::from_utf8_lossy(&run_remote(query_args)?).to_string();
let existing_refs: BTreeSet<&str> = remote_refs
.lines()
.filter_map(|line| line.split_once('\t').map(|(_, reference)| reference.trim()))
.collect();
branch_names.retain(|branch| existing_refs.contains(format!("refs/heads/{branch}").as_str()));
if !branch_names.is_empty() {
let mut atomic_args = vec![
"push".to_string(),
"--atomic".to_string(),
remote.to_string(),
"--delete".to_string(),
];
atomic_args.extend(branch_names.iter().cloned());
if let Err(error) = run_remote(atomic_args) {
let message = error.to_lowercase();
let atomic_unsupported = message.contains("does not support --atomic")
|| message.contains("atomic push is not supported")
|| message.contains("does not support atomic push");
if !atomic_unsupported {
return Err(error);
}
log::warn!(
target: "gitty::remote",
"remote {remote} does not support atomic pushes; retrying branch-folder deletion as one regular push"
);
let mut fallback_args = vec![
"push".to_string(),
remote.to_string(),
"--delete".to_string(),
];
fallback_args.extend(branch_names);
run_remote(fallback_args)?;
}
}
// Also remove stale tracking refs for branches that were already absent.
if let Err(error) = run_remote(vec![
"fetch".to_string(),
"--prune".to_string(),
remote.to_string(),
]) {
log::warn!(target: "gitty::remote", "remote branches were deleted, but tracking refs could not be pruned: {error}");
}
status_for_repo(repo)
}
#[tauri::command]
pub async fn rename_remote_branch(
path: String,
remote: String,
old_branch: String,
new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(
path,
remote,
old_branch,
new_branch,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
fn rename_remote_branch_core(
path: String,
remote: String,
old_branch: String,
new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let old_branch = validate_branch_ref_name(old_branch.trim())?;
let new_branch = validate_branch_ref_name(new_branch.trim())?;
if old_branch == new_branch {
return Err("The new remote branch name is unchanged.".to_string());
}
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
if !ref_exists(&repo, &old_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{old_branch}' was not found."
));
}
if ref_exists(&repo, &new_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{new_branch}' already exists."
));
}
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
let old_remote_ref = format!("refs/heads/{old_branch}");
let new_remote_ref = format!("refs/heads/{new_branch}");
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
// An empty expected value means the destination must not exist on the remote.
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
let delete_refspec = format!(":{old_remote_ref}");
// Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched.
let push_args = [
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
];
match credentials {
Some((username, password)) if !username.is_empty() || !password.is_empty() => {
run_git_authenticated(&repo, push_args, username, password)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
if !ref_exists(&repo, &new_tracking_ref)? {
if let Err(error) = run_git(
&repo,
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
}
}
if ref_exists(&repo, &old_tracking_ref)? {
if let Err(error) = run_git(
&repo,
[
"update-ref",
"-d",
old_tracking_ref.as_str(),
old_hash.as_str(),
],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
run_git_task("Could not load stashes", move || {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
run_git_task("Could not load tags", move || {
let repo = resolve_repo(&path)?;
tags_for_repo(&repo)
})
.await
}
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
let output = run_git(
repo,
[
"for-each-ref",
"--sort=-creatordate",
"refs/tags",
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
],
)?;
let text = String::from_utf8_lossy(&output);
let mut tags = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(7, '\0');
let name = parts.next().unwrap_or_default().trim();
let object_hash = parts.next().unwrap_or_default().trim();
let object_short = parts.next().unwrap_or_default().trim();
let deref_hash = parts.next().unwrap_or_default().trim();
let deref_short = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
if name.is_empty() {
continue;
}
// Annotated tags are their own object with a `taggerdate`/subject and
// dereference (`*...`) to the commit they point at; lightweight tags
// point straight at the commit, so the dereferenced fields are empty.
let annotated = !deref_hash.is_empty();
let hash = if annotated { deref_hash } else { object_hash };
let short_hash = if annotated { deref_short } else { object_short };
tags.push(GitTag {
name: name.to_string(),
hash: hash.to_string(),
short_hash: short_hash.to_string(),
message: if annotated && !subject.is_empty() {
Some(subject.to_string())
} else {
None
},
date: date.to_string(),
annotated,
});
}
Ok(tags)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git(
repo,
[
"for-each-ref",
"--format=%(refname)\t%(HEAD)\t%(upstream:short)",
"refs/heads",
"refs/remotes",
],
)?;
let text = String::from_utf8_lossy(&output);
let mut branches = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(3, '\t');
let ref_name = parts.next().unwrap_or_default();
let head_marker = parts.next().unwrap_or_default();
let configured_upstream = parts.next().unwrap_or_default().trim();
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,
upstream: if remote || configured_upstream.is_empty() {
None
} else {
Some(configured_upstream.to_string())
},
});
}
Ok(branches)
}
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, String> {
let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?;
let text = String::from_utf8_lossy(&output);
let mut stashes = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(4, '\0');
let selector = parts.next().unwrap_or_default().trim();
let hash = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
if selector.is_empty() {
continue;
}
let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32);
let (branch, message) = parse_stash_subject(subject);
stashes.push(GitStash {
selector: selector.to_string(),
index,
hash: hash.to_string(),
branch,
message,
date: date.to_string(),
});
}
Ok(stashes)
}
fn stash_index_from_selector(selector: &str) -> Option<u32> {
selector
.strip_prefix("stash@{")
.and_then(|value| value.strip_suffix('}'))
.and_then(|value| value.parse::<u32>().ok())
}
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
for prefix in ["WIP on ", "On "] {
if let Some(value) = subject.strip_prefix(prefix) {
if let Some((branch, rest)) = value.split_once(": ") {
let message = if prefix == "WIP on " {
rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest)
} else {
rest
};
return (Some(branch.to_string()), message.to_string());
}
}
}
(None, subject.to_string())
}
#[tauri::command]
pub async fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
paths: Option<Vec<String>>,
) -> Result<GitStatus, String> {
run_git_task("Could not stash changes", move || {
let repo = resolve_repo(&path)?;
stash_push_for_repo(
&repo,
message.as_deref(),
include_untracked,
paths.as_deref().unwrap_or_default(),
)
})
.await
}
fn stash_push_for_repo(
repo: &Path,
message: Option<&str>,
include_untracked: bool,
paths: &[String],
) -> Result<GitStatus, String> {
validate_files(paths)?;
let trimmed_message = message.unwrap_or_default().trim();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
if !paths.is_empty() {
args.push(OsString::from("--"));
args.extend(paths.iter().map(OsString::from));
}
run_git(repo, args)?;
status_for_repo(repo)
}
#[tauri::command]
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not apply stash", move || {
run_stash_update(path, "apply", selector)
})
.await
}
#[tauri::command]
pub async fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not pop stash", move || {
run_stash_update(path, "pop", selector)
})
.await
}
#[tauri::command]
pub async fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_git_task("Could not drop stash", move || {
run_stash_update(path, "drop", selector)
})
.await
}
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let selector = validate_stash_selector(&selector)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["stash", action, selector.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Git command failed: {}",
command_output_details(&output)
))
}
fn validate_stash_selector(selector: &str) -> Result<String, String> {
let selector = selector.trim();
let Some(index) = stash_index_from_selector(selector) else {
return Err("Invalid stash selector.".to_string());
};
Ok(format!("stash@{{{index}}}"))
}
#[tauri::command]
pub async fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
run_git_task("Could not check out branch", move || {
checkout_branch_core(path, branch)
})
.await
}
fn checkout_branch_core(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 must not be empty.".to_string());
}
match checkout_plan(&repo, &branch)? {
CheckoutPlan::Local(local) => {
run_git(&repo, ["checkout", local.as_str()])?;
}
CheckoutPlan::TrackRemote { local, remote } => {
run_git(
&repo,
["checkout", "--track", "-b", local.as_str(), remote.as_str()],
)?;
}
CheckoutPlan::Raw(target) => {
run_git(&repo, ["checkout", target.as_str()])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub async fn create_branch(
path: String,
branch: String,
start_point: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not create branch", move || {
create_branch_core(path, branch, start_point)
})
.await
}
fn create_branch_core(
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 async fn rename_branch(
path: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
run_git_task("Could not rename branch", move || {
rename_branch_core(path, old_branch, new_branch)
})
.await
}
fn rename_branch_core(
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 async fn delete_branch(
path: String,
branch: String,
force: Option<bool>,
) -> Result<GitStatus, String> {
run_git_task("Could not delete branch", move || {
delete_branch_core(path, branch, force)
})
.await
}
fn delete_branch_core(
path: String,
branch: String,
force: Option<bool>,
) -> 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("The current branch cannot be deleted.".to_string());
}
let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" };
run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?;
status_for_repo(&repo)
}
fn worktree_path_matches(left: &Path, right: &Path) -> bool {
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec<GitWorktree> {
#[derive(Default)]
struct Record {
path: Option<String>,
head: Option<String>,
branch: Option<String>,
bare: bool,
detached: bool,
locked: bool,
lock_reason: Option<String>,
prunable: bool,
prune_reason: Option<String>,
}
fn finish_record(rows: &mut Vec<GitWorktree>, record: &mut Record, current_repo: &Path) {
let Some(path) = record.path.take() else {
*record = Record::default();
return;
};
let path_buf = PathBuf::from(&path);
let missing = !path_buf.exists();
let is_current = worktree_path_matches(&path_buf, current_repo);
let head = record.head.take();
let short_head = head
.as_ref()
.map(|value| value.chars().take(8).collect::<String>());
rows.push(GitWorktree {
path,
head,
short_head,
branch: record.branch.take(),
bare: record.bare,
detached: record.detached,
locked: record.locked,
lock_reason: record.lock_reason.take(),
prunable: record.prunable,
prune_reason: record.prune_reason.take(),
missing,
is_main: rows.is_empty(),
is_current,
clean: true,
changed_files: 0,
});
*record = Record::default();
}
let mut rows = Vec::new();
let mut record = Record::default();
for field in output.split(|byte| *byte == 0) {
if field.is_empty() {
finish_record(&mut rows, &mut record, current_repo);
continue;
}
let value = String::from_utf8_lossy(field);
let (key, detail) = value
.split_once(' ')
.map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail)));
match key {
"worktree" => record.path = detail.map(str::to_string),
"HEAD" => record.head = detail.map(str::to_string),
"branch" => {
record.branch = detail.map(|branch| {
branch
.strip_prefix("refs/heads/")
.unwrap_or(branch)
.to_string()
})
}
"bare" => record.bare = true,
"detached" => record.detached = true,
"locked" => {
record.locked = true;
record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
"prunable" => {
record.prunable = true;
record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
_ => {}
}
}
finish_record(&mut rows, &mut record, current_repo);
rows
}
fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?;
let mut worktrees = parse_worktree_porcelain(&output, repo);
for worktree in &mut worktrees {
if worktree.missing {
worktree.clean = false;
continue;
}
if worktree.bare {
continue;
}
match run_git_at(
Path::new(&worktree.path),
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
"Could not inspect worktree status",
) {
Ok(status) => {
worktree.changed_files = status
.split(|byte| *byte == 0)
.filter(|entry| entry.len() >= 3 && entry[2] == b' ')
.count() as u32;
worktree.clean = worktree.changed_files == 0;
}
Err(_) => {
worktree.clean = false;
}
}
}
Ok(worktrees)
}
#[tauri::command]
pub async fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not load worktrees", move || {
list_worktrees_core(path)
})
.await
}
fn list_worktrees_core(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn add_worktree(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not add worktree", move || {
add_worktree_core(
path,
worktree_path,
branch,
new_branch,
start_point,
detached,
lock,
)
})
.await
}
fn add_worktree_core(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = worktree_path.trim();
if destination.is_empty() {
return Err("Choose a folder for the new worktree.".to_string());
}
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let new_branch = new_branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if branch.is_some() && new_branch.is_some() {
return Err("Choose either an existing branch or a new branch.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
if lock.unwrap_or(false) {
args.push(OsString::from("--lock"));
}
if detached.unwrap_or(false) {
args.push(OsString::from("--detach"));
}
let target = if let Some(new_branch) = new_branch {
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
args.push(OsString::from("-b"));
args.push(OsString::from(new_branch));
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
} else if let Some(branch) = branch {
Some(validate_existing_local_branch_name(&repo, &branch)?)
} else {
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
};
args.push(OsString::from(destination));
if let Some(target) = target {
args.push(OsString::from(target));
}
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn remove_worktree(
path: String,
worktree_path: String,
force: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not remove worktree", move || {
remove_worktree_core(path, worktree_path, force)
})
.await
}
fn remove_worktree_core(
path: String,
worktree_path: String,
force: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let worktrees = worktrees_for_repo(&repo)?;
let target = worktrees
.iter()
.find(|worktree| {
worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path))
})
.ok_or_else(|| "The selected worktree is no longer registered.".to_string())?;
if target.is_main {
return Err("The main worktree cannot be removed.".to_string());
}
if target.is_current {
return Err("The currently open worktree cannot be removed.".to_string());
}
if target.locked {
return Err("Unlock this worktree before removing it.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("remove")];
if force.unwrap_or(false) {
args.push(OsString::from("--force"));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn move_worktree(
path: String,
worktree_path: String,
destination: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not move worktree", move || {
move_worktree_core(path, worktree_path, destination)
})
.await
}
fn move_worktree_core(
path: String,
worktree_path: String,
destination: String,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = destination.trim();
if destination.is_empty() {
return Err("Choose a new location for the worktree.".to_string());
}
run_git(
&repo,
[
OsString::from("worktree"),
OsString::from("move"),
OsString::from(worktree_path),
OsString::from(destination),
],
)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn lock_worktree(
path: String,
worktree_path: String,
reason: Option<String>,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not lock worktree", move || {
lock_worktree_core(path, worktree_path, reason)
})
.await
}
fn lock_worktree_core(
path: String,
worktree_path: String,
reason: Option<String>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let mut args = vec![OsString::from("worktree"), OsString::from("lock")];
if let Some(reason) = reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
args.push(OsString::from("--reason"));
args.push(OsString::from(reason));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn unlock_worktree(
path: String,
worktree_path: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not unlock worktree", move || {
unlock_worktree_core(path, worktree_path)
})
.await
}
fn unlock_worktree_core(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub async fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not prune worktrees", move || {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "prune"])?;
worktrees_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn repair_worktree(
path: String,
worktree_path: String,
) -> Result<Vec<GitWorktree>, String> {
run_git_task("Could not repair worktree", move || {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn create_tag(
path: String,
name: String,
target: Option<String>,
message: Option<String>,
) -> Result<Vec<GitTag>, String> {
run_git_task("Could not create tag", move || {
create_tag_core(path, name, target, message)
})
.await
}
fn create_tag_core(
path: String,
name: String,
target: Option<String>,
message: Option<String>,
) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
let name = validate_new_tag_name(&repo, &name)?;
let target = match target {
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
_ => verify_commit(&repo, "HEAD")?,
};
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(
&repo,
[
"tag",
"-a",
name.as_str(),
"-m",
message.as_str(),
target.as_str(),
],
)?;
}
None => {
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
}
}
tags_for_repo(&repo)
}
#[tauri::command]
pub async fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
run_git_task("Could not delete tag", move || {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
run_git(&repo, ["tag", "-d", name.as_str()])?;
tags_for_repo(&repo)
})
.await
}
#[tauri::command]
pub async fn push_tag(
path: String,
name: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not push tag", move || {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
let remote = initial_push_remote_name(&repo)?;
let tag_ref = format!("refs/tags/{name}");
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
Ok(())
})
.await
}
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Tag name must not be empty.".to_string());
}
let normalized = validate_tag_ref_name(name)?;
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
return Err(format!("Tag '{normalized}' already exists."));
}
Ok(normalized)
}
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Tag name must not be empty.".to_string());
}
let normalized = validate_tag_ref_name(name)?;
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
return Err(format!("Tag '{normalized}' was not found."));
}
Ok(normalized)
}
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
let output = git_command()
.args([
"check-ref-format",
"--allow-onelevel",
&format!("refs/tags/{name}"),
])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Invalid tag name: {details}"));
}
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
let normalized = normalized
.strip_prefix("refs/tags/")
.map(str::to_string)
.unwrap_or(normalized);
Ok(if normalized.is_empty() {
name.to_string()
} else {
normalized
})
}
#[tauri::command]
pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> 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)
})
.await
.map_err(|err| format!("Could not stage files: {err}"))?
}
#[tauri::command]
pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> 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)
})
.await
.map_err(|err| format!("Could not unstage files: {err}"))?
}
#[tauri::command]
pub async fn add_to_gitignore(
path: String,
target: String,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
add_to_gitignore_for_repo(&repo, &target, kind)
})
.await
.map_err(|err| format!("Could not update .gitignore: {err}"))?
}
#[tauri::command]
pub async fn untrack_paths(path: String, targets: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
untrack_paths_for_repo(&repo, &targets)
})
.await
.map_err(|err| format!("Could not stop tracking paths: {err}"))?
}
#[tauri::command]
pub async fn restore_files(
path: String,
files: Vec<String>,
staged: bool,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> 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)
})
.await
.map_err(|err| format!("Could not restore files: {err}"))?
}
#[tauri::command(async)]
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())
}
// The prompt is built from `git diff --cached` only, i.e. exactly the staged changes —
// unstaged edits and untracked files never influence the generated message.
fn staged_diff(repo: &Path) -> Result<String, String> {
// Full staged file list (nothing excluded) so the model knows the complete scope
// even when the detailed diff below is filtered or truncated for context size.
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
// Generated lockfiles say little about intent and can easily dominate the context,
// so keep them out of the detailed diff.
let diff = run_git(
repo,
[
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
"--unified=3",
"--",
".",
":(exclude)*package-lock.json",
":(exclude)*pnpm-lock.yaml",
":(exclude)*yarn.lock",
":(exclude)*bun.lockb",
":(exclude)*Cargo.lock",
":(exclude)*composer.lock",
":(exclude)*Gemfile.lock",
":(exclude)*poetry.lock",
":(exclude)*go.sum",
],
)?;
let diff = String::from_utf8_lossy(&diff);
if file_list.is_empty() {
return Ok(diff.to_string());
}
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
}
#[tauri::command]
pub async fn commit_ai_generate(
path: String,
notes: Option<String>,
provider: String,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
) -> Result<String, String> {
let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?;
let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() {
"openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
commit_ai::generate_openai(&api_key, &model, &diff, notes).await
}
"anthropic" => {
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
commit_ai::generate_anthropic(&api_key, &model, &diff, notes).await
}
"custom" => {
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
commit_ai::generate_custom(&base_url, api_key.as_deref(), &model, &diff, notes).await
}
other => Err(format!("Unknown AI provider: {other}")),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AiReviewRisk {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AiReviewSeverity {
Critical,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AiReviewFinding {
pub severity: AiReviewSeverity,
pub title: String,
pub description: String,
pub file: Option<String>,
pub line: Option<u32>,
pub suggestion: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AiReviewResult {
pub summary: String,
pub risk: AiReviewRisk,
pub findings: Vec<AiReviewFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitGroup {
pub message: String,
pub reason: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitPlan {
pub summary: String,
pub groups: Vec<AiCommitGroup>,
}
fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result<AiCommitPlan, String> {
use std::collections::HashSet;
let trimmed = raw.trim().trim_matches('`').trim();
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
_ => return Err("The AI response did not contain a valid commit plan.".to_string()),
};
let mut plan: AiCommitPlan = serde_json::from_str(json)
.map_err(|error| format!("Could not process the commit plan: {error}"))?;
plan.groups
.retain(|group| !group.message.trim().is_empty() && !group.files.is_empty());
if plan.groups.len() < 2 {
return Err("The staged changes do not appear to benefit from splitting.".to_string());
}
if plan.groups.len() > 12 {
return Err("The AI proposed too many commit groups.".to_string());
}
let expected = staged_files.iter().cloned().collect::<HashSet<_>>();
let mut seen = HashSet::new();
for group in &mut plan.groups {
group.message = group.message.trim().to_string();
group.reason = group.reason.trim().to_string();
group
.files
.retain(|file| expected.contains(file) && seen.insert(file.clone()));
if group.files.is_empty() {
return Err("The AI returned an empty or duplicate commit group.".to_string());
}
}
if seen != expected {
return Err("The AI plan did not assign every staged file exactly once.".to_string());
}
plan.summary = plan.summary.trim().to_string();
Ok(plan)
}
#[tauri::command]
pub async fn commit_ai_split(
path: String,
provider: String,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
) -> Result<AiCommitPlan, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let staged_files = status
.files
.iter()
.filter(|file| file.staged.is_some())
.map(|file| file.path.clone())
.collect::<Vec<_>>();
if staged_files.len() < 2 {
return Err("Stage at least two files before creating a split plan.".to_string());
}
if status
.files
.iter()
.any(|file| file.staged.is_some() && file.unstaged.is_some())
{
return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string());
}
let diff = staged_diff(&repo)?;
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
let raw = match provider.as_str() {
"openai" => {
commit_ai::split_openai(
api_key
.as_deref()
.ok_or_else(|| "OpenAI API key is missing.".to_string())?,
model.as_deref().unwrap_or("gpt-4o-mini"),
&diff,
)
.await?
}
"anthropic" => {
commit_ai::split_anthropic(
api_key
.as_deref()
.ok_or_else(|| "Anthropic API key is missing.".to_string())?,
model.as_deref().unwrap_or("claude-3-5-haiku-latest"),
&diff,
)
.await?
}
"custom" => {
commit_ai::split_custom(
base_url
.as_deref()
.ok_or_else(|| "Endpoint URL is missing.".to_string())?,
api_key.as_deref(),
model
.as_deref()
.ok_or_else(|| "Model name is missing.".to_string())?,
&diff,
)
.await?
}
other => return Err(format!("Unknown AI provider: {other}")),
};
parse_ai_commit_plan(&raw, &staged_files)
}
#[derive(Deserialize)]
struct AiReviewWireFinding {
severity: String,
title: String,
description: String,
file: Option<String>,
line: Option<u32>,
suggestion: String,
}
#[derive(Deserialize)]
struct AiReviewWireResult {
summary: String,
risk: String,
#[serde(default)]
findings: Vec<AiReviewWireFinding>,
}
fn parse_ai_review(raw: &str) -> Result<AiReviewResult, String> {
let trimmed = raw.trim().trim_matches('`').trim();
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
_ => return Err("The AI review did not contain valid JSON.".to_string()),
};
let wire: AiReviewWireResult = serde_json::from_str(json)
.map_err(|error| format!("Could not process the AI review: {error}"))?;
let risk = match wire.risk.trim().to_ascii_lowercase().as_str() {
"high" => AiReviewRisk::High,
"medium" => AiReviewRisk::Medium,
_ => AiReviewRisk::Low,
};
let mut findings = wire
.findings
.into_iter()
.filter(|finding| {
!finding.title.trim().is_empty() && !finding.description.trim().is_empty()
})
.map(|finding| AiReviewFinding {
severity: match finding.severity.trim().to_ascii_lowercase().as_str() {
"critical" | "error" | "high" => AiReviewSeverity::Critical,
"warning" | "warn" | "medium" => AiReviewSeverity::Warning,
_ => AiReviewSeverity::Info,
},
title: finding.title.trim().to_string(),
description: finding.description.trim().to_string(),
file: finding.file.filter(|file| !file.trim().is_empty()),
line: finding.line,
suggestion: finding.suggestion.trim().to_string(),
})
.collect::<Vec<_>>();
findings.sort_by_key(|finding| match finding.severity {
AiReviewSeverity::Critical => 0,
AiReviewSeverity::Warning => 1,
AiReviewSeverity::Info => 2,
});
findings.truncate(12);
Ok(AiReviewResult {
summary: if wire.summary.trim().is_empty() {
"Review completed.".to_string()
} else {
wire.summary.trim().to_string()
},
risk,
findings,
})
}
#[tauri::command]
pub async fn commit_ai_review(
path: String,
provider: String,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
) -> Result<AiReviewResult, String> {
let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?;
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
let raw = match provider.as_str() {
"openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
commit_ai::review_openai(&api_key, &model, &diff).await?
}
"anthropic" => {
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
commit_ai::review_anthropic(&api_key, &model, &diff).await?
}
"custom" => {
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
}
other => return Err(format!("Unknown AI provider: {other}")),
};
parse_ai_review(&raw)
}
#[tauri::command(async)]
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("No patch selected.".to_string());
}
let patch_path = write_temp_patch(&patch)?;
let result = match action.as_str() {
"stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])),
"discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
"discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
_ => Err("Invalid patch action.".to_string()),
};
let _ = std::fs::remove_file(&patch_path);
result?;
status_for_repo(&repo)
}
#[tauri::command]
pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if message.trim().is_empty() {
return Err("Commit message must not be empty.".to_string());
}
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not commit: {err}"))?
}
#[tauri::command]
pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to amend.".to_string());
}
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
}
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not amend commit: {err}"))?
}
#[tauri::command(async)]
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Ok(None);
}
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
let message = String::from_utf8_lossy(&output).trim_end().to_string();
Ok(if message.is_empty() {
None
} else {
Some(message)
})
}
#[tauri::command(async)]
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to undo.".to_string());
}
if verify_commit(&repo, "HEAD~1").is_err() {
return Err("This is the first commit; there is nothing to undo to.".to_string());
}
// Mixed reset: moves HEAD back one commit and unstages the difference, but
// leaves the working tree files untouched, so the undone commit's changes
// reappear as ordinary uncommitted changes instead of being discarded.
run_git(&repo, ["reset", "HEAD~1"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub async fn pull(
path: String,
username: Option<String>,
password: Option<String>,
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
allow_unrelated_histories: Option<bool>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let pull_args = pull_args_for_repo(
&repo,
strategy.as_deref().unwrap_or("merge"),
remote.as_deref(),
branch.as_deref(),
allow_unrelated_histories.unwrap_or(false),
)?;
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(&pull_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
sync_git_lfs_objects_if_needed(
&repo,
remote.as_deref(),
username.as_deref(),
password.as_deref(),
false,
)?;
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git command failed: {details}"))
})
.await
.map_err(|err| format!("Could not pull: {err}"))?
}
fn pull_args_for_repo(
repo: &Path,
strategy: &str,
remote: Option<&str>,
branch: Option<&str>,
allow_unrelated_histories: bool,
) -> Result<Vec<OsString>, String> {
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]);
if allow_unrelated_histories {
pull_args.push(OsString::from("--allow-unrelated-histories"));
}
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote_name) = remote.map(str::trim).filter(|value| !value.is_empty()) {
validate_remote_name(repo, remote_name, true)?;
pull_args.push(remote_name.into());
let branch = branch
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.map(Ok)
.unwrap_or_else(|| current_branch_name(repo))?;
pull_args.push(branch.into());
}
Ok(pull_args)
}
#[tauri::command]
pub async fn fetch(
path: String,
username: Option<String>,
password: Option<String>,
prune: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let mut fetch_args = vec![OsString::from("fetch")];
if prune.unwrap_or(false) {
fetch_args.push(OsString::from("--prune"));
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
fetch_args.push(remote.into());
}
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(&fetch_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
return status_for_repo(&repo);
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git command failed: {details}"))
})
.await
.map_err(|err| format!("Could not fetch repository: {err}"))?
}
#[tauri::command]
pub async fn push(
path: String,
username: Option<String>,
password: Option<String>,
force_with_lease: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> {
let result = tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
if force_with_lease.unwrap_or(false) {
push_args.insert(1, OsString::from("--force-with-lease"));
}
if let Err(error) = run_push_command(
&repo,
push_args.clone(),
username.as_deref(),
password.as_deref(),
) {
if !is_lfs_http_413_error(&error) {
return Err(error);
}
// Azure DevOps can reject LFS objects above roughly 128 MB when
// HTTP/2 sends them with one large Content-Length. Git LFS can
// transfer the same object over HTTP/1.1 using chunked encoding.
// Keep this override scoped to the retry instead of changing the
// user's repository or global Git configuration.
log::warn!(
target: "gitty::remote",
"Git LFS upload returned HTTP 413; retrying push with HTTP/1.1"
);
run_push_command(
&repo,
push_args_with_http_1_1(push_args),
username.as_deref(),
password.as_deref(),
)
.map_err(|retry_error| {
format!(
"Git LFS upload was rejected with HTTP 413 even after retrying with HTTP/1.1: {retry_error}"
)
})?;
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not push: {err}"))?;
match &result {
Ok(_) => log::info!(target: "gitty::remote", "push completed successfully"),
Err(error) => log::error!(target: "gitty::remote", "push failed: {error}"),
}
result
}
fn run_push_command(
repo: &Path,
args: Vec<OsString>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, args, user, pass)?;
}
_ => {
run_git(repo, args)?;
}
}
Ok(())
}
fn is_lfs_http_413_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("lfs:")
&& (error.contains("http 413")
|| error.contains("413 content too large")
|| error.contains("413 payload too large")
|| error.contains("413 request entity too large"))
}
fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
let mut retry_args = vec![
OsString::from("-c"),
OsString::from("http.version=HTTP/1.1"),
];
retry_args.extend(push_args);
retry_args
}
// ── 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, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
let key = key.trim();
if key.is_empty() {
return Err("No key provided for the credentials.".to_string());
}
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
}
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(key)?;
match entry.get_password() {
Ok(json) => serde_json::from_str::<StoredCredential>(&json)
.map(Some)
.map_err(|err| format!("Stored credentials unreadable: {err}")),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Keychain access failed: {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(async)]
pub fn get_remote_url(
path: String,
remote: Option<String>,
push: Option<bool>,
) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
let remote = match remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(remote) => validate_remote_name(&repo, &remote, true)?,
None => upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()),
};
if let Some(url) = remote_url_for_auth(&repo, &remote, push.unwrap_or(false)) {
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_auth(&repo, &first, push.unwrap_or(false)) {
return Ok(Some(url));
}
}
}
Ok(None)
}
fn remote_url_for_auth(repo: &Path, remote: &str, push: bool) -> Option<String> {
let mut command = git_command();
command.arg("-C").arg(repo).args(["remote", "get-url"]);
if push {
command.arg("--push");
}
let out = command.arg(remote).output().ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn remote_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 remote_push_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command()
.arg("-C")
.arg(repo)
.args(["remote", "get-url", "--push", remote])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn validate_remote_url(url: &str) -> Result<String, String> {
let url = url.trim();
if url.is_empty() || url.starts_with('-') {
return Err("Remote URL must not be empty.".to_string());
}
Ok(url.to_string())
}
fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result<String, String> {
let name = name.trim();
if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
return Err("Invalid remote name.".to_string());
}
let exists = remote_url_for(repo, name).is_some();
if must_exist && !exists {
return Err(format!("Remote '{name}' was not found."));
}
if !must_exist && exists {
return Err(format!("Remote '{name}' already exists."));
}
Ok(name.to_string())
}
fn upstream_remote_name(repo: &Path) -> Option<String> {
let branch = current_branch_name(repo).ok()?;
let out = git_command()
.arg("-C")
.arg(repo)
.args(["config", &format!("branch.{branch}.remote")])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
fn first_remote_name(repo: &Path) -> Option<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)
}
fn current_branch_name(repo: &Path) -> Result<String, String> {
// `symbolic-ref` also works before the first commit, while
// `rev-parse --abbrev-ref HEAD` fails for an unborn HEAD.
let branch = run_git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"])?;
let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return Err("Could not determine current branch.".to_string());
}
Ok(branch)
}
fn branch_has_upstream(repo: &Path) -> bool {
git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
if remote_url_for(repo, "origin").is_some() {
return Ok("origin".to_string());
}
first_remote_name(repo)
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
}
#[cfg(test)]
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
push_args_for_repo_to(repo, None)
}
fn push_args_for_repo_to(
repo: &Path,
requested_remote: Option<&str>,
) -> Result<Vec<OsString>, String> {
if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) {
let remote = validate_remote_name(repo, remote, true)?;
let branch = current_branch_name(repo)?;
return Ok(vec![
OsString::from("push"),
OsString::from("--set-upstream"),
remote.into(),
branch.into(),
]);
}
if branch_has_upstream(repo) {
return Ok(vec![OsString::from("push")]);
}
let branch = current_branch_name(repo)?;
let remote = initial_push_remote_name(repo)?;
Ok(vec![
OsString::from("push"),
OsString::from("--set-upstream"),
OsString::from(remote),
OsString::from(branch),
])
}
#[tauri::command(async)]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
load_stored_credential(&key)
}
#[tauri::command(async)]
pub fn cred_save(
key: String,
username: String,
password: String,
mode: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?;
let mode = match mode.as_deref() {
Some("token") => Some("token".to_string()),
_ => Some("credentials".to_string()),
};
let cred = StoredCredential {
username,
password,
mode,
};
let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry
.set_password(&json)
.map_err(|err| format!("Saving to keychain failed: {err}"))
}
#[tauri::command(async)]
pub fn cred_delete(key: String) -> Result<(), String> {
let entry = cred_entry(&key)?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
}
}
#[tauri::command]
pub async fn merge_branch(
path: String,
branch: String,
strategy: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch name must not be empty.".to_string());
}
let mut args = vec!["merge", "--no-edit"];
match strategy.as_deref().unwrap_or("default") {
"default" => {}
"squash" => args.push("--squash"),
"ff-only" => args.push("--ff-only"),
"no-ff" => args.push("--no-ff"),
_ => return Err("Unknown merge strategy.".to_string()),
}
args.push(branch);
let output = git_command()
.arg("-C")
.arg(&repo)
.args(args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
// A merge that stops on conflicts leaves unmerged paths in the work tree.
// Surface those through the status so the UI can offer conflict resolution
// instead of treating the conflict as a hard error.
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() {
stderr.trim()
} else if !stdout.trim().is_empty() {
stdout.trim()
} else {
"unknown error"
};
Err(format!("Merge failed: {details}"))
})
.await
.map_err(|err| format!("Could not merge: {err}"))?
}
#[tauri::command(async)]
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
if has_unresolved_conflicts(&status_for_repo(&repo)?) {
return Err("Resolve all conflicts before continuing the merge.".to_string());
}
run_git(&repo, ["commit", "--no-edit"])?;
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
run_git(&repo, ["merge", "--abort"])?;
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit = verify_commit(&repo, &commit)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["revert", "--no-edit", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Revert failed: {}",
command_output_details(&output)
))
}
#[tauri::command]
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch name must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase failed", true)
})
.await
.map_err(|err| format!("Could not rebase: {err}"))?
}
#[tauri::command(async)]
pub fn list_interactive_rebase_commits(
path: String,
base: String,
) -> Result<Vec<RebaseCommit>, String> {
let repo = resolve_repo(&path)?;
interactive_rebase_commits_for_repo(&repo, &base)
}
#[tauri::command]
pub async fn start_interactive_rebase(
path: String,
base: String,
plan: Vec<RebasePlanItem>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
if !status.clean {
return Err(
"Commit or stash working tree changes before starting an interactive rebase."
.to_string(),
);
}
if status.rebase_in_progress || status.cherry_pick_in_progress {
return Err(
"Finish the current Git operation before starting an interactive rebase."
.to_string(),
);
}
let base_hash = verify_commit(&repo, &base)?;
let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?;
validate_rebase_plan(&available, &plan)?;
let todo = build_rebase_todo(&available, &plan)?;
let reword_queue = build_reword_queue(&available, &plan)?;
let git_dir = git_dir_for_repo(&repo)?;
cleanup_interactive_rebase_helpers(&repo);
let todo_path = git_dir.join(REBASE_TODO_FILE);
let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE);
fs::write(&todo_path, todo)
.map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?;
fs::write(&reword_queue_path, reword_queue)
.map_err(|err| format!("Could not prepare reword messages: {err}"))?;
let sequence_helper_path = repo.join(sequence_helper_name());
let commit_helper_path = repo.join(commit_helper_name());
let current_exe = env::current_exe()
.map_err(|err| format!("Could not locate the Gitty executable: {err}"))?;
fs::copy(&current_exe, &sequence_helper_path)
.and_then(|_| fs::copy(&current_exe, &commit_helper_path))
.map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?;
let sequence_editor_command = format!("./{}", sequence_helper_name());
let commit_editor_command = format!("./{}", commit_helper_name());
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", "-i", base_hash.as_str()])
.env("GIT_SEQUENCE_EDITOR", sequence_editor_command)
.env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path)
.env("GIT_EDITOR", commit_editor_command)
.env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true);
let _ = fs::remove_file(&todo_path);
let _ = fs::remove_file(&sequence_helper_path);
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
let _ = fs::remove_file(&reword_queue_path);
let _ = fs::remove_file(&commit_helper_path);
}
result
})
.await
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
}
#[tauri::command(async)]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
let git_dir = git_dir_for_repo(&repo)?;
let queue_path = git_dir.join(REWORD_QUEUE_FILE);
let helper_path = repo.join(commit_helper_name());
let mut command = git_command();
command.arg("-C").arg(&repo).args(["rebase", "--continue"]);
if queue_path.exists() && helper_path.exists() {
command
.env("GIT_EDITOR", format!("./{}", commit_helper_name()))
.env(COMMIT_EDITOR_QUEUE_ENV, &queue_path);
} else {
command.env("GIT_EDITOR", "true");
}
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false);
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
cleanup_interactive_rebase_helpers(&repo);
}
result
}
#[tauri::command(async)]
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
run_git(&repo, ["rebase", "--abort"])?;
cleanup_interactive_rebase_helpers(&repo);
status_for_repo(&repo)
}
#[tauri::command(async)]
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, String> {
let repo = resolve_repo(&path)?;
let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string();
let output = run_git(
&repo,
[
"reflog",
"show",
"--date=iso-strict",
"--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e",
"-n",
limit.as_str(),
],
)?;
parse_reflog(&output)
}
#[tauri::command(async)]
pub fn restore_reflog_entry(
path: String,
commit: String,
branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
if !status.clean {
return Err(
"Commit or stash working tree changes before restoring from the reflog.".to_string(),
);
}
let commit_hash = verify_commit(&repo, &commit)?;
let branch = validate_new_branch_name(&repo, &branch)?;
run_git(
&repo,
["checkout", "-b", branch.as_str(), commit_hash.as_str()],
)?;
status_for_repo(&repo)
}
fn interactive_rebase_commits_for_repo(
repo: &Path,
base: &str,
) -> Result<Vec<RebaseCommit>, String> {
let base_hash = verify_commit(repo, base)?;
let range = format!("{base_hash}..HEAD");
let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?;
if !String::from_utf8_lossy(&merges).trim().is_empty() {
return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string());
}
let output = run_git(
repo,
[
"log",
"--reverse",
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e",
range.as_str(),
],
)?;
let mut commits = Vec::new();
for raw in output.split(|byte| *byte == 0x1e) {
let record = String::from_utf8_lossy(raw);
let record = record.trim_matches(['\r', '\n', ' ']);
if record.is_empty() {
continue;
}
let fields = record.split('\x1f').collect::<Vec<_>>();
if fields.len() != 5 {
return Err("Git returned an unexpected interactive rebase record.".to_string());
}
commits.push(RebaseCommit {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
summary: fields[2].to_string(),
author_name: fields[3].to_string(),
date: fields[4].to_string(),
});
}
Ok(commits)
}
fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> {
if commits.is_empty() {
return Err("There are no commits to rebase onto the selected base.".to_string());
}
if commits.len() != plan.len() {
return Err("The rebase plan must include every commit exactly once.".to_string());
}
let expected = commits
.iter()
.map(|item| item.hash.as_str())
.collect::<BTreeSet<_>>();
let actual = plan
.iter()
.map(|item| item.hash.as_str())
.collect::<BTreeSet<_>>();
if actual.len() != plan.len() || actual != expected {
return Err(
"The rebase plan contains missing, duplicate, or unexpected commits.".to_string(),
);
}
let mut has_kept_commit = false;
for item in plan {
match item.action {
RebaseAction::Drop => {}
RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => {
return Err("Squash and fixup need an earlier picked commit.".to_string());
}
RebaseAction::Reword => {
let message = item.message.as_deref().unwrap_or("").trim();
if message.is_empty() || message.contains(['\r', '\n']) {
return Err("Reword messages must be a single non-empty line.".to_string());
}
has_kept_commit = true;
}
_ => has_kept_commit = true,
}
}
if !has_kept_commit {
return Err("Keep at least one commit in the interactive rebase plan.".to_string());
}
Ok(())
}
fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
let mut todo = String::new();
for item in plan {
let commit = by_hash
.get(item.hash.as_str())
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
let summary = commit.summary.replace(['\r', '\n'], " ");
match item.action {
RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)),
RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)),
RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)),
RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)),
RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)),
}
}
Ok(todo)
}
fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
let mut queue = String::new();
for item in plan
.iter()
.filter(|item| item.action == RebaseAction::Reword)
{
let commit = by_hash
.get(item.hash.as_str())
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
let message = item.message.as_deref().unwrap_or("").trim();
queue.push_str(&commit.summary.replace(['\r', '\n'], " "));
queue.push('\x1f');
queue.push_str(message);
queue.push('\x1e');
}
Ok(queue)
}
fn git_dir_for_repo(repo: &Path) -> Result<PathBuf, String> {
let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?;
let path = String::from_utf8_lossy(&output).trim().to_string();
if path.is_empty() {
Err("Git could not determine its metadata directory.".to_string())
} else {
Ok(PathBuf::from(path))
}
}
fn sequence_helper_name() -> &'static str {
if cfg!(windows) {
".gitty-sequence-editor.exe"
} else {
SEQUENCE_HELPER_STEM
}
}
fn commit_helper_name() -> &'static str {
if cfg!(windows) {
".gitty-commit-editor.exe"
} else {
COMMIT_HELPER_STEM
}
}
fn cleanup_interactive_rebase_helpers(repo: &Path) {
if let Ok(git_dir) = git_dir_for_repo(repo) {
let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE));
let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE));
}
let _ = fs::remove_file(repo.join(sequence_helper_name()));
let _ = fs::remove_file(repo.join(commit_helper_name()));
}
fn is_interactive_rebase_helper_path(path: &str) -> bool {
matches!(
path.replace('\\', "/").rsplit('/').next(),
Some(name) if name == sequence_helper_name() || name == commit_helper_name()
)
}
fn parse_reflog(output: &[u8]) -> Result<Vec<ReflogEntry>, String> {
let mut entries = Vec::new();
for raw in output.split(|byte| *byte == 0x1e) {
let record = String::from_utf8_lossy(raw);
let record = record.trim_matches(['\r', '\n', ' ']);
if record.is_empty() {
continue;
}
let fields = record.split('\x1f').collect::<Vec<_>>();
if fields.len() != 6 {
return Err("Git returned an unexpected reflog record.".to_string());
}
entries.push(ReflogEntry {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
selector: fields[2].to_string(),
action: fields[3].to_string(),
author_name: fields[4].to_string(),
date: fields[5].to_string(),
});
}
Ok(entries)
}
fn rebase_status_or_error(
repo: &Path,
output: Output,
context: &str,
ok_if_rebase_in_progress: bool,
) -> Result<GitStatus, String> {
if output.status.success() {
return status_for_repo(repo);
}
let status = status_for_repo(repo)?;
if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress)
{
return Ok(status);
}
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command]
pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["cherry-pick", commit_hash.as_str()])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
})
.await
.map_err(|err| format!("Could not cherry-pick: {err}"))?
}
#[tauri::command(async)]
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
return Err("No cherry-pick is currently in progress.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["cherry-pick", "--continue"])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
}
#[tauri::command(async)]
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
return Err("No cherry-pick is currently in progress.".to_string());
}
run_git(&repo, ["cherry-pick", "--abort"])?;
status_for_repo(&repo)
}
fn cherry_pick_status_or_error(
repo: &Path,
output: Output,
context: &str,
) -> Result<GitStatus, String> {
if output.status.success() {
return status_for_repo(repo);
}
let status = status_for_repo(repo)?;
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
return Ok(status);
}
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command]
pub async fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
run_git_task("Could not load commit history", move || {
let repo = resolve_repo(&path)?;
commit_page_for_repo(&repo, limit, skip)
})
.await
}
const COMMIT_NOTES_REF: &str = "refs/notes/commits";
const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync";
const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024;
#[tauri::command]
pub async fn get_commit_note(path: String, commit: String) -> Result<Option<String>, String> {
run_git_task("Could not load commit note", move || {
let repo = resolve_repo(&path)?;
commit_note_for_repo(&repo, &commit)
})
.await
}
#[tauri::command]
pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> {
run_git_task("Could not save commit note", move || {
let repo = resolve_repo(&path)?;
set_commit_note_for_repo(&repo, &commit, &note)
})
.await
}
#[tauri::command]
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
run_git_task("Could not delete commit note", move || {
let repo = resolve_repo(&path)?;
delete_commit_note_for_repo(&repo, &commit)
})
.await
}
#[tauri::command]
pub async fn fetch_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not fetch commit notes", move || {
let repo = resolve_repo(&path)?;
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
#[tauri::command]
pub async fn push_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not push commit notes", move || {
let repo = resolve_repo(&path)?;
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
let commit = verify_commit(repo, commit)?;
let output = git_command()
.arg("-C")
.arg(repo)
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.code() == Some(1) {
return Ok(None);
}
if !output.status.success() {
return Err(format!(
"Could not inspect commit note: {}",
command_output_details(&output)
));
}
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
if note_object.is_empty() {
return Ok(None);
}
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
let mut note =
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
if note.ends_with('\n') {
note.pop();
if note.ends_with('\r') {
note.pop();
}
}
Ok(Some(note))
}
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
if note.trim().is_empty() {
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
}
if note.len() > MAX_COMMIT_NOTE_BYTES {
return Err(format!(
"Commit note is too large (maximum {} KiB).",
MAX_COMMIT_NOTE_BYTES / 1024
));
}
run_git_with_stdin(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"add",
"-f",
"-F",
"-",
"--",
commit.as_str(),
],
note.as_bytes(),
)?;
Ok(())
}
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"remove",
"--ignore-missing",
"--",
commit.as_str(),
],
)?;
Ok(())
}
fn fetch_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
let fetched = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, fetch_args, user, pass)
}
_ => run_git(repo, fetch_args),
};
if let Err(error) = fetched {
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
return Err(
if error.to_lowercase().contains("couldn't find remote ref") {
format!("Remote '{remote}' does not contain commit notes yet.")
} else {
error
},
);
}
let merge_result = (|| -> Result<(), String> {
if ref_exists(repo, COMMIT_NOTES_REF)? {
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"merge",
"-s",
"cat_sort_uniq",
COMMIT_NOTES_SYNC_REF,
],
)?;
} else {
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
.trim()
.to_string();
run_git(
repo,
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
)?;
}
Ok(())
})();
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
merge_result
}
fn push_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
if !ref_exists(repo, COMMIT_NOTES_REF)? {
return Err("There are no local commit notes to push.".to_string());
}
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
let push_args = ["push", remote.as_str(), refspec.as_str()];
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, push_args, user, pass)?;
}
_ => {
run_git(repo, push_args)?;
}
}
Ok(())
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None)
}
fn commit_page_for_repo(
repo: &Path,
limit: Option<u32>,
skip: 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, 5_000).to_string();
let skip = skip.unwrap_or(0).min(10_000_000).to_string();
// Fetch the per-commit changed files inline via `--name-status` in a single
// `git log` process, instead of spawning one `git diff-tree` per commit
// (which was ~100 extra processes and the main cost of opening a repo).
let output = run_git(
repo,
[
"log",
"--branches",
"--remotes",
"--tags",
"HEAD",
"--topo-order",
"--decorate=short",
"--name-status",
"-M",
"-z",
"--root",
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
"--skip",
skip.as_str(),
"-n",
limit.as_str(),
],
)?;
let mut commits = parse_commit_log_inline(&output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
}
fn mark_commits_with_notes(repo: &Path, commits: &mut [GitCommit]) -> Result<(), String> {
if commits.is_empty() || !ref_exists(repo, COMMIT_NOTES_REF)? {
return Ok(());
}
let output = run_git(repo, ["notes", "--ref", COMMIT_NOTES_REF, "list"])?;
let noted_commits: BTreeSet<String> = String::from_utf8_lossy(&output)
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(ToString::to_string)
.collect();
for commit in commits {
commit.has_note = noted_commits.contains(&commit.hash);
}
Ok(())
}
#[tauri::command]
pub async fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile>, String> {
run_git_task("Could not load repository files", move || {
let repo = resolve_repo(&path)?;
repository_files(&repo)
})
.await
}
#[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!("Could not load file history: {err}"))?
}
#[tauri::command]
pub fn cancel_file_history(
request_id: String,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<(), String> {
let request_id = request_id.trim();
if request_id.is_empty() {
return Ok(());
}
state.cancel(request_id)
}
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
#[tauri::command(async)]
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("Repository has no commits yet.".to_string());
}
let args = vec![
OsString::from("blame"),
OsString::from("--line-porcelain"),
OsString::from("--"),
OsString::from(file.clone()),
];
let output =
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
Ok(GitBlameResult {
path: file,
lines: parse_blame_porcelain(&output),
})
}
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
#[derive(Default, Clone)]
struct BlameMeta {
author_name: String,
author_email: String,
author_time: i64,
summary: String,
}
let text = String::from_utf8_lossy(output);
let mut lines_out: Vec<GitBlameLine> = Vec::new();
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
let mut current_hash = String::new();
let mut current_final_line: u32 = 0;
for line in text.split('\n') {
if let Some(content) = line.strip_prefix('\t') {
let meta = commit_meta.get(&current_hash).cloned().unwrap_or_default();
lines_out.push(GitBlameLine {
line_number: current_final_line,
content: content.to_string(),
commit_hash: current_hash.clone(),
short_hash: short_hash(&current_hash),
author_name: meta.author_name,
author_email: meta.author_email,
author_time: meta.author_time,
summary: meta.summary,
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
});
continue;
}
let mut parts = line.splitn(2, ' ');
let head = parts.next().unwrap_or("");
let tail = parts.next().unwrap_or("");
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
if let Some(final_line) = tail.split_whitespace().nth(1) {
current_final_line = final_line.parse().unwrap_or(current_final_line);
}
current_hash = head.to_string();
commit_meta.entry(current_hash.clone()).or_default();
continue;
}
match head {
"author" => {
commit_meta
.entry(current_hash.clone())
.or_default()
.author_name = tail.to_string()
}
"author-mail" => {
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
commit_meta
.entry(current_hash.clone())
.or_default()
.author_email = email;
}
"author-time" => {
commit_meta
.entry(current_hash.clone())
.or_default()
.author_time = tail.parse().unwrap_or(0);
}
"summary" => {
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
}
_ => {}
}
}
lines_out
}
fn list_file_history_core(
repo: &Path,
file: String,
limit: Option<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 file history failed")?;
check_search_cancelled(cancellation)?;
let mut commits = parse_commit_log(repo, &output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
}
#[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("Search text must not be empty.".to_string());
}
if verify_commit(&repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let case_sensitive = case_sensitive.unwrap_or(false);
let limit = limit.unwrap_or(250).clamp(1, 1000) as usize;
let search_id = search_id
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty());
let cancellation = search_id.as_ref().map(|search_id| SearchCancellation {
state: state.clone(),
search_id: search_id.clone(),
});
let result = search_code_introductions_core(
&repo,
query,
case_sensitive,
limit,
cancellation.as_ref(),
);
if let Some(search_id) = search_id.as_deref() {
let _ = state.clear(search_id);
}
result
})
.await
.map_err(|err| format!("Could not complete search task: {err}"))?
}
#[tauri::command]
pub fn cancel_code_search(
search_id: String,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<(), String> {
let search_id = search_id.trim();
if search_id.is_empty() {
return Ok(());
}
state.cancel(search_id)
}
fn search_code_introductions_core(
repo: &Path,
query: String,
case_sensitive: bool,
limit: usize,
cancellation: Option<&SearchCancellation>,
) -> Result<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(async)]
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(async)]
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(async)]
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",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
from_hash.as_str(),
to_hash.as_str(),
],
)?;
let files = parse_diff_files(&name_status, &numstat)?;
let patch = String::from_utf8_lossy(&patch_output).to_string();
Ok(GitCommitComparison {
from_short: short_hash(&from_hash),
to_short: short_hash(&to_hash),
from_hash,
to_hash,
files,
patch,
})
}
#[tauri::command(async)]
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",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
commit_hash.as_str(),
],
std::slice::from_ref(&file),
)?;
let files = parse_diff_files(&name_status, &numstat)?;
let patch = String::from_utf8_lossy(&patch_output).to_string();
Ok(GitCommitComparison {
from_short: short_hash(&commit_hash),
to_short: "working tree".to_string(),
from_hash: commit_hash,
to_hash: String::new(),
files,
patch,
})
}
#[tauri::command(async)]
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",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
commit_hash.as_str(),
head_hash.as_str(),
],
std::slice::from_ref(&file),
)?;
let files = parse_diff_files(&name_status, &numstat)?;
let patch = String::from_utf8_lossy(&patch_output).to_string();
Ok(GitCommitComparison {
from_short: short_hash(&commit_hash),
to_short: "HEAD".to_string(),
from_hash: commit_hash,
to_hash: head_hash,
files,
patch,
})
}
#[tauri::command(async)]
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",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
from_hash.as_str(),
commit_hash.as_str(),
],
&pathspecs,
)?;
let files = parse_diff_files(&name_status, &numstat)?;
let patch = String::from_utf8_lossy(&patch_output).to_string();
Ok(GitCommitComparison {
from_short,
to_short: short_hash(&commit_hash),
from_hash,
to_hash: commit_hash,
files,
patch,
})
}
#[tauri::command(async)]
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!("Could not read conflict file: {err}"))?;
let binary = is_binary_bytes(&bytes);
// For binary files we cannot offer a text merge, so we only report the side
// sizes; the UI lets the user pick which side to keep wholesale.
if binary {
return Ok(ConflictFile {
path: file.clone(),
content: String::new(),
ours: None,
theirs: None,
base: None,
binary: true,
ours_size: index_stage_size(&repo, 2, &file),
theirs_size: index_stage_size(&repo, 3, &file),
});
}
Ok(ConflictFile {
base: read_index_stage(&repo, 1, &file),
ours: read_index_stage(&repo, 2, &file),
theirs: read_index_stage(&repo, 3, &file),
binary: false,
ours_size: index_stage_size(&repo, 2, &file),
theirs_size: index_stage_size(&repo, 3, &file),
path: file,
content: String::from_utf8_lossy(&bytes).to_string(),
})
}
#[tauri::command(async)]
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("Invalid side. Allowed values are 'ours' or 'theirs'.".to_string()),
};
run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?;
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
status_for_repo(&repo)
}
fn is_binary_bytes(bytes: &[u8]) -> bool {
// Git's own heuristic: a NUL byte within the first 8000 bytes marks the
// blob as binary.
bytes.iter().take(8000).any(|byte| *byte == 0)
}
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<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(async)]
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!("Could not create directory: {err}"))?;
}
std::fs::write(&target, content)
.map_err(|err| format!("Could not write conflict file: {err}"))?;
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
status_for_repo(&repo)
}
fn read_index_stage(repo: &Path, stage: u8, file: &str) -> Option<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 path must not be empty.".to_string());
}
let input = PathBuf::from(path);
let output = run_git_at(
&input,
["rev-parse", "--show-toplevel"],
"Not a Git repository or unreachable",
)?;
let top_level = String::from_utf8_lossy(&output).trim().to_string();
if top_level.is_empty() {
return Err("Git could not determine the repository root path.".to_string());
}
Ok(PathBuf::from(top_level))
}
#[cfg(windows)]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
let native_path = path.to_string_lossy().replace('/', "\\");
let mut command = Command::new("explorer.exe");
command.arg(native_path);
command.creation_flags(CREATE_NO_WINDOW);
command
.spawn()
.map_err(|err| format!("Could not launch Explorer: {err}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("open")
.arg(path)
.spawn()
.map_err(|err| format!("Could not launch Finder: {err}"))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("xdg-open")
.arg(path)
.spawn()
.map_err(|err| format!("Could not launch file manager: {err}"))?;
Ok(())
}
fn resolve_repo_child_path(repo: &Path, child: &str) -> Result<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("File path must stay within the repository.".to_string());
}
let candidate = repo.join(child_path);
let repo = repo
.canonicalize()
.map_err(|err| format!("Could not resolve repository path: {err}"))?;
let candidate = candidate
.canonicalize()
.map_err(|err| format!("Could not resolve file path: {err}"))?;
if !candidate.starts_with(&repo) {
return Err("File path lies outside the repository.".to_string());
}
Ok(candidate)
}
#[cfg(windows)]
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
let native_path = path.to_string_lossy().replace('/', "\\");
let mut command = Command::new("explorer.exe");
command.arg(format!("/select,{native_path}"));
command.creation_flags(CREATE_NO_WINDOW);
command
.spawn()
.map_err(|err| format!("Could not launch Explorer: {err}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("open")
.arg("-R")
.arg(path)
.spawn()
.map_err(|err| format!("Could not launch Finder: {err}"))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn reveal_path_in_file_manager(path: &Path) -> Result<(), String> {
// No universal "select this file" flag across Linux file managers; open its folder instead.
let target = path.parent().unwrap_or(path);
Command::new("xdg-open")
.arg(target)
.spawn()
.map_err(|err| format!("Could not launch file manager: {err}"))?;
Ok(())
}
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
let commit = commit.trim();
if commit.is_empty() {
return Err("Commit must not be empty.".to_string());
}
let rev = format!("{commit}^{{commit}}");
let output = run_git(repo, ["rev-parse", "--verify", "--quiet", rev.as_str()])
.map_err(|err| format!("Could not find commit: {err}"))?;
let hash = String::from_utf8_lossy(&output).trim().to_string();
if hash.is_empty() {
return Err("Commit could not be found.".to_string());
}
Ok(hash)
}
fn status_for_repo(repo: &Path) -> Result<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);
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
Ok(GitStatus {
repo_path: repo.to_string_lossy().to_string(),
current_branch: branch.current_branch,
upstream: branch.upstream,
ahead: branch.ahead,
behind: branch.behind,
clean: files.is_empty(),
files,
rebase_in_progress: rebase_in_progress(repo),
cherry_pick_in_progress: cherry_pick_in_progress(repo),
merge_in_progress: merge_in_progress(repo),
})
}
fn git_lfs_status_for_repo(repo: &Path) -> Result<GitLfsStatus, String> {
let bundled = bundled_git_lfs_path().is_some();
let version = git_lfs_version();
let available = version.is_some();
let attribute_patterns = lfs_patterns_from_attributes(repo);
let patterns = if available {
match git_lfs_patterns(repo) {
Ok(reported_patterns) => merge_lfs_patterns(attribute_patterns, reported_patterns),
Err(_) => attribute_patterns,
}
} else {
attribute_patterns
};
let files = if available {
git_lfs_files(repo).unwrap_or_default()
} else {
Vec::new()
};
Ok(GitLfsStatus {
available,
bundled,
version,
filters_configured: lfs_filters_configured(repo),
hook_installed: lfs_hook_installed(repo),
repository_uses_lfs: !patterns.is_empty() || !files.is_empty(),
patterns,
files,
})
}
fn git_lfs_version() -> Option<String> {
let output = git_command().args(["lfs", "version"]).output().ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
fn ensure_git_lfs_available() -> Result<String, String> {
git_lfs_version().ok_or_else(|| {
"Git LFS is unavailable. Reinstall Gitty or install git-lfs and restart the app."
.to_string()
})
}
fn activate_git_lfs_for_repo(repo: &Path) -> Result<(), String> {
ensure_git_lfs_available()?;
run_git(repo, ["lfs", "install", "--local"])?;
ensure_root_gitattributes_not_ignored(repo)?;
Ok(())
}
fn git_path_is_ignored(repo: &Path, path: &str) -> Result<bool, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["check-ignore", "--quiet", "--no-index", "--", path])
.output()
.map_err(|err| format!("Could not inspect Git ignore rules: {err}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"Could not inspect Git ignore rules: {}",
command_output_details(&output)
)),
}
}
fn ensure_root_gitattributes_not_ignored(repo: &Path) -> Result<bool, String> {
const GITATTRIBUTES_PATH: &str = ".gitattributes";
const GITATTRIBUTES_EXCEPTION: &str = "!/.gitattributes";
if !git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Ok(false);
}
// The last matching ignore rule wins. Always append here, even if an
// earlier exception exists and is overridden by a later ignore rule.
append_gitignore_pattern_to_file(repo, GITATTRIBUTES_EXCEPTION, false)?;
if git_path_is_ignored(repo, GITATTRIBUTES_PATH)? {
return Err(
"`.gitattributes` is still ignored. Check higher-priority Git ignore rules."
.to_string(),
);
}
Ok(true)
}
fn repository_uses_lfs(repo: &Path) -> bool {
!lfs_patterns_from_attributes(repo).is_empty()
|| git_lfs_files(repo).is_ok_and(|files| !files.is_empty())
}
fn sync_git_lfs_objects_if_needed(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
install_local: bool,
) -> Result<(), String> {
if !repository_uses_lfs(repo) {
return Ok(());
}
if install_local {
activate_git_lfs_for_repo(repo)?;
} else {
ensure_git_lfs_available()?;
}
pull_git_lfs_objects(repo, remote, username, password)
}
fn pull_git_lfs_objects(
repo: &Path,
remote: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut args = vec![OsString::from("lfs"), OsString::from("pull")];
if let Some(remote) = remote.map(str::trim).filter(|remote| !remote.is_empty()) {
args.push(validate_remote_name(repo, remote, true)?.into());
}
let output = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated_output(repo, args, user, pass)?
}
_ => git_command()
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|err| format!("Could not start Git LFS: {err}"))?,
};
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git LFS pull failed: {details}"))
}
fn lfs_filters_configured(repo: &Path) -> bool {
[
"filter.lfs.process",
"filter.lfs.clean",
"filter.lfs.smudge",
]
.iter()
.all(|key| git_config_value(repo, key).is_some())
}
fn git_config_value(repo: &Path, key: &str) -> Option<String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["config", "--get", key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn lfs_hook_installed(repo: &Path) -> bool {
let Ok(path) = run_git(repo, ["rev-parse", "--git-path", "hooks/pre-push"]) else {
return false;
};
let path = PathBuf::from(String::from_utf8_lossy(&path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
let Ok(hook) = fs::read_to_string(path) else {
return false;
};
hook.contains("git lfs pre-push") || hook.contains("git-lfs pre-push")
}
fn git_lfs_patterns(repo: &Path) -> Result<Vec<GitLfsPattern>, String> {
let output = run_git(repo, ["lfs", "track", "--json"])?;
let mut parsed: GitLfsPatternsOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid tracked-pattern data: {err}"))?;
parsed.patterns.retain(|pattern| pattern.tracked);
parsed.patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
Ok(parsed.patterns)
}
fn merge_lfs_patterns(
mut attribute_patterns: Vec<GitLfsPattern>,
reported_patterns: Vec<GitLfsPattern>,
) -> Vec<GitLfsPattern> {
for reported in reported_patterns {
if let Some(existing) = attribute_patterns.iter_mut().find(|existing| {
existing.source == reported.source && existing.pattern == reported.pattern
}) {
*existing = reported;
} else {
attribute_patterns.push(reported);
}
}
attribute_patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
attribute_patterns
.dedup_by(|left, right| left.source == right.source && left.pattern == right.pattern);
attribute_patterns
}
fn git_lfs_files(repo: &Path) -> Result<Vec<GitLfsFile>, String> {
let json_output = run_git(repo, ["lfs", "ls-files", "--long", "--size", "--json"]);
if let Ok(output) = json_output {
let parsed: GitLfsFilesOutput = serde_json::from_slice(&output)
.map_err(|err| format!("Git LFS returned invalid file data: {err}"))?;
let mut files = parsed.files.unwrap_or_default();
files.sort_by(|left, right| left.name.cmp(&right.name));
return Ok(files);
}
// Git LFS versions before JSON output was introduced still expose a stable
// line format. Size is left at zero because the human-readable --size
// suffix cannot be converted back to exact bytes reliably.
let output = run_git(repo, ["lfs", "ls-files", "--long"])?;
let mut files = String::from_utf8_lossy(&output)
.lines()
.filter_map(parse_git_lfs_file_line)
.collect::<Vec<_>>();
files.sort_by(|left, right| left.name.cmp(&right.name));
Ok(files)
}
fn parse_git_lfs_file_line(line: &str) -> Option<GitLfsFile> {
let mut fields = line.splitn(3, ' ');
let oid = fields.next()?.trim();
let marker = fields.next()?.trim();
let name = fields.next()?.trim();
if oid.is_empty() || name.is_empty() || !matches!(marker, "*" | "-") {
return None;
}
Some(GitLfsFile {
name: name.to_string(),
size: 0,
checkout: marker == "*",
downloaded: marker == "*",
oid_type: "sha256".to_string(),
oid: oid.to_string(),
version: "https://git-lfs.github.com/spec/v1".to_string(),
})
}
fn lfs_patterns_from_attributes(repo: &Path) -> Vec<GitLfsPattern> {
let mut patterns = Vec::new();
// Read the repository root explicitly. `git lfs track` writes here even
// when .gitattributes is still untracked or excluded by an ignore rule.
read_lfs_patterns_from_file(repo, ".gitattributes", &mut patterns);
if let Ok(output) = run_git(
repo,
[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
) {
for path in parse_nul_paths(&output) {
if path.replace('\\', "/") == ".gitattributes"
|| Path::new(&path).file_name() != Some(OsStr::new(".gitattributes"))
{
continue;
}
read_lfs_patterns_from_file(repo, &path, &mut patterns);
}
}
if let Ok(info_path) = run_git(repo, ["rev-parse", "--git-path", "info/attributes"]) {
let path = PathBuf::from(String::from_utf8_lossy(&info_path).trim().to_string());
let path = if path.is_absolute() {
path
} else {
repo.join(path)
};
if let Ok(contents) = fs::read_to_string(path) {
append_lfs_patterns(&contents, ".git/info/attributes", &mut patterns);
}
}
patterns.sort_by(|left, right| {
left.source
.cmp(&right.source)
.then_with(|| left.pattern.cmp(&right.pattern))
});
patterns.dedup();
patterns
}
fn read_lfs_patterns_from_file(
repo: &Path,
relative_path: &str,
patterns: &mut Vec<GitLfsPattern>,
) {
let Ok(contents) = fs::read_to_string(repo.join(relative_path)) else {
return;
};
append_lfs_patterns(&contents, relative_path, patterns);
}
fn append_lfs_patterns(contents: &str, source: &str, patterns: &mut Vec<GitLfsPattern>) {
for line in contents.lines() {
let Some((pattern, attributes)) = split_gitattributes_pattern(line) else {
continue;
};
let attributes = attributes.split_whitespace().collect::<Vec<_>>();
if !attributes.contains(&"filter=lfs") {
continue;
}
patterns.push(GitLfsPattern {
pattern,
source: source.replace('\\', "/"),
lockable: attributes.contains(&"lockable"),
tracked: true,
});
}
}
fn split_gitattributes_pattern(line: &str) -> Option<(String, &str)> {
let line = line.trim_start();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
return None;
}
let mut pattern = String::new();
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
pattern.push(character);
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character.is_whitespace() {
let attributes = line[index..].trim_start();
return (!pattern.is_empty() && !attributes.is_empty())
.then_some((pattern, attributes));
}
pattern.push(character);
}
None
}
fn validate_lfs_pattern(pattern: &str) -> Result<String, String> {
let pattern = pattern.trim();
if pattern.is_empty() {
return Err("Git LFS pattern must not be empty.".to_string());
}
if pattern.len() > 1_024 {
return Err("Git LFS pattern is too long.".to_string());
}
if pattern.starts_with('-') {
return Err("Git LFS pattern must not start with '-'.".to_string());
}
if pattern.chars().any(char::is_control) {
return Err("Git LFS pattern contains invalid control characters.".to_string());
}
Ok(pattern.to_string())
}
fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
fn cherry_pick_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "CHERRY_PICK_HEAD")
}
fn merge_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "MERGE_HEAD")
}
fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false;
};
let value = String::from_utf8_lossy(&output).trim().to_string();
if value.is_empty() {
return false;
}
let path = PathBuf::from(value);
if path.is_absolute() {
path.exists()
} else {
repo.join(path).exists()
}
}
// `git status` only auto-detects renames between HEAD and the index (staged changes).
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
// deletion and an untracked file share the same blob hash (and the match is unambiguous),
// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged
// renames. This is intentionally content-hash based (not similarity-based) so it never
// mutates the repository's real index.
const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300;
fn detect_worktree_renames(repo: &Path, files: &mut Vec<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 clone_repository_core(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
username: Option<&str>,
password: Option<&str>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
let target = clone_target_path(remote_url, parent_path, directory_name)?;
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
sync_git_lfs_objects_if_needed(
&repo,
Some("origin"),
username,
password,
true,
)
.err()
.map(|error| {
format!(
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
)
})
} else {
// There cannot be LFS pointers to download before the first commit.
// In particular, avoid Git LFS implementations that try to resolve
// HEAD themselves and fail on an empty repository.
None
};
let mut bundle = repository_bundle_for_repo(&repo, commit_limit)?;
bundle.warning = lfs_warning;
Ok(bundle)
}
fn clone_target_path(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
) -> Result<PathBuf, String> {
let remote = remote_url.trim();
if remote.is_empty() {
return Err("Remote URL must not be empty.".to_string());
}
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
return Err("Remote URL contains invalid characters.".to_string());
}
let parent = PathBuf::from(parent_path.trim());
if parent_path.trim().is_empty() {
return Err("Destination folder must not be empty.".to_string());
}
if !parent.exists() {
return Err("Destination folder does not exist.".to_string());
}
if !parent.is_dir() {
return Err("Destination path must be a folder.".to_string());
}
let raw_name = directory_name
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| infer_clone_directory_name(remote));
let name = validate_clone_directory_name(&raw_name)?;
let target = parent.join(name);
if target.exists() {
if !target.is_dir() {
return Err("Clone destination already exists and is not a folder.".to_string());
}
let mut entries = target
.read_dir()
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
if entries.next().is_some() {
return Err("Clone destination already exists and is not empty.".to_string());
}
}
Ok(target)
}
fn infer_clone_directory_name(remote_url: &str) -> String {
let trimmed = remote_url
.trim()
.split(['?', '#'])
.next()
.unwrap_or(remote_url)
.trim_end_matches(['/', '\\']);
let last_segment = trimmed
.rsplit(['/', '\\', ':'])
.find(|part| !part.trim().is_empty())
.unwrap_or("")
.trim();
last_segment
.strip_suffix(".git")
.unwrap_or(last_segment)
.trim()
.to_string()
}
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
}
if trimmed == "." || trimmed == ".." {
return Err("Folder name is not valid.".to_string());
}
if trimmed.chars().any(|c| {
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
return Err("Folder name contains invalid characters.".to_string());
}
if Path::new(trimmed).is_absolute() {
return Err("Folder name must be relative.".to_string());
}
Ok(trimmed.to_string())
}
fn run_git_clone(
remote_url: &str,
target: &Path,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut command = git_command();
let has_explicit_credentials = matches!(
(username, password),
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
);
if has_explicit_credentials {
command.arg("-c").arg("credential.helper=");
}
command
.arg("clone")
.arg("--")
.arg(remote_url)
.arg(target)
.env("GIT_TERMINAL_PROMPT", "0");
let askpass = match (username, password) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
let askpass = write_askpass_script()?;
command
.env("GIT_ASKPASS", &askpass)
.env("GIT_CRED_USER", u)
.env("GIT_CRED_PASS", p);
Some(askpass)
}
_ => None,
};
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
if let Some(path) = askpass {
let _ = std::fs::remove_file(path);
}
let output = output?;
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git clone failed: {}", details))
}
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<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 search failed",
)?
} else {
let mut args = vec![
OsString::from("log"),
OsString::from("--all"),
OsString::from("--reverse"),
OsString::from("--format=%H"),
// Skip textconv diff drivers so git does not extract binary files
// (e.g. .docx / Office temp "~$" lock files) to temp files, which can
// fail with "unsupported filetype" and abort the whole search.
OsString::from("--no-textconv"),
];
if !case_sensitive {
args.push(OsString::from("-i"));
}
args.push(OsString::from(format!("-S{query}")));
run_git_cancellable(repo, args, cancellation, "Git search failed")?
};
Ok(String::from_utf8_lossy(&output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect())
}
fn commit_parents(repo: &Path, commit: &str) -> Result<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!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
return Ok(None);
}
if is_binary_bytes(&output.stdout) {
return Ok(None);
}
Ok(Some(normalize_newlines(&String::from_utf8_lossy(
&output.stdout,
))))
}
fn normalize_newlines(value: &str) -> String {
value.replace("\r\n", "\n").replace('\r', "\n")
}
fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize {
let content = if case_sensitive {
content.to_string()
} else {
content.to_ascii_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_ascii_lowercase()
};
if query.is_empty() {
return 0;
}
let mut count = 0;
let mut start = 0;
while let Some(index) = content[start..].find(&query) {
count += 1;
start += index + query.len();
}
count
}
fn first_added_match_line(
repo: &Path,
parent: Option<&str>,
commit: &str,
file: &str,
query: &str,
case_sensitive: bool,
cancellation: Option<&SearchCancellation>,
) -> Result<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 for search result failed",
)?;
let patch = String::from_utf8_lossy(&output);
let line_query = first_query_line(query);
let mut new_line = 0u32;
for line in patch.lines() {
check_search_cancelled(cancellation)?;
if line.starts_with("@@") {
if let Some(start) = parse_new_hunk_start(line) {
new_line = start;
}
continue;
}
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") {
continue;
}
if let Some(added) = line.strip_prefix('+') {
if count_matches(added, line_query, case_sensitive) > 0 {
return Ok(Some((new_line.max(1), compact_search_line(added))));
}
new_line = new_line.saturating_add(1);
} else if line.starts_with('-') {
continue;
} else if line.starts_with(' ') {
new_line = new_line.saturating_add(1);
}
}
Ok(None)
}
fn first_query_line(query: &str) -> &str {
query
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(query)
}
fn parse_new_hunk_start(line: &str) -> Option<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!("Unexpected Git commit entry: {text}"));
}
Ok(GitSearchCommitMetadata {
commit_hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author_name: fields[2].to_string(),
author_email: fields[3].to_string(),
date: fields[4].to_string(),
summary: fields[5].to_string(),
})
}
/// Parses `git log --name-status -z` output where each commit's changed files
/// are embedded inline (see `commits_for_repo`), so no per-commit git process is
/// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then
/// git's newline, then the NUL-separated name-status entries.
fn parse_commit_log_inline(output: &[u8]) -> Result<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!(
"Unexpected Git log entry: {}",
String::from_utf8_lossy(record)
));
}
// Field 8 (if present) holds the name-status list, preceded by the newline
// git inserts between the pretty-format output and the diff.
let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]);
while let Some((&first, rest)) = files_bytes.split_first() {
if matches!(first, b'\n' | b'\r') {
files_bytes = rest;
} else {
break;
}
}
let refs = String::from_utf8_lossy(parts[5])
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect();
let parents = String::from_utf8_lossy(parts[6])
.split_whitespace()
.map(ToString::to_string)
.collect();
commits.push(GitCommit {
hash: String::from_utf8_lossy(parts[0]).trim().to_string(),
short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(),
author_name: String::from_utf8_lossy(parts[2]).to_string(),
author_email: String::from_utf8_lossy(parts[3]).to_string(),
date: String::from_utf8_lossy(parts[4]).trim().to_string(),
refs,
parents,
summary: String::from_utf8_lossy(parts[7]).to_string(),
files: parse_commit_files(files_bytes)?,
has_note: false,
});
}
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!("Unexpected Git log entry: {record}"));
}
let refs = fields[5]
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect();
let parents = fields[6]
.split_whitespace()
.map(ToString::to_string)
.collect();
let hash = fields[0].to_string();
let files = commit_files(repo, &hash)?;
commits.push(GitCommit {
hash,
short_hash: fields[1].to_string(),
author_name: fields[2].to_string(),
author_email: fields[3].to_string(),
date: fields[4].to_string(),
refs,
parents,
summary: fields[7].to_string(),
files,
has_note: false,
});
}
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!("Unexpected Git log entry: {record}"));
}
let refs = fields[5]
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect();
let parents = fields[6]
.split_whitespace()
.map(ToString::to_string)
.collect();
commits.push(GitCommit {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author_name: fields[2].to_string(),
author_email: fields[3].to_string(),
date: fields[4].to_string(),
refs,
parents,
summary: fields[7].to_string(),
files: Vec::new(),
has_note: false,
});
}
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 entry without path: {status_text}"));
}
if matches!(status, FileStatusKind::Renamed) {
let old_path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
if index >= entries.len() {
return Err(format!("Git-Rename ohne Zielpfad: {old_path}"));
}
let path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
files.push(GitCommitFile {
path,
old_path: Some(old_path),
status,
});
} else {
let path = String::from_utf8_lossy(entries[index]).to_string();
index += 1;
files.push(GitCommitFile {
path,
old_path: None,
status,
});
}
}
Ok(files)
}
fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result<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 must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(repo)
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Invalid branch name: {details}"));
}
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
let normalized = if normalized.is_empty() {
branch.to_string()
} else {
normalized
};
if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!("Branch '{normalized}' already exists."));
}
Ok(normalized)
}
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch name must not be empty.".to_string());
}
let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!("Local branch '{normalized}' was not found."));
}
Ok(normalized)
}
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
let output = git_command()
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Invalid branch name: {details}"));
}
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(if normalized.is_empty() {
branch.to_string()
} else {
normalized
})
}
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", ref_name])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => {
let stderr = String::from_utf8_lossy(&output.stderr);
let details = if stderr.trim().is_empty() {
"unknown error".to_string()
} else {
stderr.trim().to_string()
};
Err(format!("Could not verify Git ref: {details}"))
}
}
}
fn restore_worktree_files(
repo: &Path,
statuses: &[GitFileStatus],
files: &[String],
) -> Result<(), String> {
let mut restore_paths = Vec::new();
let mut clean_paths = Vec::new();
for file in files {
let status = find_status(statuses, file);
match status.and_then(|entry| entry.unstaged) {
Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()),
// An unstaged rename (see `detect_worktree_renames`) has no index entry for the
// new path, so `git restore` can't act on it directly: restore the original
// content at the old path and drop the untracked new file instead.
Some(FileStatusKind::Renamed) => {
if let Some(entry) = status {
if let Some(old_path) = entry.old_path.clone() {
restore_paths.push(old_path);
}
clean_paths.push(entry.path.clone());
} else {
restore_paths.push(file.clone());
}
}
_ => restore_paths.push(file.clone()),
}
}
if !restore_paths.is_empty() {
run_git_with_paths(repo, &["restore", "--worktree"], &restore_paths)?;
}
if !clean_paths.is_empty() {
run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?;
}
Ok(())
}
fn unstage_selected_files(
repo: &Path,
statuses: &[GitFileStatus],
files: &[String],
) -> Result<(), String> {
let mut restore_paths = Vec::new();
let mut remove_from_index = Vec::new();
for file in files {
let status = find_status(statuses, file);
if matches!(
status.and_then(|entry| entry.staged),
Some(FileStatusKind::Added)
) && status.and_then(|entry| entry.old_path.as_ref()).is_none()
{
remove_from_index.push(file.clone());
} else {
restore_paths.push(file.clone());
}
}
if !restore_paths.is_empty() {
run_git_with_paths(repo, &["restore", "--staged"], &restore_paths)?;
}
if !remove_from_index.is_empty() {
run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?;
}
Ok(())
}
fn restore_staged_files(
repo: &Path,
statuses: &[GitFileStatus],
files: &[String],
) -> Result<(), String> {
let mut restore_paths = Vec::new();
let mut remove_from_index = Vec::new();
let mut clean_paths = Vec::new();
for file in files {
let status = find_status(statuses, file);
match status.and_then(|entry| entry.staged) {
Some(FileStatusKind::Added) => {
let target = status
.map(|entry| entry.path.clone())
.unwrap_or_else(|| file.clone());
remove_from_index.push(target.clone());
clean_paths.push(target);
}
Some(FileStatusKind::Renamed) => {
if let Some(entry) = status {
if let Some(old_path) = entry.old_path.clone() {
restore_paths.push(old_path);
remove_from_index.push(entry.path.clone());
clean_paths.push(entry.path.clone());
} else {
restore_paths.push(entry.path.clone());
}
} else {
restore_paths.push(file.clone());
}
}
Some(_) => {
if let Some(entry) = status {
restore_paths.push(entry.path.clone());
} else {
restore_paths.push(file.clone());
}
}
None => {
if matches!(
status.and_then(|entry| entry.unstaged),
Some(FileStatusKind::Untracked)
) {
clean_paths.push(file.clone());
} else {
restore_paths.push(file.clone());
}
}
}
}
if !restore_paths.is_empty() {
run_git_with_paths(
repo,
&["restore", "--source=HEAD", "--staged", "--worktree"],
&restore_paths,
)?;
}
if !remove_from_index.is_empty() {
run_git_with_paths(repo, &["rm", "--cached", "-f"], &remove_from_index)?;
}
if !clean_paths.is_empty() {
run_git_with_paths(repo, &["clean", "-fd"], &clean_paths)?;
}
Ok(())
}
fn find_status<'a>(statuses: &'a [GitFileStatus], file: &str) -> Option<&'a GitFileStatus> {
statuses
.iter()
.find(|entry| entry.path == file || entry.old_path.as_deref() == Some(file))
}
fn validate_files(files: &[String]) -> Result<(), String> {
if files.iter().any(|file| file.is_empty()) {
return Err("File list contains an empty path.".to_string());
}
Ok(())
}
fn normalize_gitignore_target(target: &str) -> Result<String, String> {
if target.is_empty() || target.trim().is_empty() {
return Err("Ignore target must not be empty.".to_string());
}
if target
.chars()
.any(|character| matches!(character, '\0' | '\r' | '\n'))
{
return Err("Ignore target contains an unsupported control character.".to_string());
}
let normalized = target.replace('\\', "/");
if normalized.starts_with('/') || normalized.as_bytes().get(1) == Some(&b':') {
return Err("Ignore target must be relative to the repository.".to_string());
}
let segments: Vec<&str> = normalized.trim_end_matches('/').split('/').collect();
if segments.is_empty()
|| segments
.iter()
.any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
{
return Err("Ignore target must be a normalized repository path.".to_string());
}
if segments[0].eq_ignore_ascii_case(".git") {
return Err("The repository metadata directory cannot be ignored.".to_string());
}
Ok(segments.join("/"))
}
fn escape_gitignore_literal(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(
character,
'\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ' | '\t'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
fn gitignore_extension(target: &str) -> Option<&str> {
let name = target.rsplit('/').next()?;
let separator = name.rfind('.')?;
(separator > 0 && separator < name.len() - 1).then(|| &name[separator + 1..])
}
fn gitignore_pattern(target: &str, kind: GitIgnoreKind) -> Result<String, String> {
match kind {
GitIgnoreKind::File => Ok(format!("/{}", escape_gitignore_literal(target))),
GitIgnoreKind::Folder => Ok(format!("/{}/", escape_gitignore_literal(target))),
GitIgnoreKind::Extension => {
let extension = gitignore_extension(target)
.ok_or_else(|| "The selected file has no extension to ignore.".to_string())?;
Ok(format!("*.{}", escape_gitignore_literal(extension)))
}
}
}
fn gitignore_target_matches(kind: GitIgnoreKind, target: &str, candidate: &str) -> bool {
match kind {
GitIgnoreKind::File => candidate == target,
GitIgnoreKind::Folder => candidate
.strip_prefix(target)
.is_some_and(|remainder| remainder.starts_with('/')),
GitIgnoreKind::Extension => gitignore_extension(target)
.zip(gitignore_extension(candidate))
.is_some_and(|(selected, current)| selected == current),
}
}
fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result<bool, String> {
append_gitignore_pattern_to_file(repo, pattern, true)
}
fn append_gitignore_pattern_to_file(
repo: &Path,
pattern: &str,
skip_if_present: bool,
) -> Result<bool, String> {
let gitignore = repo.join(".gitignore");
match fs::symlink_metadata(&gitignore) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("Refusing to update a symlinked .gitignore file.".to_string());
}
Ok(metadata) if metadata.is_dir() => {
return Err(".gitignore is a directory, not a file.".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("Could not inspect .gitignore: {error}")),
}
let existing = match fs::read(&gitignore) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("Could not read .gitignore: {error}")),
};
let text = std::str::from_utf8(&existing)
.map_err(|_| ".gitignore is not valid UTF-8 and cannot be updated safely.".to_string())?;
if skip_if_present
&& text
.lines()
.any(|line| line.trim_end_matches('\r') == pattern)
{
return Ok(false);
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore)
.map_err(|error| format!("Could not open .gitignore: {error}"))?;
if !existing.is_empty() && !existing.ends_with(b"\n") {
file.write_all(b"\n")
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
}
file.write_all(pattern.as_bytes())
.and_then(|_| file.write_all(b"\n"))
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
Ok(true)
}
fn add_to_gitignore_for_repo(
repo: &Path,
target: &str,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
let target = normalize_gitignore_target(target)?;
let pattern = gitignore_pattern(&target, kind)?;
let status = status_for_repo(repo)?;
let staged_additions: Vec<String> = status
.files
.iter()
.filter(|file| {
file.staged == Some(FileStatusKind::Added)
&& file.old_path.is_none()
&& gitignore_target_matches(kind, &target, &file.path)
})
.map(|file| file.path.clone())
.collect();
append_gitignore_pattern(repo, &pattern)?;
if !staged_additions.is_empty() {
unstage_selected_files(repo, &status.files, &staged_additions)?;
}
status_for_repo(repo)
}
fn untrack_paths_for_repo(repo: &Path, targets: &[String]) -> Result<GitStatus, String> {
validate_files(targets)?;
if !targets.is_empty() {
// --cached keeps every working-tree file in place. Force only permits
// index removal when the staged/worktree content differs from HEAD.
run_git_with_paths(
repo,
&["rm", "-r", "--cached", "--force", "--ignore-unmatch"],
targets,
)?;
}
status_for_repo(repo)
}
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!("Could not write patch file: {err}"))?;
Ok(path)
}
fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
run_apply_patch_command(repo, patch_path, options, true)
}
fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
run_apply_patch_command(repo, patch_path, options, false)
}
fn run_apply_patch_command(
repo: &Path,
patch_path: &Path,
options: &[&str],
check_only: bool,
) -> Result<(), String> {
let mut args = Vec::with_capacity(options.len() + 5);
args.push(OsString::from("apply"));
if check_only {
args.push(OsString::from("--check"));
}
args.extend(options.iter().map(OsString::from));
args.push(OsString::from("--recount"));
args.push(OsString::from("--whitespace=nowarn"));
args.push(patch_path.as_os_str().to_os_string());
run_git(repo, args).map(|_| ())
}
static ASKPASS_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_askpass_path(extension: &str) -> std::path::PathBuf {
let sequence = ASKPASS_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"gitty-askpass-{}-{sequence}.{extension}",
std::process::id()
))
}
#[cfg(unix)]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
use std::os::unix::fs::PermissionsExt;
let path = next_askpass_path("sh");
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
.map_err(|e| format!("Could not set script permissions: {e}"))?;
Ok(path)
}
#[cfg(not(unix))]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = next_askpass_path("bat");
// Reading the value from PowerShell avoids cmd.exe interpreting special
// characters such as &, |, ^ or % from passwords and access tokens.
let script = r#"@echo off
echo %1 | findstr /I "sername" >nul 2>&1
if %errorlevel% == 0 (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_USER)"
) else (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_PASS)"
)
"#;
std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {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 command failed: {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("credential.helper=")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_ASKPASS", &askpass)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CRED_USER", username)
.env("GIT_CRED_PASS", password)
.output()
.map_err(|err| format!("Could not start Git: {err}"));
let _ = std::fs::remove_file(&askpass);
result
}
fn command_output_details(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if !stdout.trim().is_empty() {
stdout.trim().to_string()
} else {
"Unknown error".to_string()
}
}
/// Heuristic: did git fail because the credentials were rejected/expired,
/// as opposed to a network or merge error? Used by the frontend to drop the
/// stored credential and re-prompt the login.
fn is_auth_error(details: &str) -> bool {
let d = details.to_lowercase();
d.contains("authentication failed")
|| d.contains("could not read username")
|| d.contains("could not read password")
|| d.contains("invalid username or password")
|| d.contains("terminal prompts disabled")
|| d.contains("permission denied")
|| d.contains("access denied")
|| d.contains("403 forbidden")
|| d.contains(" 403")
|| d.contains(" 401")
|| d.contains("authorization failed")
|| d.contains("authentication required")
// Server-side message (e.g. Gitea/Forgejo: "remote: Failed to
// authenticate user"), independent of the local git locale.
|| d.contains("failed to authenticate")
}
// Windows' CreateProcess rejects command lines longer than ~32K chars with
// "os error 206" (filename or extension too long). Staging/restoring a large
// number of files can easily exceed that, so split the paths across multiple
// invocations and concatenate their output.
const MAX_PATH_ARGS_CHARS: usize = 8_000;
fn run_git_with_paths(
repo: &Path,
base_args: &[&str],
files: &[String],
) -> Result<Vec<u8>, String> {
if files.is_empty() {
let args: Vec<OsString> = base_args.iter().map(OsString::from).collect();
return run_git(repo, args);
}
let mut combined = Vec::new();
let mut start = 0;
while start < files.len() {
let mut end = start;
let mut chunk_chars = 0usize;
while end < files.len() {
let len = files[end].len() + 1;
if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS {
break;
}
chunk_chars += len;
end += 1;
}
let chunk = &files[start..end];
let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(chunk.iter().map(OsString::from));
combined.extend(run_git(repo, args)?);
start = end;
}
Ok(combined)
}
fn run_git_with_paths_cancellable(
repo: &Path,
base_args: &[&str],
files: &[String],
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<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 command failed")
}
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!("Could not create Git output file: {err}"))?;
let stderr_file = std::fs::File::create(&stderr_path)
.map_err(|err| format!("Could not create Git error file: {err}"))?;
let mut child = git_command()
.arg("-C")
.arg(repo)
.args(args)
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|err| {
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
format!("Could not start Git. Is Git installed? {err}")
})?;
let status = loop {
if let Err(err) = check_search_cancelled(cancellation) {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
return Err(err);
}
if let Some(status) = child
.try_wait()
.map_err(|err| format!("Could not check Git process: {err}"))?
{
break status;
}
thread::sleep(Duration::from_millis(60));
};
let stdout =
std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
if status.success() {
return Ok(stdout);
}
let stderr_text = String::from_utf8_lossy(&stderr);
let stdout_text = String::from_utf8_lossy(&stdout);
let details = if !stderr_text.trim().is_empty() {
stderr_text.trim()
} else if !stdout_text.trim().is_empty() {
stdout_text.trim()
} else {
"unknown error"
};
Err(format!("{context}: {details}"))
}
fn run_git_at<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!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() {
stderr.trim()
} else if !stdout.trim().is_empty() {
stdout.trim()
} else {
"unknown error"
};
Err(format!("{context}: {details}"))
}
fn run_git_with_stdin<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!("Could not start Git. Is Git installed? {err}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_data)
.map_err(|err| format!("Could not send input to Git: {err}"))?;
}
let output = child
.wait_with_output()
.map_err(|err| format!("Could not read Git output: {err}"))?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() {
stderr.trim()
} else if !stdout.trim().is_empty() {
stdout.trim()
} else {
"unknown error"
};
Err(format!("Git command failed: {details}"))
}
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<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 repo = temp_dir(name);
run_git_test(&repo.path, ["init", "-q"]);
run_git_test(&repo.path, ["config", "user.email", "test@example.com"]);
run_git_test(&repo.path, ["config", "user.name", "Tester"]);
repo
}
fn init_bare_temp_repo(name: &str) -> TempRepo {
let repo = temp_dir(name);
run_git_test(&repo.path, ["init", "--bare", "-q"]);
repo
}
fn temp_dir(name: &str) -> TempRepo {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after Unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"tauri_git_lite_{name}_{}_{}",
std::process::id(),
nanos
));
fs::create_dir_all(&path).expect("temp repo directory should be created");
TempRepo { path }
}
fn run_git_test<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 parses_git_lfs_json_status_records() {
let patterns: GitLfsPatternsOutput = serde_json::from_str(
r#"{"patterns":[{"pattern":"*.psd","source":".gitattributes","lockable":true,"tracked":true}]}"#,
)
.expect("pattern JSON should parse");
assert_eq!(patterns.patterns.len(), 1);
assert_eq!(patterns.patterns[0].pattern, "*.psd");
assert!(patterns.patterns[0].lockable);
let files: GitLfsFilesOutput = serde_json::from_str(
r#"{"files":[{"name":"Assets/scene.psd","size":2048,"checkout":false,"downloaded":true,"oid_type":"sha256","oid":"abc","version":"https://git-lfs.github.com/spec/v1"}]}"#,
)
.expect("file JSON should parse");
let file = files
.files
.expect("files should be present")
.pop()
.expect("one file should be present");
assert_eq!(file.name, "Assets/scene.psd");
assert_eq!(file.size, 2048);
assert!(file.downloaded);
}
#[test]
fn parses_lfs_attributes_and_plain_file_output() {
assert_eq!(
split_gitattributes_pattern(r"Assets/My\ Files/** filter=lfs diff=lfs -text"),
Some((
"Assets/My Files/**".to_string(),
"filter=lfs diff=lfs -text"
))
);
assert_eq!(split_gitattributes_pattern("# *.zip filter=lfs"), None);
let file = parse_git_lfs_file_line(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - Assets/My File.bin",
)
.expect("plain LFS record should parse");
assert_eq!(file.name, "Assets/My File.bin");
assert!(!file.downloaded);
assert!(!file.checkout);
}
#[test]
fn merges_attribute_patterns_when_git_lfs_reports_none() {
let patterns = merge_lfs_patterns(
vec![GitLfsPattern {
pattern: "*.prt".to_string(),
source: ".gitattributes".to_string(),
lockable: false,
tracked: true,
}],
Vec::new(),
);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
}
#[test]
fn reads_ignored_root_gitattributes_for_lfs_patterns() {
let repo = init_temp_repo("ignored_root_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
fs::write(
repo.path.join(".gitattributes"),
"*.prt filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
let patterns = lfs_patterns_from_attributes(&repo.path);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pattern, "*.prt");
assert_eq!(patterns[0].source, ".gitattributes");
}
#[test]
fn lfs_activation_unignores_root_gitattributes() {
let repo = init_temp_repo("unignore_lfs_attributes");
fs::write(repo.path.join(".gitignore"), ".gitattributes\n")
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should unignore .gitattributes")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
assert!(
!ensure_root_gitattributes_not_ignored(&repo.path)
.expect("the existing exception should remain unchanged")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
".gitattributes\n!/.gitattributes\n"
);
}
#[test]
fn lfs_activation_moves_an_overridden_gitattributes_exception_to_the_end() {
let repo = init_temp_repo("refresh_lfs_attributes_exception");
fs::write(
repo.path.join(".gitignore"),
"!/.gitattributes\n.gitattributes\n",
)
.expect("ignore fixture should be written");
assert!(
ensure_root_gitattributes_not_ignored(&repo.path)
.expect("LFS activation should restore the final exception")
);
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"!/.gitattributes\n.gitattributes\n!/.gitattributes\n"
);
assert!(
!git_path_is_ignored(&repo.path, ".gitattributes")
.expect("ignore state should be readable")
);
}
#[test]
fn validates_git_lfs_patterns() {
assert_eq!(validate_lfs_pattern(" *.psd ").unwrap(), "*.psd");
assert!(validate_lfs_pattern("").is_err());
assert!(validate_lfs_pattern("--include=*").is_err());
assert!(validate_lfs_pattern("*.bin\n*.zip").is_err());
}
#[test]
fn detects_git_lfs_repository_from_attributes() {
let repo = init_temp_repo("lfs_repository_detection");
assert!(!repository_uses_lfs(&repo.path));
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
assert!(repository_uses_lfs(&repo.path));
}
#[test]
fn clone_lfs_sync_installs_local_filters_before_download() {
let repo = init_temp_repo("clone_lfs_local_setup");
fs::write(
repo.path.join(".gitattributes"),
"*.bin filter=lfs diff=lfs merge=lfs -text\n",
)
.expect("LFS attributes should be written");
// A repository without a remote may reject the pull, but clone setup
// must already be complete before that network step is attempted.
let _ = sync_git_lfs_objects_if_needed(&repo.path, None, None, None, true);
assert!(
run_git(
&repo.path,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone sync should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&repo.path));
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail LFS filter tests with a sh signal pipe error"
)]
fn pulls_git_lfs_objects_from_local_remote() {
ensure_git_lfs_available().expect("Git LFS should be available for the test");
let source = init_temp_repo("lfs_pull_source");
let remote = init_bare_temp_repo("lfs_pull_remote");
let checkout = temp_dir("lfs_pull_checkout");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"downloaded LFS payload\n")
.expect("LFS fixture should be written");
run_git_test(&source.path, ["add", ".gitattributes", "asset.bin"]);
run_git_test(&source.path, ["commit", "-q", "-m", "add LFS object"]);
run_git_test(
&source.path,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote.path.as_os_str().to_owned(),
],
);
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
run_git_test(&source.path, ["push", "-q", "-u", "origin", &branch]);
let remote_head = format!("refs/heads/{branch}");
run_git_test(&remote.path, ["symbolic-ref", "HEAD", &remote_head]);
let output = git_command()
.arg("clone")
.arg("-q")
.arg(&remote.path)
.arg(&checkout.path)
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()
.expect("Git clone should start");
assert!(
output.status.success(),
"Git clone failed: {}",
command_output_details(&output)
);
let pointer = fs::read_to_string(checkout.path.join("asset.bin"))
.expect("skipped LFS object should remain a pointer");
assert!(pointer.starts_with("version https://git-lfs.github.com/spec/v1"));
assert!(repository_uses_lfs(&checkout.path));
pull_git_lfs_objects(&checkout.path, Some("origin"), None, None)
.expect("automatic LFS pull should succeed");
assert_eq!(
fs::read(checkout.path.join("asset.bin")).expect("LFS object should be downloaded"),
b"downloaded LFS payload\n"
);
}
#[test]
fn git_lfs_status_finds_the_bundled_extension() {
let repo = init_temp_repo("bundled_lfs_status");
let status = git_lfs_status_for_repo(&repo.path).expect("LFS status should load");
assert!(status.available);
assert!(status.bundled);
assert!(
status
.version
.as_deref()
.is_some_and(|version| version.starts_with("git-lfs/"))
);
assert!(!status.repository_uses_lfs);
assert!(status.patterns.is_empty());
assert!(status.files.is_empty());
}
#[test]
fn stash_push_can_be_limited_to_selected_files() {
let repo = init_temp_repo("scoped_stash");
fs::write(repo.path.join("selected.txt"), "initial selected\n")
.expect("selected file should be written");
fs::write(repo.path.join("remaining.txt"), "initial remaining\n")
.expect("remaining file should be written");
run_git_test(&repo.path, ["add", "selected.txt", "remaining.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial files"]);
fs::write(repo.path.join("selected.txt"), "stashed change\n")
.expect("selected change should be written");
fs::write(repo.path.join("remaining.txt"), "remaining change\n")
.expect("remaining change should be written");
let paths = vec!["selected.txt".to_string()];
let status = stash_push_for_repo(&repo.path, Some("selected file"), false, &paths)
.expect("selected file should be stashed");
assert!(status.files.iter().all(|file| file.path != "selected.txt"));
assert!(status.files.iter().any(|file| file.path == "remaining.txt"));
assert_eq!(
git_output_test(
&repo.path,
["stash", "show", "--name-only", "--format=", "stash@{0}"],
),
"selected.txt"
);
}
#[test]
fn branches_report_configured_upstream_and_local_only_state() {
let repo = init_temp_repo("branch_upstream_state");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature/local-only"]);
run_git_test(&repo.path, ["branch", "feature/tracked"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
run_git_test(
&repo.path,
[
"update-ref",
"refs/remotes/origin/feature/published",
"HEAD",
],
);
run_git_test(
&repo.path,
["config", "branch.feature/tracked.remote", "origin"],
);
run_git_test(
&repo.path,
[
"config",
"branch.feature/tracked.merge",
"refs/heads/feature/published",
],
);
let branches = branches_for_repo(&repo.path).expect("branches should load");
let local_only = branches
.iter()
.find(|branch| branch.name == "feature/local-only")
.expect("local-only branch should exist");
let tracked = branches
.iter()
.find(|branch| branch.name == "feature/tracked")
.expect("tracked branch should exist");
let remote = branches
.iter()
.find(|branch| branch.name == "origin/feature/published")
.expect("remote branch should exist");
assert_eq!(local_only.upstream, None);
assert_eq!(
tracked.upstream.as_deref(),
Some("origin/feature/published")
);
assert_eq!(remote.upstream, None);
}
#[test]
fn clearing_an_unconfigured_upstream_is_a_noop() {
let repo = init_temp_repo("unset_missing_upstream");
commit_initial_file(&repo.path);
let branch = git_output_test(&repo.path, ["branch", "--show-current"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let status = set_branch_upstream(
repo.path.to_string_lossy().to_string(),
branch.clone(),
None,
)
.expect("saving an empty upstream should not fail");
assert_eq!(status.current_branch.as_deref(), Some(branch.as_str()));
assert_eq!(status.upstream, None);
assert!(git_config_value(&repo.path, &format!("branch.{branch}.merge")).is_none());
}
#[test]
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
let repo = init_temp_repo("commit_notes_crud");
commit_initial_file(&repo.path);
let commit_before = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note lookup should work"),
None
);
set_commit_note_for_repo(
&repo.path,
&commit_before,
"Review: sieht gut aus\nBuild: 42",
)
.expect("note should be created");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should load");
assert!(
commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("annotated commit should be in history")
.has_note
);
assert!(
commits
.iter()
.all(|commit| commit.summary != "Notes added by 'git notes add'")
);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
Some("Review: sieht gut aus\nBuild: 42".to_string())
);
set_commit_note_for_repo(&repo.path, &commit_before, "Freigabe erteilt")
.expect("note should be replaced");
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("updated note should load"),
Some("Freigabe erteilt".to_string())
);
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should reload");
assert!(
!commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("commit should remain in history")
.has_note
);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before)
.expect("deleted note lookup should work"),
None
);
assert_eq!(
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
commit_before
);
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn commit_notes_can_be_pushed_and_fetched_through_the_notes_ref() {
let source = init_temp_repo("commit_notes_source");
let target = init_temp_repo("commit_notes_target");
let remote = init_bare_temp_repo("commit_notes_remote");
commit_initial_file(&source.path);
let commit = git_output_test(&source.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(
&source.path,
["remote", "add", "origin", remote_url.as_str()],
);
run_git_test(
&source.path,
["push", "-q", "origin", "HEAD:refs/heads/main"],
);
set_commit_note_for_repo(&source.path, &commit, "Shared review note")
.expect("source note should be created");
push_commit_notes_for_repo(&source.path, "origin", None, None)
.expect("notes should be pushed");
run_git_test(
&target.path,
["remote", "add", "origin", remote_url.as_str()],
);
run_git_test(&target.path, ["fetch", "-q", "origin", "main"]);
run_git_test(&target.path, ["checkout", "-q", "FETCH_HEAD"]);
fetch_commit_notes_for_repo(&target.path, "origin", None, None)
.expect("notes should be fetched");
assert_eq!(
commit_note_for_repo(&target.path, &commit).expect("fetched note should load"),
Some("Shared review note".to_string())
);
}
#[test]
fn clone_directory_name_is_inferred_from_common_remote_urls() {
assert_eq!(
infer_clone_directory_name("https://github.com/example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("git@github.com:example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
"project"
);
}
#[test]
fn clone_repository_core_clones_and_returns_repository_bundle() {
let source = init_temp_repo("clone_source");
commit_initial_file(&source.path);
let parent = temp_dir("clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(cloned_repo.join("old.txt").exists());
assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
assert!(bundle.warning.is_none());
}
#[test]
fn clone_repository_core_supports_empty_repository() {
let source = init_bare_temp_repo("empty_clone_source");
let parent = temp_dir("empty_clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("empty repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(bundle.status.clean);
assert!(bundle.commits.is_empty());
assert!(bundle.branches.is_empty());
assert!(bundle.tags.is_empty());
assert!(bundle.stashes.is_empty());
assert!(bundle.files.is_empty());
assert!(bundle.warning.is_none());
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local LFS clone tests with a sh signal pipe error"
)]
fn clone_repository_core_activates_and_downloads_lfs() {
let source = init_temp_repo("lfs_clone_source");
let remote = init_bare_temp_repo("lfs_clone_remote");
let parent = temp_dir("lfs_clone_parent");
run_git_test(&source.path, ["lfs", "install", "--local"]);
run_git_test(&source.path, ["lfs", "track", "*.bin"]);
fs::write(source.path.join("asset.bin"), b"cloned LFS payload\n")
.expect("LFS fixture should be written");
run_git_test(&source.path, ["add", ".gitattributes", "asset.bin"]);
run_git_test(&source.path, ["commit", "-q", "-m", "add LFS object"]);
run_git_test(
&source.path,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote.path.as_os_str().to_owned(),
],
);
let branch = git_output_test(&source.path, ["branch", "--show-current"]);
run_git_test(&source.path, ["push", "-q", "-u", "origin", &branch]);
run_git_test(
&remote.path,
["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
);
let bundle = clone_repository_core(
remote.path.to_str().expect("remote path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("LFS repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert!(bundle.warning.is_none());
assert_eq!(
fs::read(cloned_repo.join("asset.bin")).expect("LFS object should be downloaded"),
b"cloned LFS payload\n"
);
assert!(
run_git(
&cloned_repo,
["config", "--local", "--get", "filter.lfs.process"]
)
.is_ok(),
"clone should configure repository-local LFS filters"
);
assert!(lfs_hook_installed(&cloned_repo));
}
#[test]
fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string");
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n")
.expect("initial source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]);
fs::write(
repo.path.join("src/app.ts"),
"export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n",
)
.expect("updated source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]);
let hits =
search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "src/app.ts");
assert_eq!(hits[0].summary, "add render widget");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_finds_multiline_function_block() {
let repo = init_temp_repo("search_multiline_function");
fs::write(repo.path.join("module.ts"), "export const ready = true;\n")
.expect("initial module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]);
let function_body = "export function parseThing() {\n return \"needle-token\";\n}";
fs::write(
repo.path.join("module.ts"),
format!("export const ready = true;\n\n{function_body}\n"),
)
.expect("updated module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]);
let hits =
search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "module.ts");
assert_eq!(hits[0].summary, "add parser");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_can_be_cancelled() {
let repo = init_temp_repo("search_cancelled");
fs::write(
repo.path.join("module.ts"),
"export const value = \"needle\";\n",
)
.expect("module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]);
let state = SearchCancellationState::default();
state
.cancel("test-search")
.expect("cancel flag should be set");
let result = search_code_introductions_core(
&repo.path,
"needle".to_string(),
false,
20,
Some(&SearchCancellation {
state: state.clone(),
search_id: "test-search".to_string(),
}),
);
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
}
#[test]
fn parses_branch_tracking_and_file_states() {
let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0";
let (branch, files) = parse_status_output(raw).unwrap();
assert_eq!(branch.current_branch.as_deref(), Some("main"));
assert_eq!(branch.upstream.as_deref(), Some("origin/main"));
assert_eq!(branch.ahead, 2);
assert_eq!(branch.behind, 1);
assert_eq!(
files,
vec![
GitFileStatus {
path: "changed.txt".to_string(),
old_path: None,
staged: None,
unstaged: Some(FileStatusKind::Modified),
},
GitFileStatus {
path: "deleted.txt".to_string(),
old_path: None,
staged: Some(FileStatusKind::Deleted),
unstaged: None,
},
GitFileStatus {
path: "new.txt".to_string(),
old_path: None,
staged: None,
unstaged: Some(FileStatusKind::Untracked),
},
]
);
}
#[test]
fn parses_z_renames_with_current_path_first() {
let raw = b"## feature\0R new-name.txt\0old-name.txt\0";
let (_, files) = parse_status_output(raw).unwrap();
assert_eq!(
files,
vec![GitFileStatus {
path: "new-name.txt".to_string(),
old_path: Some("old-name.txt".to_string()),
staged: Some(FileStatusKind::Renamed),
unstaged: None,
}]
);
}
#[test]
fn parses_conflicts_as_conflicted() {
let raw = b"## main\0UU conflicted.txt\0";
let (_, files) = parse_status_output(raw).unwrap();
assert_eq!(files[0].staged, Some(FileStatusKind::Conflicted));
assert_eq!(files[0].unstaged, Some(FileStatusKind::Conflicted));
}
#[test]
fn parses_initial_branch_header() {
let branch = parse_branch_header("## No commits yet on main");
assert_eq!(branch.current_branch.as_deref(), Some("main"));
assert_eq!(branch.upstream, None);
assert_eq!(branch.ahead, 0);
assert_eq!(branch.behind, 0);
}
#[test]
fn parses_reflog_records() {
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e";
let entries = parse_reflog(raw).expect("reflog should parse");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].selector, "HEAD@{0}");
assert_eq!(entries[0].action, "commit: Add feature");
}
#[test]
fn commit_editor_applies_only_the_matching_reword_message() {
let temp = temp_dir("commit_editor");
let queue = temp.path.join("queue");
let message = temp.path.join("COMMIT_EDITMSG");
fs::write(
&queue,
"first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e",
)
.expect("queue should be written");
fs::write(
&message,
"first commit\n\n# Please enter the commit message\n",
)
.expect("message should be written");
apply_reword_message(&queue, &message).expect("message should be applied");
assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n");
assert_eq!(
fs::read_to_string(&queue).unwrap(),
"second commit\x1frenamed second\x1e"
);
}
#[test]
fn interactive_rebase_builds_a_valid_reword_and_squash_plan() {
let repo = init_temp_repo("interactive_rebase");
commit_initial_file(&repo.path);
let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written");
run_git_test(&repo.path, ["add", "first.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]);
fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written");
run_git_test(&repo.path, ["add", "second.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]);
let commits = interactive_rebase_commits_for_repo(&repo.path, &base)
.expect("rebase commits should load");
assert_eq!(commits.len(), 2);
let plan = vec![
RebasePlanItem {
hash: commits[0].hash.clone(),
action: RebaseAction::Reword,
message: Some("combined feature".to_string()),
},
RebasePlanItem {
hash: commits[1].hash.clone(),
action: RebaseAction::Squash,
message: None,
},
];
validate_rebase_plan(&commits, &plan).expect("plan should be valid");
let todo = build_rebase_todo(&commits, &plan).expect("todo should build");
assert!(todo.contains(&format!("reword {} first commit", commits[0].hash)));
assert!(todo.contains(&format!("squash {} second commit", commits[1].hash)));
assert_eq!(
build_reword_queue(&commits, &plan).expect("queue should build"),
"first commit\x1fcombined feature\x1e"
);
}
#[test]
fn interactive_rebase_accepts_a_diverged_base_branch() {
let repo = init_temp_repo("interactive_rebase_diverged");
commit_initial_file(&repo.path);
let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
run_git_test(&repo.path, ["add", "main.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]);
run_git_test(&repo.path, ["checkout", "-q", "feature"]);
let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch)
.expect("diverged base should be accepted");
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "feature commit");
}
#[test]
fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() {
let repo = init_temp_repo("reflog_restore");
commit_initial_file(&repo.path);
let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written");
run_git_test(&repo.path, ["add", "later.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "later"]);
let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20))
.expect("reflog should load");
assert!(entries.len() >= 2);
let status = restore_reflog_entry(
repo.path.to_string_lossy().to_string(),
initial.clone(),
"recovery/initial".to_string(),
)
.expect("recovery branch should be created");
assert_eq!(status.current_branch.as_deref(), Some("recovery/initial"));
assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial);
assert!(
ref_exists(&repo.path, &format!("refs/heads/{original_branch}"))
.expect("original branch should still exist")
);
}
#[test]
fn parses_commit_history_records() {
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e";
let commits = parse_commit_log_metadata(raw).unwrap();
assert_eq!(
commits,
vec![GitCommit {
hash: "1111111111111111111111111111111111111111".to_string(),
short_hash: "1111111".to_string(),
author_name: "Ada Lovelace".to_string(),
author_email: "ada@example.com".to_string(),
date: "2026-06-26T12:34:56+02:00".to_string(),
refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()],
parents: vec![
"2222222222222222222222222222222222222222".to_string(),
"3333333333333333333333333333333333333333".to_string(),
],
summary: "Add history panel".to_string(),
files: Vec::new(),
has_note: false,
}]
);
}
#[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_accepts_branch_refs_for_a_full_repository_diff() {
let repo = init_temp_repo("compare_branches");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "base"]);
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
.expect("branch file should be written");
run_git_test(&repo.path, ["add", "branch-only.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
let comparison = compare_commits(
repo.path.to_string_lossy().to_string(),
"refs/heads/base".to_string(),
"refs/heads/feature/complete-compare".to_string(),
)
.unwrap();
assert!(comparison.files.iter().any(|file| {
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
}));
assert!(comparison.patch.contains("only on feature"));
}
#[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]
fn commits_for_repo_includes_all_branch_tips_for_graph() {
let repo = init_temp_repo("commits_all_branches");
commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]);
let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
run_git_test(&repo.path, ["add", "main.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]);
let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
assert!(commits.iter().any(|commit| commit.hash == main_commit));
assert!(commits.iter().any(|commit| {
commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph"))
}));
}
#[test]
fn commit_pages_use_stable_non_overlapping_offsets() {
let repo = init_temp_repo("commit_pages");
commit_initial_file(&repo.path);
for index in 1..=3 {
fs::write(repo.path.join("old.txt"), format!("version {index}\n"))
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(
&repo.path,
["commit", "-q", "-m", format!("commit {index}").as_str()],
);
}
let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
let first = commit_page_for_repo(&repo.path, Some(2), Some(0))
.expect("first commit page should load");
let second = commit_page_for_repo(&repo.path, Some(2), Some(2))
.expect("second commit page should load");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert_eq!(first[0].hash, all[0].hash);
assert_eq!(first[1].hash, all[1].hash);
assert_eq!(second[0].hash, all[2].hash);
assert_eq!(second[1].hash, all[3].hash);
assert!(
first
.iter()
.all(|commit| second.iter().all(|other| other.hash != commit.hash))
);
}
#[test]
fn pull_with_selected_remote_infers_current_branch() {
let repo = init_temp_repo("pull_selected_remote");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, false)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[test]
fn pull_can_explicitly_allow_unrelated_histories_for_merge() {
let repo = init_temp_repo("pull_unrelated_histories");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, true)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("--allow-unrelated-histories"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
async fn pull_retries_unrelated_histories_only_after_explicit_opt_in() {
let remote = init_temp_repo("pull_unrelated_remote");
fs::write(remote.path.join("remote.txt"), "remote history\n")
.expect("remote file should be written");
run_git_test(&remote.path, ["add", "remote.txt"]);
run_git_test(&remote.path, ["commit", "-q", "-m", "remote init"]);
let remote_branch = git_output_test(&remote.path, ["branch", "--show-current"]);
let local = init_temp_repo("pull_unrelated_local");
fs::write(local.path.join("local.txt"), "local history\n")
.expect("local file should be written");
run_git_test(&local.path, ["add", "local.txt"]);
run_git_test(&local.path, ["commit", "-q", "-m", "local init"]);
run_git_test(
&local.path,
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let error = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch.clone()),
Some(false),
)
.await
.expect_err("unrelated histories should require explicit opt-in");
assert!(error.contains("refusing to merge unrelated histories"));
let status = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch),
Some(true),
)
.await
.expect("explicitly allowed histories should merge");
assert!(status.clean, "{:?}", status.files);
assert!(local.path.join("local.txt").exists());
assert!(local.path.join("remote.txt").exists());
let parent_count =
git_output_test(&local.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
.split_whitespace()
.count()
- 1;
assert_eq!(parent_count, 2);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
let repo = init_temp_repo("pull_diverged");
commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "remote-change"]);
fs::write(repo.path.join("remote.txt"), "remote change\n")
.expect("remote file should be written");
run_git_test(&repo.path, ["add", "remote.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "remote change"]);
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
fs::write(repo.path.join("local.txt"), "local change\n")
.expect("local file should be written");
run_git_test(&repo.path, ["add", "local.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "local change"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
run_git_test(
&repo.path,
["config", &format!("branch.{base_branch}.remote"), "origin"],
);
run_git_test(
&repo.path,
[
"config",
&format!("branch.{base_branch}.merge"),
"refs/heads/remote-change",
],
);
let status = pull(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists());
assert!(repo.path.join("local.txt").exists());
let parent_count =
git_output_test(&repo.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
.split_whitespace()
.count()
- 1;
assert_eq!(parent_count, 2);
}
#[test]
fn push_args_use_set_upstream_for_branch_without_tracking_remote() {
let repo = init_temp_repo("push_sets_upstream");
let remote = init_bare_temp_repo("push_sets_upstream_remote");
commit_initial_file(&repo.path);
run_git_test(
&repo.path,
["checkout", "-q", "-b", "features/ai_commits_local"],
);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]);
run_git_test(
&repo.path,
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let args = push_args_for_repo(&repo.path).unwrap();
let args = args
.iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"push",
"--set-upstream",
"origin",
"features/ai_commits_local"
]
);
}
#[test]
fn detects_lfs_http_413_and_builds_scoped_http_1_1_retry() {
assert!(is_lfs_http_413_error(
"Git command failed: LFS: Client error https://example.test/object from HTTP 413\nerror: failed to push some refs"
));
assert!(!is_lfs_http_413_error(
"error: failed to push some refs (fetch first)"
));
let retry_args = push_args_with_http_1_1(vec![
OsString::from("push"),
OsString::from("--force-with-lease"),
OsString::from("origin"),
])
.into_iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
retry_args,
vec![
"-c",
"http.version=HTTP/1.1",
"push",
"--force-with-lease",
"origin"
]
);
}
#[test]
fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential =
serde_json::from_str(r#"{"username":"alice","password":"secret"}"#)
.expect("legacy credential should deserialize");
assert_eq!(legacy.username, "alice");
assert_eq!(legacy.password, "secret");
assert_eq!(legacy.mode, None);
let token = StoredCredential {
username: "alice".to_string(),
password: "token".to_string(),
mode: Some("token".to_string()),
};
let encoded = serde_json::to_string(&token).expect("credential should serialize");
assert!(encoded.contains(r#""mode":"token""#));
}
#[test]
fn auth_remote_url_uses_the_requested_direction() {
let repo = init_temp_repo("auth_remote_url_direction");
run_git_test(
&repo.path,
[
"remote",
"add",
"origin",
"https://gitea.example/fetch/repo.git",
],
);
run_git_test(
&repo.path,
[
"remote",
"set-url",
"--push",
"origin",
"https://gitea.example/push/repo.git",
],
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", false).as_deref(),
Some("https://gitea.example/fetch/repo.git")
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", true).as_deref(),
Some("https://gitea.example/push/repo.git")
);
}
#[test]
fn askpass_scripts_use_unique_paths() {
let first = next_askpass_path("test");
let second = next_askpass_path("test");
assert_ne!(first, second);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
async fn push_sets_upstream_for_branch_without_tracking_remote() {
let repo = init_temp_repo("push_sets_upstream_integration");
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
commit_initial_file(&repo.path);
run_git_test(
&repo.path,
["checkout", "-q", "-b", "features/ai_commits_local"],
);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature work"]);
run_git_test(
&repo.path,
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let status = push(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(
status.upstream.as_deref(),
Some("origin/features/ai_commits_local")
);
let local_head = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let remote_head = git_output_test(
&remote.path,
["rev-parse", "refs/heads/features/ai_commits_local"],
);
assert_eq!(remote_head, local_head);
}
#[test]
fn read_and_resolve_conflict_round_trip() {
let repo = init_temp_repo("resolve_conflict");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change");
run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
// The merge is expected to fail with a conflict, so run git directly.
let _ = git_command()
.arg("-C")
.arg(&repo.path)
.args(["merge", "--no-edit", "feature"])
.output()
.expect("git merge should start");
let conflict = read_conflict(
repo.path.to_string_lossy().to_string(),
"file.txt".to_string(),
)
.unwrap();
assert_eq!(
conflict.ours.unwrap().replace("\r\n", "\n"),
"ours change\n"
);
assert_eq!(
conflict.theirs.unwrap().replace("\r\n", "\n"),
"theirs change\n"
);
assert!(conflict.content.contains("<<<<<<<"));
let status = resolve_conflict(
repo.path.to_string_lossy().to_string(),
"file.txt".to_string(),
"resolved\n".to_string(),
)
.unwrap();
assert!(!status.files.iter().any(|file| {
matches!(file.staged, Some(FileStatusKind::Conflicted))
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
}));
let contents = fs::read_to_string(repo.path.join("file.txt")).unwrap();
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
}
#[tokio::test]
async fn commit_rejects_unresolved_merge_conflicts() {
let repo = init_temp_repo("commit_rejects_conflicts");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("file.txt"), "theirs change\n").expect("feature change");
run_git_test(&repo.path, ["commit", "-q", "-am", "feature change"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("file.txt"), "ours change\n").expect("main change");
run_git_test(&repo.path, ["commit", "-q", "-am", "main change"]);
let _ = git_command()
.arg("-C")
.arg(&repo.path)
.args(["merge", "--no-edit", "feature"])
.output()
.expect("git merge should start");
let err = commit(
repo.path.to_string_lossy().to_string(),
"should not commit".to_string(),
)
.await
.unwrap_err();
assert!(err.contains("Merge conflicts"));
let status = status_for_repo(&repo.path).unwrap();
assert!(has_unresolved_conflicts(&status));
}
#[test]
fn detects_binary_content_by_nul_byte() {
assert!(is_binary_bytes(&[0u8, 1, 2, 3]));
assert!(!is_binary_bytes(b"plain text content\n"));
}
#[test]
fn binary_conflict_can_be_resolved_by_side() {
let repo = init_temp_repo("binary_conflict");
fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3])
.expect("base binary should be written");
run_git_test(&repo.path, ["add", "img.bin"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("img.bin"), [0u8, 9, 9, 9, 9, 9]).expect("feature binary");
run_git_test(&repo.path, ["commit", "-q", "-am", "feature bin"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
let _ = git_command()
.arg("-C")
.arg(&repo.path)
.args(["merge", "--no-edit", "feature"])
.output()
.expect("git merge should start");
let conflict = read_conflict(
repo.path.to_string_lossy().to_string(),
"img.bin".to_string(),
)
.unwrap();
assert!(conflict.binary);
assert!(conflict.content.is_empty());
assert_eq!(conflict.ours_size, Some(3));
assert_eq!(conflict.theirs_size, Some(6));
let status = resolve_conflict_side(
repo.path.to_string_lossy().to_string(),
"img.bin".to_string(),
"ours".to_string(),
)
.unwrap();
assert!(!status.files.iter().any(|file| {
matches!(file.staged, Some(FileStatusKind::Conflicted))
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
}));
let bytes = fs::read(repo.path.join("img.bin")).unwrap();
assert_eq!(bytes, vec![0u8, 7, 7]);
}
#[test]
fn restore_staged_added_file_removes_it_from_index_and_worktree() {
let repo = init_temp_repo("restore_staged_added_file");
commit_initial_file(&repo.path);
fs::write(repo.path.join("new.txt"), "new\n").expect("added file should be written");
run_git_test(&repo.path, ["add", "new.txt"]);
let status = status_for_repo(&repo.path).unwrap();
assert_eq!(status.files[0].path, "new.txt");
assert_eq!(status.files[0].staged, Some(FileStatusKind::Added));
restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap();
let next_status = status_for_repo(&repo.path).unwrap();
assert!(next_status.clean, "{:?}", next_status.files);
assert!(!repo.path.join("new.txt").exists());
}
#[test]
fn restore_staged_rename_restores_old_path_and_removes_new_path() {
let repo = init_temp_repo("restore_staged_rename");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["mv", "old.txt", "new.txt"]);
let status = status_for_repo(&repo.path).unwrap();
assert_eq!(status.files[0].path, "new.txt");
assert_eq!(status.files[0].old_path.as_deref(), Some("old.txt"));
assert_eq!(status.files[0].staged, Some(FileStatusKind::Renamed));
restore_staged_files(&repo.path, &status.files, &["new.txt".to_string()]).unwrap();
let next_status = status_for_repo(&repo.path).unwrap();
assert!(next_status.clean, "{:?}", next_status.files);
assert!(repo.path.join("old.txt").exists());
assert!(!repo.path.join("new.txt").exists());
}
#[test]
fn plans_remote_branch_checkout_as_tracking_branch() {
let repo = init_temp_repo("remote_checkout_plan");
commit_initial_file(&repo.path);
run_git_test(
&repo.path,
["update-ref", "refs/remotes/origin/feature/demo", "HEAD"],
);
let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap();
assert_eq!(
plan,
CheckoutPlan::TrackRemote {
local: "feature/demo".to_string(),
remote: "origin/feature/demo".to_string(),
}
);
}
#[test]
fn remote_checkout_prefers_existing_local_branch() {
let repo = init_temp_repo("remote_checkout_existing_local");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature/demo"]);
run_git_test(
&repo.path,
["update-ref", "refs/remotes/origin/feature/demo", "HEAD"],
);
let plan = checkout_plan(&repo.path, "origin/feature/demo").unwrap();
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
}
#[test]
fn create_branch_creates_and_checks_out_local_branch() {
let repo = init_temp_repo("create_branch");
commit_initial_file(&repo.path);
let status = create_branch_core(
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_core(
repo.path.to_string_lossy().to_string(),
"feature/new-panel".to_string(),
None,
)
.unwrap_err();
assert!(err.contains("already exists"));
}
#[test]
fn rename_branch_renames_existing_local_branch() {
let repo = init_temp_repo("rename_branch");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
let status = rename_branch_core(
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]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn rename_remote_branch_moves_the_remote_ref_atomically() {
let repo = init_temp_repo("rename_remote_branch");
let remote = init_bare_temp_repo("rename_remote_branch_remote");
commit_initial_file(&repo.path);
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
run_git_test(
&repo.path,
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
);
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
rename_remote_branch_core(
repo.path.to_string_lossy().to_string(),
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
None,
)
.unwrap();
assert!(
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
"old remote branch should be gone"
);
assert_eq!(
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
commit
);
assert!(
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
"old remote-tracking branch should be gone"
);
assert!(
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
"new remote-tracking 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_core(
repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!(
!ref_exists(&repo.path, "refs/heads/stale").unwrap(),
"deleted branch should be gone"
);
let err =
delete_branch_core(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
assert!(err.contains("current branch"));
}
#[test]
fn delete_branch_can_force_delete_unmerged_branch() {
let repo = init_temp_repo("delete_branch_force");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
let err = delete_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(false),
)
.unwrap_err();
assert!(err.contains("not fully merged"));
delete_branch_core(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(true),
)
.unwrap();
assert!(
!ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(),
"force-deleted branch should be gone"
);
}
#[test]
fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch");
fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n")
.expect("initial file should be written");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n")
.expect("changed file should be written");
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
selected_patch.to_string(),
"stage".to_string(),
)
.expect("selected line should stage");
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
assert_eq!(
git_output_test(&repo.path, ["show", ":old.txt"]),
"one\nTWO\nthree"
);
assert_eq!(
fs::read_to_string(repo.path.join("old.txt"))
.expect("working tree should be readable")
.replace("\r\n", "\n"),
"one\nTWO\nthree\nfour\n"
);
let staged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
true,
)
.expect("staged patch should load");
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
staged_patch,
"unstage".to_string(),
)
.expect("selected staged lines should unstage");
assert_eq!(status.files[0].staged, None);
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
assert_eq!(
git_output_test(&repo.path, ["show", ":old.txt"]),
"one\ntwo\nthree"
);
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
selected_patch.to_string(),
"stage".to_string(),
)
.expect("selected line should stage again");
let unstaged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
false,
)
.expect("unstaged patch should load");
assert!(unstaged_patch.contains("+four"));
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
unstaged_patch,
"discard-unstaged".to_string(),
)
.expect("unstaged line should discard");
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
assert_eq!(status.files[0].unstaged, None);
assert_eq!(
fs::read_to_string(repo.path.join("old.txt"))
.expect("working tree should be readable")
.replace("\r\n", "\n"),
"one\nTWO\nthree\n"
);
}
#[test]
fn get_file_patch_splits_distant_changes_like_interactive_diff() {
let repo = init_temp_repo("file_patch_hunks");
let original = (1..=30)
.map(|line| format!("line {line}\n"))
.collect::<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);
}
#[test]
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
let repo = init_temp_repo("file_blame");
commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "original\ntwo\n")
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
let result = get_file_blame(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
)
.unwrap();
assert_eq!(result.path, "old.txt");
assert_eq!(result.lines.len(), 2);
assert_eq!(result.lines[0].content, "original");
assert_eq!(result.lines[0].summary, "init");
assert_eq!(result.lines[1].content, "two");
assert_eq!(result.lines[1].summary, "add second line");
assert!(!result.lines[1].author_name.is_empty());
assert!(!result.lines[1].is_uncommitted);
}
#[test]
fn get_file_blame_marks_uncommitted_working_tree_changes() {
let repo = init_temp_repo("file_blame_uncommitted");
commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
let result = get_file_blame(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
)
.unwrap();
assert_eq!(result.lines.len(), 1);
assert!(result.lines[0].is_uncommitted);
}
#[test]
fn parse_worktree_porcelain_preserves_flags_and_reasons() {
let current = Path::new("/repos/main");
let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0";
let rows = parse_worktree_porcelain(output, current);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_main);
assert_eq!(rows[0].branch.as_deref(), Some("main"));
assert_eq!(rows[0].short_head.as_deref(), Some("12345678"));
assert!(rows[1].detached);
assert!(rows[1].locked);
assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive"));
assert!(rows[1].prunable);
}
#[test]
fn worktree_add_list_and_remove_round_trip() {
let repo = init_temp_repo("worktree_round_trip");
let destination = temp_dir("worktree_round_trip_destination");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature"]);
let rows = add_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("feature".to_string()),
None,
None,
Some(false),
Some(false),
)
.expect("worktree should be created");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should be listed");
assert!(!linked.is_main);
assert!(linked.clean);
let rows = lock_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("test lock".to_string()),
)
.expect("worktree should lock");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should remain listed");
assert!(linked.locked);
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
unlock_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
)
.expect("worktree should unlock");
let rows = remove_worktree_core(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some(false),
)
.expect("worktree should be removed");
assert_eq!(rows.len(), 1);
}
#[test]
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
let raw = r#"```json
{"summary":"One issue found","risk":"HIGH","findings":[{"severity":"warn","title":"Unchecked result","description":"The new call ignores an error.","file":"src/main.rs","line":42,"suggestion":"Propagate the error."}]}
```"#;
let review = parse_ai_review(raw).expect("review JSON should parse");
assert_eq!(review.risk, AiReviewRisk::High);
assert_eq!(review.findings.len(), 1);
assert_eq!(review.findings[0].severity, AiReviewSeverity::Warning);
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
assert_eq!(review.findings[0].line, Some(42));
}
#[test]
fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() {
let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()];
let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#;
let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse");
assert_eq!(plan.groups.len(), 2);
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
}
#[test]
fn gitignore_file_rule_escapes_literal_path_characters() {
let repo = init_temp_repo("gitignore_literal_path");
fs::create_dir_all(repo.path.join("generated files"))
.expect("test directory should be created");
let target = "generated files/[draft]!.log";
fs::write(repo.path.join(target), "temporary\n").expect("test file should be written");
add_to_gitignore_for_repo(&repo.path, target, GitIgnoreKind::File)
.expect("file should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/generated\\ files/\\[draft\\]\\!.log\n"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", target]),
target
);
}
#[test]
fn gitignore_extension_rule_unstages_all_matching_new_files_without_duplicates() {
let repo = init_temp_repo("gitignore_extension");
fs::create_dir_all(repo.path.join("build")).expect("build directory should be created");
fs::write(repo.path.join("build/result.log"), "result\n")
.expect("nested log should be written");
fs::write(repo.path.join("debug.log"), "debug\n").expect("root log should be written");
run_git_test(&repo.path, ["add", "build/result.log", "debug.log"]);
let status =
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("extension should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
assert!(status.files.iter().all(|file| !file.path.ends_with(".log")));
assert!(git_output_test(&repo.path, ["ls-files"]).is_empty());
assert_eq!(
git_output_test(
&repo.path,
["check-ignore", "build/result.log", "debug.log"]
),
"build/result.log\ndebug.log"
);
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("existing rule should be accepted");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
}
#[test]
fn gitignore_folder_rule_only_unstages_files_below_the_selected_folder() {
let repo = init_temp_repo("gitignore_folder");
fs::create_dir_all(repo.path.join("cache/nested"))
.expect("cache directory should be created");
fs::create_dir_all(repo.path.join("cache-old"))
.expect("similarly named directory should be created");
fs::write(repo.path.join("cache/nested/result.tmp"), "cached\n")
.expect("cached file should be written");
fs::write(repo.path.join("cache-old/keep.tmp"), "keep\n")
.expect("kept file should be written");
run_git_test(
&repo.path,
["add", "cache/nested/result.tmp", "cache-old/keep.tmp"],
);
add_to_gitignore_for_repo(&repo.path, "cache", GitIgnoreKind::Folder)
.expect("folder should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/cache/\n"
);
assert_eq!(
git_output_test(&repo.path, ["ls-files"]),
"cache-old/keep.tmp"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", "cache/nested/result.tmp"]),
"cache/nested/result.tmp"
);
}
#[test]
fn untrack_paths_keep_worktree_files_and_only_remove_the_selected_scope() {
let repo = init_temp_repo("untrack_paths");
fs::create_dir_all(repo.path.join("generated/nested"))
.expect("tracked directory should be created");
fs::write(repo.path.join("generated/nested/output.bin"), "original\n")
.expect("tracked file should be written");
fs::write(repo.path.join("keep.txt"), "keep\n").expect("kept file should be written");
run_git_test(&repo.path, ["add", "."]);
run_git_test(&repo.path, ["commit", "-m", "initial"]);
fs::write(
repo.path.join("generated/nested/output.bin"),
"local change\n",
)
.expect("tracked file should be modified");
let targets = vec!["generated".to_string()];
untrack_paths_for_repo(&repo.path, &targets).expect("folder should be untracked");
assert_eq!(
fs::read_to_string(repo.path.join("generated/nested/output.bin"))
.expect("working-tree file should remain"),
"local change\n"
);
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
assert!(repo.path.join("keep.txt").is_file());
}
}