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>,
    branch: Option<String>,
    blobless: Option<bool>,
    custom_flags: Option<String>,
    shallow_depth: Option<u32>,
    shallow_since: Option<String>,
    sparse: Option<bool>,
    sparse_paths: Option<Vec<String>>,
) -> Result<RepositoryBundle, String> {
    tauri::async_runtime::spawn_blocking(move || {
        let clone_options = CloneRunOptions {
            branch,
            blobless: blobless.unwrap_or(false),
            custom_flags: custom_flags.unwrap_or_default(),
            shallow_depth,
            shallow_since,
            sparse: sparse.unwrap_or(false),
            sparse_paths: sparse_paths.unwrap_or_default(),
        };
        clone_repository_core(
            &remote_url,
            &parent_path,
            directory_name.as_deref(),
            username.as_deref(),
            password.as_deref(),
            commit_limit,
            &clone_options,
        )
    })
    .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", "-t", "--cached", "--deleted"])?;
    for entry in tracked_output
        .split(|byte| *byte == 0)
        .filter(|entry| !entry.is_empty())
    {
        let (tag, path_bytes) = if entry.len() >= 2 && entry[1] == b' ' {
            (entry[0], &entry[2..])
        } else {
            (b'H', entry)
        };
        if tag == b'S' {
            continue;
        }
        let path = String::from_utf8_lossy(path_bytes).into_owned();
        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())
}

#[derive(Debug, Default)]
struct CloneRunOptions {
    branch: Option<String>,
    blobless: bool,
    custom_flags: String,
    shallow_depth: Option<u32>,
    shallow_since: Option<String>,
    sparse: bool,
    sparse_paths: Vec<String>,
}

fn clone_repository_core(
    remote_url: &str,
    parent_path: &str,
    directory_name: Option<&str>,
    username: Option<&str>,
    password: Option<&str>,
    commit_limit: Option<u32>,
    clone_options: &CloneRunOptions,
) -> Result<RepositoryBundle, String> {
    let target = clone_target_path(remote_url, parent_path, directory_name)?;
    run_git_clone(
        remote_url.trim(),
        &target,
        username,
        password,
        clone_options,
    )?;

    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>,
    clone_options: &CloneRunOptions,
) -> Result<(), String> {
    if matches!(clone_options.shallow_depth, Some(0)) {
        return Err("Shallow clone depth must be at least 1.".to_string());
    }
    if clone_options.shallow_depth.is_some() && clone_options.shallow_since.is_some() {
        return Err(
            "Choose either shallow clone depth or shallow clone date, not both.".to_string(),
        );
    }
    let branch = validate_clone_branch(clone_options.branch.as_deref())?;
    let shallow_since = validate_shallow_since(clone_options.shallow_since.as_deref())?;
    let custom_flags = parse_custom_clone_flags(&clone_options.custom_flags)?;
    let sparse_paths = validate_sparse_checkout_paths(&clone_options.sparse_paths)?;
    let sparse_enabled = clone_options.sparse || !sparse_paths.is_empty();
    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");
    if let Some(branch) = branch {
        command.arg("--branch").arg(branch);
    }
    if let Some(depth) = clone_options.shallow_depth {
        command.arg("--depth").arg(depth.to_string());
    }
    if let Some(since) = shallow_since {
        command.arg("--shallow-since").arg(since);
    }
    if clone_options.blobless {
        command.arg("--filter=blob:none");
    }
    if sparse_enabled {
        command.arg("--sparse");
    }
    command.args(custom_flags);
    command
        .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() {
        if !sparse_paths.is_empty() {
            configure_sparse_checkout(target, &sparse_paths)?;
        }
        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 validate_clone_branch(branch: Option<&str>) -> Result<Option<String>, String> {
    let Some(branch) = branch.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };
    if branch.starts_with('-') || branch.chars().any(|character| character.is_control()) {
        return Err("Branch to clone contains invalid characters.".to_string());
    }
    Ok(Some(branch.to_string()))
}

fn validate_shallow_since(since: Option<&str>) -> Result<Option<String>, String> {
    let Some(since) = since.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };
    if since.starts_with('-') || since.chars().any(|character| character.is_control()) {
        return Err("Shallow clone date contains invalid characters.".to_string());
    }
    Ok(Some(since.to_string()))
}

fn parse_custom_clone_flags(input: &str) -> Result<Vec<String>, String> {
    let input = input.trim();
    if input.is_empty() {
        return Ok(Vec::new());
    }
    let flags = shlex::split(input).ok_or_else(|| {
        "Custom clone flags contain an unclosed quote or invalid escape.".to_string()
    })?;
    const BLOCKED_LONG_FLAGS: &[&str] = &[
        "--bare",
        "--branch",
        "--config",
        "--depth",
        "--filter",
        "--mirror",
        "--no-checkout",
        "--reference",
        "--reference-if-able",
        "--separate-git-dir",
        "--shallow-exclude",
        "--shallow-since",
        "--sparse",
        "--template",
        "--upload-pack",
    ];
    for flag in &flags {
        let name = flag.split_once('=').map_or(flag.as_str(), |(name, _)| name);
        if flag == "--"
            || BLOCKED_LONG_FLAGS.contains(&name)
            || matches!(name, "-b" | "-c" | "-n" | "-u")
        {
            return Err(format!(
                "Custom clone flag '{name}' is managed by Gitty or is not allowed."
            ));
        }
    }
    Ok(flags)
}

fn validate_sparse_checkout_paths(paths: &[String]) -> Result<Vec<String>, String> {
    let mut unique = BTreeSet::new();
    for raw_path in paths {
        let path = raw_path.trim();
        if path.is_empty() {
            continue;
        }
        if path.chars().any(|character| character.is_control()) {
            return Err("Sparse checkout paths contain invalid control characters.".to_string());
        }
        if Path::new(path).is_absolute()
            || path == ".."
            || path.starts_with("../")
            || path.starts_with("..\\")
        {
            return Err("Sparse checkout paths must be relative to the repository.".to_string());
        }
        unique.insert(path.to_string());
    }
    if paths.len() > 0 && unique.is_empty() {
        return Err("Enter at least one sparse checkout path.".to_string());
    }
    Ok(unique.into_iter().collect())
}

fn configure_sparse_checkout(target: &Path, paths: &[String]) -> Result<(), String> {
    let mut stdin = paths.join("\n");
    stdin.push('\n');
    run_git_with_stdin(
        target,
        ["sparse-checkout", "set", "--no-cone", "--stdin"],
        stdin.as_bytes(),
    )
    .map(|_| ())
    .map_err(|error| {
        format!("Repository cloned, but sparse checkout could not be configured: {error}")
    })
}

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),
            &CloneRunOptions::default(),
        )
        .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]
    #[cfg_attr(windows, ignore = "file:// clone URL differs on Windows")]
    fn clone_repository_core_supports_shallow_and_sparse_options() {
        let source = init_temp_repo("clone_options_source");
        commit_initial_file(&source.path);
        fs::create_dir_all(source.path.join("src")).expect("src directory should be created");
        fs::create_dir_all(source.path.join("docs")).expect("docs directory should be created");
        fs::write(source.path.join("src/included.txt"), b"included\n")
            .expect("included fixture should be written");
        fs::write(source.path.join("docs/excluded.txt"), b"excluded\n")
            .expect("excluded fixture should be written");
        run_git_test(
            &source.path,
            ["add", "src/included.txt", "docs/excluded.txt"],
        );
        run_git_test(&source.path, ["commit", "-q", "-m", "add sparse fixtures"]);
        fs::write(source.path.join("src/included.txt"), b"latest\n")
            .expect("latest fixture should be written");
        run_git_test(&source.path, ["add", "src/included.txt"]);
        run_git_test(
            &source.path,
            ["commit", "-q", "-m", "update included fixture"],
        );

        let parent = temp_dir("clone_options_parent");
        let remote_url = format!("file://{}", source.path.display());
        let branch = git_output_test(&source.path, ["branch", "--show-current"]);
        let clone_options = CloneRunOptions {
            branch: Some(branch),
            blobless: true,
            custom_flags: "--single-branch".to_string(),
            shallow_depth: Some(1),
            shallow_since: None,
            sparse: true,
            sparse_paths: vec!["src/".to_string()],
        };
        let bundle = clone_repository_core(
            &remote_url,
            parent.path.to_str().expect("parent path should be UTF-8"),
            Some("local-copy"),
            None,
            None,
            Some(100),
            &clone_options,
        )
        .expect("repository should clone shallow and sparse");

        let cloned_repo = parent.path.join("local-copy");
        assert!(cloned_repo.join(".git/shallow").exists());
        assert!(cloned_repo.join("src/included.txt").exists());
        assert!(!cloned_repo.join("docs/excluded.txt").exists());
        assert_eq!(bundle.commits.len(), 1);
        assert!(
            bundle
                .files
                .iter()
                .any(|file| file.path == "src/included.txt")
        );
        assert!(
            !bundle
                .files
                .iter()
                .any(|file| file.path == "docs/excluded.txt")
        );

        let root_only_parent = temp_dir("clone_sparse_root_parent");
        let root_only_bundle = clone_repository_core(
            &remote_url,
            root_only_parent
                .path
                .to_str()
                .expect("root-only parent path should be UTF-8"),
            Some("local-copy"),
            None,
            None,
            Some(100),
            &CloneRunOptions {
                sparse: true,
                ..CloneRunOptions::default()
            },
        )
        .expect("repository should clone sparse with root files only");
        let root_only_repo = root_only_parent.path.join("local-copy");
        assert!(root_only_repo.join("old.txt").exists());
        assert!(!root_only_repo.join("src/included.txt").exists());
        assert!(!root_only_repo.join("docs/excluded.txt").exists());
        assert!(
            root_only_bundle
                .files
                .iter()
                .any(|file| file.path == "old.txt")
        );
        assert!(
            !root_only_bundle
                .files
                .iter()
                .any(|file| file.path == "src/included.txt")
        );

        let since_parent = temp_dir("clone_since_parent");
        let since_options = CloneRunOptions {
            branch: clone_options.branch.clone(),
            shallow_since: Some("2000-01-01".to_string()),
            ..CloneRunOptions::default()
        };
        let since_bundle = clone_repository_core(
            &remote_url,
            since_parent
                .path
                .to_str()
                .expect("since parent path should be UTF-8"),
            Some("local-copy"),
            None,
            None,
            Some(100),
            &since_options,
        )
        .expect("repository should clone with a shallow-since date");
        assert_eq!(since_bundle.status.current_branch, clone_options.branch);
        assert!(
            since_parent
                .path
                .join("local-copy/src/included.txt")
                .exists()
        );
    }

    #[test]
    fn custom_clone_flags_are_parsed_without_a_shell_and_managed_flags_are_rejected() {
        assert_eq!(
            parse_custom_clone_flags("--recurse-submodules --origin 'team remote'")
                .expect("custom flags should parse"),
            vec!["--recurse-submodules", "--origin", "team remote"]
        );
        assert!(parse_custom_clone_flags("--depth 5").is_err());
        assert!(parse_custom_clone_flags("--upload-pack=/tmp/helper").is_err());
        assert!(parse_custom_clone_flags("--config core.hooksPath=/tmp/hooks").is_err());
        assert!(parse_custom_clone_flags("--recurse-submodules '").is_err());
    }

    #[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),
            &CloneRunOptions::default(),
        )
        .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),
            &CloneRunOptions::default(),
        )
        .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());
    }
}
