//! Native git operations via libgit2.
//!
//! Provides high-performance git operations for SF, eliminating the need
//! to spawn `git` child processes via execSync. Both read and write
//! operations are implemented natively.
//!
//! All functions have TypeScript fallbacks in `native-git-bridge.ts` for
//! environments where the native module is unavailable.

use git2::{
    build::CheckoutBuilder, BranchType, Delta, DiffOptions, IndexAddOption, MergeOptions,
    ObjectType, Repository, ResetType, Sort, StatusOptions,
};
use napi::bindgen_prelude::*;
use napi_derive::napi;
use std::path::Path;

// ─── Helpers ────────────────────────────────────────────────────────────────

/// Open a git repository at the given path.
fn open_repo(repo_path: &str) -> Result<Repository> {
    Repository::open(repo_path).map_err(|e| {
        Error::new(
            Status::GenericFailure,
            format!("Failed to open git repository at {repo_path}: {e}"),
        )
    })
}

/// Convert a git2 error to a napi error with context.
fn git_err(context: &str, e: git2::Error) -> Error {
    Error::new(Status::GenericFailure, format!("{context}: {e}"))
}

/// Validate that a file path stays within the repository boundary.
/// Prevents path traversal attacks via patterns like `../../etc/passwd`.
fn validate_path_within_repo(repo_path: &str, file_path: &str) -> Result<std::path::PathBuf> {
    let repo_dir = std::fs::canonicalize(repo_path).map_err(|e| {
        Error::new(
            Status::GenericFailure,
            format!("Failed to canonicalize repo path '{repo_path}': {e}"),
        )
    })?;
    let full_path = repo_dir.join(file_path);
    let canonical = if full_path.exists() {
        std::fs::canonicalize(&full_path).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Failed to canonicalize path '{file_path}': {e}"),
            )
        })?
    } else if let Some(parent) = full_path.parent() {
        if parent.exists() {
            let cp = std::fs::canonicalize(parent).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to canonicalize parent of '{file_path}': {e}"),
                )
            })?;
            cp.join(full_path.file_name().unwrap_or_default())
        } else {
            full_path.clone()
        }
    } else {
        full_path.clone()
    };
    if !canonical.starts_with(&repo_dir) {
        return Err(Error::new(
            Status::GenericFailure,
            format!("Path '{file_path}' escapes repository boundary"),
        ));
    }
    Ok(canonical)
}

/// Resolve a ref string to an Oid. Supports branch names, tags, HEAD, etc.
fn resolve_ref(repo: &Repository, refspec: &str) -> Result<git2::Oid> {
    repo.revparse_single(refspec)
        .map(|obj| obj.id())
        .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e))
}

/// Get the tree for a given ref.
fn ref_tree<'a>(repo: &'a Repository, refspec: &str) -> Result<git2::Tree<'a>> {
    let obj = repo
        .revparse_single(refspec)
        .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e))?;
    obj.peel_to_tree()
        .map_err(|e| git_err(&format!("Failed to peel '{refspec}' to tree"), e))
}

/// Find the merge base between two refs (for three-dot diff semantics).
fn merge_base_tree<'a>(
    repo: &'a Repository,
    from_ref: &str,
    to_ref: &str,
) -> Result<git2::Tree<'a>> {
    let from_oid = resolve_ref(repo, from_ref)?;
    let to_oid = resolve_ref(repo, to_ref)?;
    let base_oid = repo
        .merge_base(from_oid, to_oid)
        .map_err(|e| git_err("Failed to find merge base", e))?;
    let base_commit = repo
        .find_commit(base_oid)
        .map_err(|e| git_err("Failed to find merge base commit", e))?;
    base_commit
        .tree()
        .map_err(|e| git_err("Failed to get merge base tree", e))
}

// ─── NAPI Return Types ─────────────────────────────────────────────────────

#[napi(object)]
pub struct GitDiffStat {
    #[napi(js_name = "filesChanged")]
    pub files_changed: u32,
    pub insertions: u32,
    pub deletions: u32,
    pub summary: String,
}

#[napi(object)]
pub struct GitNameStatus {
    pub status: String,
    pub path: String,
}

#[napi(object)]
pub struct GitNumstat {
    pub added: u32,
    pub removed: u32,
    pub path: String,
}

#[napi(object)]
pub struct GitLogEntry {
    pub sha: String,
    pub message: String,
}

#[napi(object)]
pub struct GitWorktreeEntry {
    pub path: String,
    pub branch: String,
    #[napi(js_name = "isBare")]
    pub is_bare: bool,
}

#[napi(object)]
pub struct GitBatchInfo {
    pub branch: String,
    #[napi(js_name = "hasChanges")]
    pub has_changes: bool,
    pub status: String,
    #[napi(js_name = "stagedCount")]
    pub staged_count: u32,
    #[napi(js_name = "unstagedCount")]
    pub unstaged_count: u32,
}

#[napi(object)]
pub struct GitMergeResult {
    pub success: bool,
    pub conflicts: Vec<String>,
}

// ─── Existing Read Functions (unchanged) ────────────────────────────────────

/// Get the current branch name (HEAD symbolic ref).
/// Returns None if HEAD is detached.
#[napi]
pub fn git_current_branch(repo_path: String) -> Result<Option<String>> {
    let repo = open_repo(&repo_path)?;
    let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?;

    if head.is_branch() {
        Ok(head.shorthand().map(String::from))
    } else {
        Ok(None)
    }
}

/// Detect the main/integration branch for a repository.
///
/// Resolution order:
/// 1. refs/remotes/origin/HEAD -> extract branch name
/// 2. refs/heads/main exists -> "main"
/// 3. refs/heads/master exists -> "master"
/// 4. Fall back to current branch
#[napi]
pub fn git_main_branch(repo_path: String) -> Result<String> {
    let repo = open_repo(&repo_path)?;

    // Check origin/HEAD symbolic ref
    if let Ok(reference) = repo.find_reference("refs/remotes/origin/HEAD") {
        if let Ok(resolved) = reference.resolve() {
            if let Some(name) = resolved.name() {
                if let Some(branch) = name.strip_prefix("refs/remotes/origin/") {
                    return Ok(branch.to_string());
                }
            }
        }
    }

    if repo.find_reference("refs/heads/main").is_ok() {
        return Ok("main".to_string());
    }

    if repo.find_reference("refs/heads/master").is_ok() {
        return Ok("master".to_string());
    }

    let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?;
    Ok(head.shorthand().unwrap_or("HEAD").to_string())
}

/// Check if a local branch exists (refs/heads/<name>).
#[napi]
pub fn git_branch_exists(repo_path: String, branch: String) -> Result<bool> {
    let repo = open_repo(&repo_path)?;
    let refname = format!("refs/heads/{branch}");
    let exists = repo.find_reference(&refname).is_ok();
    Ok(exists)
}

/// Check if the repository index has unmerged entries (merge conflicts).
#[napi]
pub fn git_has_merge_conflicts(repo_path: String) -> Result<bool> {
    let repo = open_repo(&repo_path)?;
    let index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;
    Ok(index.has_conflicts())
}

/// Get working tree status in porcelain format.
/// Returns a string where each line is "XY path" (git status --porcelain).
#[napi]
pub fn git_working_tree_status(repo_path: String) -> Result<String> {
    let repo = open_repo(&repo_path)?;
    let mut opts = StatusOptions::new();
    opts.include_untracked(true).recurse_untracked_dirs(true);

    let statuses = repo
        .statuses(Some(&mut opts))
        .map_err(|e| git_err("Failed to get status", e))?;

    let mut lines = Vec::with_capacity(statuses.len());
    for entry in statuses.iter() {
        let status = entry.status();
        let path = entry.path().unwrap_or("?");

        let index_char = if status.is_index_new() {
            'A'
        } else if status.is_index_modified() {
            'M'
        } else if status.is_index_deleted() {
            'D'
        } else if status.is_index_renamed() {
            'R'
        } else if status.is_index_typechange() {
            'T'
        } else {
            ' '
        };

        let wt_char = if status.is_wt_new() {
            '?'
        } else if status.is_wt_modified() {
            'M'
        } else if status.is_wt_deleted() {
            'D'
        } else if status.is_wt_renamed() {
            'R'
        } else if status.is_wt_typechange() {
            'T'
        } else {
            ' '
        };

        lines.push(format!("{index_char}{wt_char} {path}"));
    }

    Ok(lines.join("\n"))
}

/// Quick check: are there any staged or unstaged changes in the working tree?
#[napi]
pub fn git_has_changes(repo_path: String) -> Result<bool> {
    let repo = open_repo(&repo_path)?;
    let mut opts = StatusOptions::new();
    opts.include_untracked(true);

    let statuses = repo
        .statuses(Some(&mut opts))
        .map_err(|e| git_err("Failed to get status", e))?;

    Ok(!statuses.is_empty())
}

/// Count commits between two refs (equivalent to `git rev-list --count from..to`).
#[napi]
pub fn git_commit_count_between(
    repo_path: String,
    from_ref: String,
    to_ref: String,
) -> Result<u32> {
    let repo = open_repo(&repo_path)?;

    let from_oid = resolve_ref(&repo, &from_ref)?;
    let to_oid = resolve_ref(&repo, &to_ref)?;

    let mut revwalk = repo
        .revwalk()
        .map_err(|e| git_err("Failed to create revwalk", e))?;

    revwalk
        .push(to_oid)
        .map_err(|e| git_err("Failed to push to_ref", e))?;
    revwalk
        .hide(from_oid)
        .map_err(|e| git_err("Failed to hide from_ref", e))?;

    Ok(revwalk.count() as u32)
}

// ─── New Read Functions ─────────────────────────────────────────────────────

/// Check if a path is inside a git repository.
/// Replaces: `git rev-parse --git-dir`
#[napi]
pub fn git_is_repo(path: String) -> bool {
    Repository::open(&path).is_ok()
}

/// Check if there are any staged changes (index differs from HEAD).
/// Replaces: `git diff --cached --stat` check
#[napi]
pub fn git_has_staged_changes(repo_path: String) -> Result<bool> {
    let repo = open_repo(&repo_path)?;

    // Get HEAD tree (may not exist for initial commit)
    let head_tree = match repo.head() {
        Ok(head) => {
            let commit = head
                .peel_to_commit()
                .map_err(|e| git_err("Failed to peel HEAD to commit", e))?;
            Some(
                commit
                    .tree()
                    .map_err(|e| git_err("Failed to get HEAD tree", e))?,
            )
        }
        Err(_) => None, // No commits yet — everything in index is "staged"
    };

    let diff = repo
        .diff_tree_to_index(head_tree.as_ref(), None, None)
        .map_err(|e| git_err("Failed to diff tree to index", e))?;

    Ok(diff.deltas().len() > 0)
}

/// Get diff statistics between two refs, or between HEAD and working tree.
/// When `from_ref` is "HEAD" and `to_ref` is "WORKDIR", diffs working tree vs HEAD.
/// When `from_ref` is "HEAD" and `to_ref` is "INDEX", diffs index vs HEAD (staged).
/// Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD`
#[napi]
pub fn git_diff_stat(repo_path: String, from_ref: String, to_ref: String) -> Result<GitDiffStat> {
    let repo = open_repo(&repo_path)?;

    let diff = match (from_ref.as_str(), to_ref.as_str()) {
        ("HEAD", "WORKDIR") => {
            let head_tree = match repo.head() {
                Ok(head) => Some(
                    head.peel_to_tree()
                        .map_err(|e| git_err("Failed to peel HEAD to tree", e))?,
                ),
                Err(_) => None,
            };
            repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), None)
                .map_err(|e| git_err("Failed to diff", e))?
        }
        ("HEAD", "INDEX") => {
            let head_tree = match repo.head() {
                Ok(head) => Some(
                    head.peel_to_tree()
                        .map_err(|e| git_err("Failed to peel HEAD to tree", e))?,
                ),
                Err(_) => None,
            };
            repo.diff_tree_to_index(head_tree.as_ref(), None, None)
                .map_err(|e| git_err("Failed to diff", e))?
        }
        _ => {
            let from_tree = ref_tree(&repo, &from_ref)?;
            let to_tree = ref_tree(&repo, &to_ref)?;
            repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
                .map_err(|e| git_err("Failed to diff", e))?
        }
    };

    let stats = diff
        .stats()
        .map_err(|e| git_err("Failed to get diff stats", e))?;

    let summary = stats
        .to_buf(git2::DiffStatsFormat::FULL, 80)
        .map_err(|e| git_err("Failed to format diff stats", e))?
        .as_str()
        .unwrap_or("")
        .to_string();

    Ok(GitDiffStat {
        files_changed: stats.files_changed() as u32,
        insertions: stats.insertions() as u32,
        deletions: stats.deletions() as u32,
        summary,
    })
}

/// Get name-status diff between two refs with optional pathspec filter.
/// `use_merge_base`: if true, uses three-dot semantics (diff from merge base).
/// Replaces: `git diff --name-status main...branch -- .sf/`
#[napi]
pub fn git_diff_name_status(
    repo_path: String,
    from_ref: String,
    to_ref: String,
    pathspec: Option<String>,
    use_merge_base: Option<bool>,
) -> Result<Vec<GitNameStatus>> {
    let repo = open_repo(&repo_path)?;

    let mut diff_opts = DiffOptions::new();
    if let Some(ref ps) = pathspec {
        diff_opts.pathspec(ps);
    }

    let from_tree = if use_merge_base.unwrap_or(false) {
        merge_base_tree(&repo, &from_ref, &to_ref)?
    } else {
        ref_tree(&repo, &from_ref)?
    };
    let to_tree = ref_tree(&repo, &to_ref)?;

    let diff = repo
        .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts))
        .map_err(|e| git_err("Failed to diff trees", e))?;

    let mut results = Vec::with_capacity(diff.deltas().len());
    for delta in diff.deltas() {
        let status_char = match delta.status() {
            Delta::Added => "A",
            Delta::Deleted => "D",
            Delta::Modified => "M",
            Delta::Renamed => "R",
            Delta::Copied => "C",
            Delta::Typechange => "T",
            _ => continue,
        };
        let path = delta
            .new_file()
            .path()
            .or_else(|| delta.old_file().path())
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();

        results.push(GitNameStatus {
            status: status_char.to_string(),
            path,
        });
    }

    Ok(results)
}

/// Get numstat diff between two refs.
/// Replaces: `git diff --numstat main branch`
#[napi]
pub fn git_diff_numstat(
    repo_path: String,
    from_ref: String,
    to_ref: String,
) -> Result<Vec<GitNumstat>> {
    let repo = open_repo(&repo_path)?;

    let from_tree = ref_tree(&repo, &from_ref)?;
    let to_tree = ref_tree(&repo, &to_ref)?;

    let diff = repo
        .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
        .map_err(|e| git_err("Failed to diff trees", e))?;

    // Collect paths per delta index, then count lines in a second pass
    let mut results = Vec::new();
    for delta in diff.deltas() {
        let path = delta
            .new_file()
            .path()
            .or_else(|| delta.old_file().path())
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();
        results.push(GitNumstat {
            added: 0,
            removed: 0,
            path,
        });
    }

    // Count added/removed lines per file using the patch API
    for (i, _) in diff.deltas().enumerate() {
        if let Ok(Some(patch)) = git2::Patch::from_diff(&diff, i) {
            let (_, additions, deletions) = patch.line_stats().unwrap_or((0, 0, 0));
            if let Some(entry) = results.get_mut(i) {
                entry.added = additions as u32;
                entry.removed = deletions as u32;
            }
        }
    }

    Ok(results)
}

/// Get unified diff content between two refs with optional pathspec/exclude.
/// `use_merge_base`: if true, uses three-dot semantics.
/// `exclude`: optional pathspec to exclude (e.g., ".sf/").
/// Replaces: `git diff main...branch -- .sf/` and `-- . :(exclude).sf/`
#[napi]
pub fn git_diff_content(
    repo_path: String,
    from_ref: String,
    to_ref: String,
    pathspec: Option<String>,
    exclude: Option<String>,
    use_merge_base: Option<bool>,
) -> Result<String> {
    let repo = open_repo(&repo_path)?;

    let mut diff_opts = DiffOptions::new();
    if let Some(ref ps) = pathspec {
        diff_opts.pathspec(ps);
    }

    let from_tree = if use_merge_base.unwrap_or(false) {
        merge_base_tree(&repo, &from_ref, &to_ref)?
    } else {
        ref_tree(&repo, &from_ref)?
    };
    let to_tree = ref_tree(&repo, &to_ref)?;

    let diff = repo
        .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts))
        .map_err(|e| git_err("Failed to diff trees", e))?;

    let exclude_prefix = exclude.as_deref();

    let mut output = String::new();
    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
        // Apply exclude filter
        if let Some(excl) = exclude_prefix {
            let path = delta
                .new_file()
                .path()
                .or_else(|| delta.old_file().path())
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_default();
            if path.starts_with(excl) {
                return true;
            }
        }

        let prefix = match line.origin() {
            '+' | '-' | ' ' => {
                output.push(line.origin());
                ""
            }
            'F' | 'H' | 'B' => "",
            _ => "",
        };
        output.push_str(prefix);
        if let Ok(content) = std::str::from_utf8(line.content()) {
            output.push_str(content);
        }
        true
    })
    .map_err(|e| git_err("Failed to print diff", e))?;

    Ok(output)
}

/// Get commit log between two refs (from..to).
/// Replaces: `git log --oneline main..branch`
#[napi]
pub fn git_log_oneline(
    repo_path: String,
    from_ref: String,
    to_ref: String,
) -> Result<Vec<GitLogEntry>> {
    let repo = open_repo(&repo_path)?;

    let from_oid = resolve_ref(&repo, &from_ref)?;
    let to_oid = resolve_ref(&repo, &to_ref)?;

    let mut revwalk = repo
        .revwalk()
        .map_err(|e| git_err("Failed to create revwalk", e))?;
    revwalk.set_sorting(Sort::TIME).ok();
    revwalk
        .push(to_oid)
        .map_err(|e| git_err("Failed to push to_ref", e))?;
    revwalk
        .hide(from_oid)
        .map_err(|e| git_err("Failed to hide from_ref", e))?;

    let mut entries = Vec::new();
    for oid in revwalk.flatten() {
        if let Ok(commit) = repo.find_commit(oid) {
            let sha = format!("{:.7}", oid);
            let message = commit.summary().unwrap_or("").to_string();
            entries.push(GitLogEntry { sha, message });
        }
    }

    Ok(entries)
}

/// List git worktrees in porcelain format.
/// Replaces: `git worktree list --porcelain`
#[napi]
pub fn git_worktree_list(repo_path: String) -> Result<Vec<GitWorktreeEntry>> {
    let repo = open_repo(&repo_path)?;

    let mut entries = Vec::new();

    // Add the main worktree
    if let Some(workdir) = repo.workdir() {
        let branch = match repo.head() {
            Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(),
            Err(_) => "HEAD".to_string(),
        };
        entries.push(GitWorktreeEntry {
            path: workdir.to_string_lossy().to_string(),
            branch,
            is_bare: false,
        });
    } else if repo.is_bare() {
        entries.push(GitWorktreeEntry {
            path: repo.path().to_string_lossy().to_string(),
            branch: String::new(),
            is_bare: true,
        });
    }

    // List linked worktrees
    if let Ok(worktrees) = repo.worktrees() {
        for wt_name in worktrees.iter().flatten() {
            if let Ok(wt) = repo.find_worktree(wt_name) {
                let wt_path = wt.path().to_string_lossy().to_string();
                // Open the worktree's repo to read its HEAD
                let branch = match Repository::open(&wt_path) {
                    Ok(wt_repo) => match wt_repo.head() {
                        Ok(head) => {
                            if let Some(name) = head.name() {
                                name.strip_prefix("refs/heads/")
                                    .unwrap_or(head.shorthand().unwrap_or("HEAD"))
                                    .to_string()
                            } else {
                                "HEAD".to_string()
                            }
                        }
                        Err(_) => "HEAD".to_string(),
                    },
                    Err(_) => String::new(),
                };
                entries.push(GitWorktreeEntry {
                    path: wt_path,
                    branch,
                    is_bare: false,
                });
            }
        }
    }

    Ok(entries)
}

/// List branches matching an optional glob pattern.
/// Replaces: `git branch --list milestone/*`, `git branch --list sf/*`
#[napi]
pub fn git_branch_list(repo_path: String, pattern: Option<String>) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let branches = repo
        .branches(Some(BranchType::Local))
        .map_err(|e| git_err("Failed to list branches", e))?;

    let mut names = Vec::new();
    for branch_result in branches {
        let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?;
        if let Some(name) = branch.name().ok().flatten() {
            if let Some(ref pat) = pattern {
                // Simple glob matching: support "prefix/*" and "prefix/*/*"
                if matches_branch_pattern(name, pat) {
                    names.push(name.to_string());
                }
            } else {
                names.push(name.to_string());
            }
        }
    }

    Ok(names)
}

/// Simple branch pattern matching for patterns like "milestone/*", "sf/*/*"
fn matches_branch_pattern(name: &str, pattern: &str) -> bool {
    // Handle simple prefix/* patterns
    if let Some(prefix) = pattern.strip_suffix("/*") {
        // For "sf/*/*", this becomes "sf/*" after first strip
        if prefix.contains('*') {
            // Recursive: "sf/*/*" → name must start with "sf/" and have at least 2 segments after
            if let Some(inner_prefix) = prefix.strip_suffix("/*") {
                return name.starts_with(&format!("{inner_prefix}/"))
                    && name[inner_prefix.len() + 1..].contains('/');
            }
        }
        return name.starts_with(&format!("{prefix}/"));
    }
    // Exact match
    name == pattern
}

/// List branches that have been merged into the given target branch.
/// Replaces: `git branch --merged main --list sf/*`
#[napi]
pub fn git_branch_list_merged(
    repo_path: String,
    target: String,
    pattern: Option<String>,
) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let target_oid = resolve_ref(&repo, &target)?;

    let branches = repo
        .branches(Some(BranchType::Local))
        .map_err(|e| git_err("Failed to list branches", e))?;

    let mut merged = Vec::new();
    for branch_result in branches {
        let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?;
        if let Some(name) = branch.name().ok().flatten() {
            // Apply pattern filter
            if let Some(ref pat) = pattern {
                if !matches_branch_pattern(name, pat) {
                    continue;
                }
            }

            // Check if merged: a branch is merged into target if the merge base
            // of the branch tip and target equals the branch tip.
            if let Ok(branch_ref) = branch.get().peel(ObjectType::Commit) {
                let branch_oid = branch_ref.id();
                if let Ok(base) = repo.merge_base(target_oid, branch_oid) {
                    if base == branch_oid {
                        merged.push(name.to_string());
                    }
                }
            }
        }
    }

    Ok(merged)
}

/// List files tracked in the index matching a pathspec.
/// Replaces: `git ls-files "<path>"`
#[napi]
pub fn git_ls_files(repo_path: String, pathspec: String) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    let mut files = Vec::new();
    for entry in index.iter() {
        let path = String::from_utf8_lossy(&entry.path).to_string();
        if path.starts_with(&pathspec)
            || (pathspec.ends_with('/') && path.starts_with(pathspec.trim_end_matches('/')))
        {
            files.push(path);
        }
    }

    Ok(files)
}

/// List references matching a prefix.
/// Replaces: `git for-each-ref refs/sf/snapshots/ --format=%(refname)`
#[napi]
pub fn git_for_each_ref(repo_path: String, prefix: String) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let glob = if prefix.ends_with('/') {
        format!("{prefix}*")
    } else {
        format!("{prefix}/*")
    };

    let refs = repo
        .references_glob(&glob)
        .map_err(|e| git_err("Failed to list references", e))?;

    let mut names = Vec::new();
    for r in refs.flatten() {
        if let Some(name) = r.name() {
            names.push(name.to_string());
        }
    }

    Ok(names)
}

/// Get list of files with unmerged (conflict) entries in the index.
/// Replaces: `git diff --name-only --diff-filter=U`
#[napi]
pub fn git_conflict_files(repo_path: String) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    if !index.has_conflicts() {
        return Ok(Vec::new());
    }

    let conflicts = index
        .conflicts()
        .map_err(|e| git_err("Failed to read conflicts", e))?;

    let mut files = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for conflict in conflicts.flatten() {
        // A conflict has ancestor, our, theirs entries — get the path from whichever exists
        let path = conflict
            .our
            .as_ref()
            .or(conflict.their.as_ref())
            .or(conflict.ancestor.as_ref())
            .map(|entry| String::from_utf8_lossy(&entry.path).to_string());

        if let Some(p) = path {
            if seen.insert(p.clone()) {
                files.push(p);
            }
        }
    }

    Ok(files)
}

/// Get batch info: branch + status + change counts in ONE call.
/// Replaces: sequential calls to getCurrentBranch + hasChanges + status.
#[napi]
pub fn git_batch_info(repo_path: String) -> Result<GitBatchInfo> {
    let repo = open_repo(&repo_path)?;

    // Branch
    let branch = match repo.head() {
        Ok(head) => {
            if head.is_branch() {
                head.shorthand().unwrap_or("HEAD").to_string()
            } else {
                "HEAD".to_string()
            }
        }
        Err(_) => String::new(),
    };

    // Status
    let mut opts = StatusOptions::new();
    opts.include_untracked(true).recurse_untracked_dirs(true);

    let statuses = repo
        .statuses(Some(&mut opts))
        .map_err(|e| git_err("Failed to get status", e))?;

    let has_changes = !statuses.is_empty();
    let mut staged_count: u32 = 0;
    let mut unstaged_count: u32 = 0;
    let mut lines = Vec::with_capacity(statuses.len());

    for entry in statuses.iter() {
        let status = entry.status();
        let path = entry.path().unwrap_or("?");

        let index_char = if status.is_index_new() {
            staged_count += 1;
            'A'
        } else if status.is_index_modified() {
            staged_count += 1;
            'M'
        } else if status.is_index_deleted() {
            staged_count += 1;
            'D'
        } else if status.is_index_renamed() {
            staged_count += 1;
            'R'
        } else if status.is_index_typechange() {
            staged_count += 1;
            'T'
        } else {
            ' '
        };

        let wt_char = if status.is_wt_new() {
            unstaged_count += 1;
            '?'
        } else if status.is_wt_modified() {
            unstaged_count += 1;
            'M'
        } else if status.is_wt_deleted() {
            unstaged_count += 1;
            'D'
        } else if status.is_wt_renamed() {
            unstaged_count += 1;
            'R'
        } else if status.is_wt_typechange() {
            unstaged_count += 1;
            'T'
        } else {
            ' '
        };

        lines.push(format!("{index_char}{wt_char} {path}"));
    }

    Ok(GitBatchInfo {
        branch,
        has_changes,
        status: lines.join("\n"),
        staged_count,
        unstaged_count,
    })
}

// ─── Write Functions ────────────────────────────────────────────────────────

/// Initialize a new git repository.
/// Replaces: `git init -b <branch>`
#[napi]
pub fn git_init(path: String, initial_branch: Option<String>) -> Result<()> {
    let repo = Repository::init(&path).map_err(|e| git_err("Failed to init repository", e))?;

    // Set initial branch name if specified
    if let Some(branch_name) = initial_branch {
        // For a new repo, HEAD points to refs/heads/master by default.
        // We need to update the symbolic ref to point to the desired branch.
        repo.set_head(&format!("refs/heads/{branch_name}"))
            .map_err(|e| git_err("Failed to set initial branch", e))?;
    }

    Ok(())
}

/// Stage all files (equivalent to `git add -A`).
/// Replaces: `git add -A`
#[napi]
pub fn git_add_all(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    index
        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
        .map_err(|e| git_err("Failed to add all files", e))?;

    // Also handle deletions: update the index to reflect removed files
    index
        .update_all(["*"].iter(), None)
        .map_err(|e| git_err("Failed to update index for deletions", e))?;

    index
        .write()
        .map_err(|e| git_err("Failed to write index", e))?;

    Ok(())
}

/// Stage specific files.
/// Replaces: `git add -- <file1> <file2> ...`
#[napi]
pub fn git_add_paths(repo_path: String, paths: Vec<String>) -> Result<()> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    index
        .add_all(paths.iter(), IndexAddOption::DEFAULT, None)
        .map_err(|e| git_err("Failed to add paths", e))?;

    index
        .write()
        .map_err(|e| git_err("Failed to write index", e))?;

    Ok(())
}

/// Unstage files (reset index entries to HEAD for specific paths).
/// Replaces: `git reset HEAD -- <path>`
#[napi]
pub fn git_reset_paths(repo_path: String, paths: Vec<String>) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    // Get HEAD commit's tree
    let head_obj = match repo.head() {
        Ok(head) => Some(
            head.peel(ObjectType::Commit)
                .map_err(|e| git_err("Failed to peel HEAD", e))?,
        ),
        Err(_) => None,
    };

    let pathspecs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();

    repo.reset_default(head_obj.as_ref(), pathspecs.iter())
        .map_err(|e| git_err("Failed to reset paths", e))?;

    Ok(())
}

/// Create a commit from the current index.
/// Returns the commit SHA.
/// Replaces: `git commit -m <message>`, `git commit --no-verify -F -`
#[napi]
pub fn git_commit(repo_path: String, message: String, allow_empty: Option<bool>) -> Result<String> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    // If message is empty, read from MERGE_MSG or SQUASH_MSG (--no-edit equivalent)
    let message = if message.is_empty() {
        let merge_msg_path = repo.path().join("MERGE_MSG");
        let squash_msg_path = repo.path().join("SQUASH_MSG");
        if merge_msg_path.exists() {
            std::fs::read_to_string(&merge_msg_path).unwrap_or_else(|_| "Merge commit".to_string())
        } else if squash_msg_path.exists() {
            std::fs::read_to_string(&squash_msg_path)
                .unwrap_or_else(|_| "Squash commit".to_string())
        } else {
            "Merge commit".to_string()
        }
    } else {
        message
    };

    // Write the index as a tree
    let tree_oid = index
        .write_tree()
        .map_err(|e| git_err("Failed to write tree", e))?;
    let tree = repo
        .find_tree(tree_oid)
        .map_err(|e| git_err("Failed to find tree", e))?;

    // Get parent commit(s)
    let parent = match repo.head() {
        Ok(head) => Some(
            head.peel_to_commit()
                .map_err(|e| git_err("Failed to peel HEAD to commit", e))?,
        ),
        Err(_) => None, // Initial commit
    };

    // Check if there are changes (unless allow_empty)
    if !allow_empty.unwrap_or(false) {
        if let Some(ref p) = parent {
            let parent_tree = p
                .tree()
                .map_err(|e| git_err("Failed to get parent tree", e))?;
            let diff = repo
                .diff_tree_to_tree(Some(&parent_tree), Some(&tree), None)
                .map_err(|e| git_err("Failed to diff for empty check", e))?;
            if diff.deltas().len() == 0 {
                return Err(Error::new(
                    Status::GenericFailure,
                    "nothing to commit, working tree clean",
                ));
            }
        }
    }

    // Create the signature from git config
    let sig = repo
        .signature()
        .map_err(|e| git_err("Failed to get signature", e))?;

    let parents: Vec<&git2::Commit> = parent.iter().collect();

    let oid = repo
        .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
        .map_err(|e| git_err("Failed to create commit", e))?;

    // Clean up merge/squash message files after commit
    for msg_file in &["SQUASH_MSG", "MERGE_MSG"] {
        let msg_path = repo.path().join(msg_file);
        if msg_path.exists() {
            std::fs::remove_file(&msg_path).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to clean up {msg_file}: {e}"),
                )
            })?;
        }
    }

    Ok(format!("{oid}"))
}

/// Checkout a branch (switch HEAD and update working tree).
/// Replaces: `git checkout <branch>`
#[napi]
pub fn git_checkout_branch(repo_path: String, branch: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    let refname = format!("refs/heads/{branch}");
    let obj = repo
        .revparse_single(&refname)
        .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?;

    repo.checkout_tree(
        &obj,
        Some(CheckoutBuilder::new().safe().recreate_missing(true)),
    )
    .map_err(|e| git_err(&format!("Failed to checkout '{branch}'"), e))?;

    repo.set_head(&refname)
        .map_err(|e| git_err(&format!("Failed to set HEAD to '{branch}'"), e))?;

    Ok(())
}

/// Resolve index conflicts by accepting "theirs" version for specific paths.
/// Replaces: `git checkout --theirs -- <file>`
#[napi]
pub fn git_checkout_theirs(repo_path: String, paths: Vec<String>) -> Result<()> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    for path in &paths {
        // Find the "theirs" (stage 3) entry in the index
        if let Some(entry) = index.get_path(Path::new(path), 3) {
            // Copy the entry data we need before mutating the index
            let blob_id = entry.id;
            let entry_mode = entry.mode;
            let entry_path = entry.path.clone();

            // Remove all conflict stages
            index.remove_path(Path::new(path)).ok();

            // Create a new stage-0 entry with the "theirs" content
            let resolved = git2::IndexEntry {
                ctime: git2::IndexTime::new(0, 0),
                mtime: git2::IndexTime::new(0, 0),
                dev: 0,
                ino: 0,
                mode: entry_mode,
                uid: 0,
                gid: 0,
                file_size: 0,
                id: blob_id,
                flags: 0, // stage 0
                flags_extended: 0,
                path: entry_path,
            };
            index
                .add(&resolved)
                .map_err(|e| git_err(&format!("Failed to add resolved '{path}'"), e))?;

            // Also checkout the file to working directory (with path traversal validation)
            let blob = repo
                .find_blob(blob_id)
                .map_err(|e| git_err(&format!("Failed to find blob for '{path}'"), e))?;
            let full_path = validate_path_within_repo(&repo_path, path)?;
            if let Some(parent) = full_path.parent() {
                std::fs::create_dir_all(parent).map_err(|e| {
                    Error::new(
                        Status::GenericFailure,
                        format!("Failed to create directory for '{path}': {e}"),
                    )
                })?;
            }
            std::fs::write(&full_path, blob.content()).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to write '{path}': {e}"),
                )
            })?;
        }
    }

    index
        .write()
        .map_err(|e| git_err("Failed to write index", e))?;

    Ok(())
}

/// Squash-merge a branch into the current branch.
/// Stages changes in the index but does NOT create a commit.
/// Replaces: `git merge --squash <branch>`
#[napi]
pub fn git_merge_squash(repo_path: String, branch: String) -> Result<GitMergeResult> {
    let repo = open_repo(&repo_path)?;

    let refname = format!("refs/heads/{branch}");
    let their_commit = repo
        .find_reference(&refname)
        .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?
        .peel_to_commit()
        .map_err(|e| git_err(&format!("Failed to peel '{branch}' to commit"), e))?;

    let annotated = repo
        .find_annotated_commit(their_commit.id())
        .map_err(|e| git_err("Failed to create annotated commit", e))?;

    // Perform the merge analysis
    let (analysis, _) = repo
        .merge_analysis(&[&annotated])
        .map_err(|e| git_err("Failed to analyze merge", e))?;

    if analysis.is_up_to_date() {
        return Ok(GitMergeResult {
            success: true,
            conflicts: vec![],
        });
    }

    // Perform the merge into the index
    let mut merge_opts = MergeOptions::new();
    let mut checkout_opts = CheckoutBuilder::new();
    checkout_opts.safe().allow_conflicts(true);

    repo.merge(
        &[&annotated],
        Some(&mut merge_opts),
        Some(&mut checkout_opts),
    )
    .map_err(|e| git_err("Failed to merge", e))?;

    // Check for conflicts
    let index = repo
        .index()
        .map_err(|e| git_err("Failed to read index after merge", e))?;

    let mut conflicts = Vec::new();
    if index.has_conflicts() {
        if let Ok(conflict_iter) = index.conflicts() {
            for conflict in conflict_iter.flatten() {
                let path = conflict
                    .our
                    .as_ref()
                    .or(conflict.their.as_ref())
                    .or(conflict.ancestor.as_ref())
                    .map(|entry| String::from_utf8_lossy(&entry.path).to_string());

                if let Some(p) = path {
                    conflicts.push(p);
                }
            }
        }
    }

    // For squash merge: clean up merge state (we don't want MERGE_HEAD)
    // This mimics `git merge --squash` which doesn't record the merge
    repo.cleanup_state()
        .map_err(|e| git_err("Failed to cleanup merge state", e))?;

    Ok(GitMergeResult {
        success: conflicts.is_empty(),
        conflicts,
    })
}

/// Abort an in-progress merge.
/// Replaces: `git merge --abort`
#[napi]
pub fn git_merge_abort(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    // Reset to HEAD
    let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?;
    let obj = head
        .peel(ObjectType::Commit)
        .map_err(|e| git_err("Failed to peel HEAD", e))?;

    repo.reset(&obj, ResetType::Hard, None)
        .map_err(|e| git_err("Failed to reset", e))?;

    // Clean up merge state files
    repo.cleanup_state()
        .map_err(|e| git_err("Failed to cleanup merge state", e))?;

    Ok(())
}

/// Abort an in-progress rebase.
/// Replaces: `git rebase --abort`
#[napi]
pub fn git_rebase_abort(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    // Check for rebase state and abort
    let git_dir = repo.path();
    let rebase_merge = git_dir.join("rebase-merge");
    let rebase_apply = git_dir.join("rebase-apply");

    if rebase_merge.exists() || rebase_apply.exists() {
        // Read ORIG_HEAD to know where to reset
        let orig_head_path = git_dir.join("ORIG_HEAD");
        if let Ok(orig_ref) = std::fs::read_to_string(&orig_head_path) {
            let oid_str = orig_ref.trim();
            if let Ok(oid) = git2::Oid::from_str(oid_str) {
                if let Ok(commit) = repo.find_commit(oid) {
                    let obj = commit.as_object();
                    repo.reset(obj, ResetType::Hard, None)
                        .map_err(|e| git_err("Failed to reset to ORIG_HEAD", e))?;
                }
            }
        }

        // Clean up rebase state directories
        if rebase_merge.exists() {
            std::fs::remove_dir_all(&rebase_merge).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to remove rebase-merge state: {e}"),
                )
            })?;
        }
        if rebase_apply.exists() {
            std::fs::remove_dir_all(&rebase_apply).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to remove rebase-apply state: {e}"),
                )
            })?;
        }
    }

    repo.cleanup_state()
        .map_err(|e| git_err("Failed to cleanup repo state", e))?;
    Ok(())
}

/// Hard reset to HEAD.
/// Replaces: `git reset --hard HEAD`
#[napi]
pub fn git_reset_hard(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    let head = repo.head().map_err(|e| git_err("Failed to read HEAD", e))?;
    let obj = head
        .peel(ObjectType::Commit)
        .map_err(|e| git_err("Failed to peel HEAD", e))?;

    repo.reset(&obj, ResetType::Hard, None)
        .map_err(|e| git_err("Failed to reset", e))?;

    Ok(())
}

/// Delete a branch.
/// Replaces: `git branch -D <branch>` (force=true) or `git branch -d <branch>` (force=false)
#[napi]
pub fn git_branch_delete(repo_path: String, branch: String, force: Option<bool>) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    let mut git_branch = repo
        .find_branch(&branch, BranchType::Local)
        .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?;

    if force.unwrap_or(false) {
        // Force delete (like -D): delete the ref directly
        let refname = format!("refs/heads/{branch}");
        if let Ok(mut reference) = repo.find_reference(&refname) {
            reference
                .delete()
                .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?;
        }
    } else {
        // Safe delete (like -d): only if fully merged
        git_branch
            .delete()
            .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?;
    }

    Ok(())
}

/// Force-reset a branch to point at a target ref.
/// Replaces: `git branch -f <branch> <target>`
#[napi]
pub fn git_branch_force_reset(repo_path: String, branch: String, target: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    let target_commit = repo
        .revparse_single(&target)
        .map_err(|e| git_err(&format!("Failed to resolve '{target}'"), e))?
        .peel_to_commit()
        .map_err(|e| git_err(&format!("Failed to peel '{target}' to commit"), e))?;

    repo.branch(&branch, &target_commit, true)
        .map_err(|e| git_err(&format!("Failed to reset branch '{branch}'"), e))?;

    Ok(())
}

/// Remove files from the index (cache) without touching the working tree.
/// Returns the list of files that were actually removed.
/// Replaces: `git rm --cached -r --ignore-unmatch <path>`
#[napi]
pub fn git_rm_cached(
    repo_path: String,
    paths: Vec<String>,
    recursive: Option<bool>,
) -> Result<Vec<String>> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    let is_recursive = recursive.unwrap_or(true);
    let mut removed = Vec::new();

    for path in &paths {
        if is_recursive && (path.ends_with('/') || Path::new(&repo_path).join(path).is_dir()) {
            // Remove all entries under this directory
            let prefix = if path.ends_with('/') {
                path.clone()
            } else {
                format!("{path}/")
            };
            let entries_to_remove: Vec<String> = index
                .iter()
                .filter_map(|entry| {
                    let entry_path = String::from_utf8_lossy(&entry.path).to_string();
                    if entry_path.starts_with(&prefix) || entry_path == path.trim_end_matches('/') {
                        Some(entry_path)
                    } else {
                        None
                    }
                })
                .collect();

            for entry_path in &entries_to_remove {
                if index.remove_path(Path::new(entry_path)).is_ok() {
                    removed.push(format!("rm '{entry_path}'"));
                }
            }
        } else if index.remove_path(Path::new(path)).is_ok() {
            removed.push(format!("rm '{path}'"));
        }
    }

    if !removed.is_empty() {
        index
            .write()
            .map_err(|e| git_err("Failed to write index", e))?;
    }

    Ok(removed)
}

/// Force-remove files from both index and working tree.
/// Replaces: `git rm --force -- <file>`
#[napi]
pub fn git_rm_force(repo_path: String, paths: Vec<String>) -> Result<()> {
    let repo = open_repo(&repo_path)?;
    let mut index = repo
        .index()
        .map_err(|e| git_err("Failed to read index", e))?;

    for path in &paths {
        index
            .remove_path(Path::new(path))
            .map_err(|e| git_err(&format!("Failed to remove '{path}' from index"), e))?;
        // Also delete from working tree (with path traversal validation)
        let full_path = validate_path_within_repo(&repo_path, path)?;
        if full_path.exists() {
            std::fs::remove_file(&full_path).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Failed to delete '{path}': {e}"),
                )
            })?;
        }
    }

    index
        .write()
        .map_err(|e| git_err("Failed to write index", e))?;

    Ok(())
}

/// Add a new git worktree.
/// Replaces: `git worktree add [-b <new_branch>] <path> <branch_or_start>`
#[napi]
pub fn git_worktree_add(
    repo_path: String,
    wt_path: String,
    branch: String,
    create_branch: Option<bool>,
    start_point: Option<String>,
) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    if create_branch.unwrap_or(false) {
        // Create a new branch from start_point, then add worktree
        let start = start_point.as_deref().unwrap_or("HEAD");
        let start_commit = repo
            .revparse_single(start)
            .map_err(|e| git_err(&format!("Failed to resolve '{start}'"), e))?
            .peel_to_commit()
            .map_err(|e| git_err(&format!("Failed to peel '{start}' to commit"), e))?;

        repo.branch(&branch, &start_commit, false)
            .map_err(|e| git_err(&format!("Failed to create branch '{branch}'"), e))?;
    }

    // Use git worktree add via the worktree API
    let refname = format!("refs/heads/{branch}");
    let reference = repo
        .find_reference(&refname)
        .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?;

    repo.worktree(
        &branch, // worktree name
        Path::new(&wt_path),
        Some(git2::WorktreeAddOptions::new().reference(Some(&reference))),
    )
    .map_err(|e| git_err(&format!("Failed to add worktree at '{wt_path}'"), e))?;

    Ok(())
}

/// Remove a git worktree.
/// Replaces: `git worktree remove [--force] <path>`
#[napi]
pub fn git_worktree_remove(repo_path: String, wt_path: String, force: Option<bool>) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    // Find the worktree by path
    if let Ok(worktrees) = repo.worktrees() {
        for wt_name in worktrees.iter().flatten() {
            if let Ok(wt) = repo.find_worktree(wt_name) {
                let path_str = wt.path().to_string_lossy().to_string();
                let normalized_wt = path_str.trim_end_matches('/');
                let normalized_target = wt_path.trim_end_matches('/');
                if normalized_wt == normalized_target {
                    if force.unwrap_or(false) {
                        // Force: validate (which marks it as prunable) then remove dir
                        wt.validate().ok(); // May fail if already invalid — that's fine
                        if wt.path().exists() {
                            std::fs::remove_dir_all(wt.path()).ok();
                        }
                        // Prune the entry
                        wt.prune(Some(
                            git2::WorktreePruneOptions::new()
                                .valid(true)
                                .locked(true)
                                .working_tree(true),
                        ))
                        .ok();
                    } else if wt.validate().is_ok() {
                        // Only prune if the worktree is valid
                        if wt.path().exists() {
                            std::fs::remove_dir_all(wt.path()).ok();
                        }
                        wt.prune(Some(git2::WorktreePruneOptions::new().valid(true)))
                            .ok();
                    }
                    return Ok(());
                }
            }
        }
    }

    // If worktree not found in git's list, try to clean up the directory anyway
    let wt = Path::new(&wt_path);
    if wt.exists() && force.unwrap_or(false) {
        std::fs::remove_dir_all(wt).ok();
    }

    Ok(())
}

/// Prune stale worktree entries.
/// Replaces: `git worktree prune`
#[napi]
pub fn git_worktree_prune(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    if let Ok(worktrees) = repo.worktrees() {
        for wt_name in worktrees.iter().flatten() {
            if let Ok(wt) = repo.find_worktree(wt_name) {
                if wt.validate().is_err() {
                    // Worktree is invalid (directory missing, etc.) — prune it
                    wt.prune(Some(
                        git2::WorktreePruneOptions::new()
                            .valid(false)
                            .working_tree(true),
                    ))
                    .ok();
                }
            }
        }
    }

    Ok(())
}

/// Revert a commit without auto-committing.
/// Replaces: `git revert --no-commit <sha>`
#[napi]
pub fn git_revert_commit(repo_path: String, sha: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    let oid = git2::Oid::from_str(&sha).map_err(|e| git_err(&format!("Invalid SHA '{sha}'"), e))?;

    let commit = repo
        .find_commit(oid)
        .map_err(|e| git_err(&format!("Commit '{sha}' not found"), e))?;

    repo.revert(&commit, None)
        .map_err(|e| git_err(&format!("Failed to revert commit '{sha}'"), e))?;

    // Clean up revert state since we don't want to auto-commit
    // (git revert --no-commit semantics)
    repo.cleanup_state().ok();

    Ok(())
}

/// Abort an in-progress revert.
/// Replaces: `git revert --abort`
#[napi]
pub fn git_revert_abort(repo_path: String) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    // Reset to HEAD
    if let Ok(head) = repo.head() {
        if let Ok(obj) = head.peel(ObjectType::Commit) {
            repo.reset(&obj, ResetType::Hard, None).ok();
        }
    }

    repo.cleanup_state().ok();
    Ok(())
}

/// Create or delete a ref.
/// When `target` is provided, creates/updates the ref to point at target.
/// When `target` is None, deletes the ref.
/// Replaces: `git update-ref <ref> HEAD` and `git update-ref -d <ref>`
#[napi]
pub fn git_update_ref(repo_path: String, refname: String, target: Option<String>) -> Result<()> {
    let repo = open_repo(&repo_path)?;

    match target {
        Some(target_ref) => {
            let oid = resolve_ref(&repo, &target_ref)?;
            repo.reference(&refname, oid, true, "update-ref")
                .map_err(|e| git_err(&format!("Failed to update ref '{refname}'"), e))?;
        }
        None => {
            if let Ok(mut reference) = repo.find_reference(&refname) {
                reference
                    .delete()
                    .map_err(|e| git_err(&format!("Failed to delete ref '{refname}'"), e))?;
            }
        }
    }

    Ok(())
}
