1277 lines · 43.6 KB · Rust
RawDownload
use std::collections::HashSet;use std::path::Path; use anyhow::{Result, anyhow};use warp_core::safe_warn;use warp_util::git::run_git_command;#[cfg(feature = "local_fs")]use warp_util::git::run_git_command_with_env; #[cfg(test)]#[path = "git_tests.rs"]mod tests; /// Returns the set of local branch names for the repo at `repo_path`./// Uses a synchronous subprocess call — suitable for call sites in/// synchronous view handlers where the result is needed immediately./// Returns an empty set on any failure (not a git repo, git not found, etc.).#[cfg(feature = "local_fs")]pub fn list_local_branches_sync(repo_path: &Path) -> HashSet<String> {    let output = command::blocking::Command::new("git")        .args(["branch", "--list", "--format=%(refname:short)"])        .current_dir(repo_path)        .stdout(command::Stdio::piped())        .stderr(command::Stdio::null())        .output();     match output {        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)            .lines()            .map(|line| line.trim().to_string())            .filter(|line| !line.is_empty())            .collect(),        _ => HashSet::new(),    }} #[cfg(not(feature = "local_fs"))]pub fn list_local_branches_sync(_repo_path: &Path) -> HashSet<String> {    HashSet::new()} /// Fetches the current git branch.#[cfg(not(feature = "local_fs"))]pub async fn detect_current_branch(_repo_path: &Path) -> Result<String> {    Err(anyhow!("Not supported without local_fs"))} /// Fetches the current git branch./// In detached HEAD state this returns the literal string "HEAD".#[cfg(feature = "local_fs")]pub async fn detect_current_branch(repo_path: &Path) -> Result<String> {    log::debug!("[GIT OPERATION] git.rs detect_current_branch git rev-parse --abbrev-ref HEAD");    let result = run_git_command(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]).await;     if result.is_err() {        log::debug!("[GIT OPERATION] git.rs detect_current_branch git branch --show-current");        run_git_command(repo_path, &["branch", "--show-current"]).await    } else {        result    }    .map(|branch_name| branch_name.trim().to_owned())} /// Like [`detect_current_branch`], but in detached HEAD state returns the short/// commit SHA instead of the literal "HEAD"./// (Matches the shell command `git symbolic-ref --short HEAD || git rev-parse --short HEAD`.)#[cfg(feature = "local_fs")]pub async fn detect_current_branch_display(repo_path: &Path) -> Result<String> {    let branch = detect_current_branch(repo_path).await?;    if branch == "HEAD" {        run_git_command(repo_path, &["rev-parse", "--short", "HEAD"])            .await            .map(|sha| sha.trim().to_owned())    } else {        Ok(branch)    }} #[cfg(not(feature = "local_fs"))]pub async fn detect_current_branch_display(_repo_path: &Path) -> Result<String> {    Err(anyhow!("Not supported without local_fs"))} /// Detects the main branch using git-branchless style heuristics.#[cfg(not(feature = "local_fs"))]pub async fn detect_main_branch(_repo_path: &Path) -> Result<String> {    Err(anyhow!("Not supported without local_fs"))} /// Detects the main branch using git-branchless style heuristics.#[cfg(feature = "local_fs")]pub async fn detect_main_branch(repo_path: &Path) -> Result<String> {    // First try to get the default branch from origin    log::debug!(        "[GIT OPERATION] git.rs detect_main_branch git symbolic-ref refs/remotes/origin/HEAD"    );    match run_git_command(repo_path, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {        Ok(output) => {            let branch_ref = output.trim();            if let Some(branch_name) = branch_ref.strip_prefix("refs/remotes/") {                return Ok(branch_name.to_string());            }        }        Err(_) => {            // If remote fetch fails, fall back to candidates.        }    }     // Fallback: try common main branch names in order of preference.    let candidates = ["origin/main", "origin/master", "main", "master", "develop"];     for candidate in candidates {        log::debug!(            "[GIT OPERATION] git.rs detect_main_branch git rev-parse --verify {candidate}^{{}}"        );        let result = run_git_command(            repo_path,            &["rev-parse", "--verify", &format!("{candidate}^{{}}")],        )        .await;         if result.is_ok() {            return Ok(candidate.to_string());        }    }     // Final fallback if all else fails.    log::debug!("[GIT OPERATION] git.rs detect_main_branch git branch --show-current");    run_git_command(repo_path, &["branch", "--show-current"]).await} /// Returns the SHA where `HEAD` forked from any other ref. Use/// `<fork>..HEAD` for "commits unique to this branch".#[cfg(not(feature = "local_fs"))]pub async fn detect_fork_point(    _repo_path: &Path,    _current_branch_name: Option<&str>,) -> Result<Option<String>> {    Err(anyhow!("Not supported without local_fs"))} /// See the no-`local_fs` stub above for documentation.#[cfg(feature = "local_fs")]pub async fn detect_fork_point(    repo_path: &Path,    current_branch_name: Option<&str>,) -> Result<Option<String>> {    // Exclude `<current>` and `origin/<current>` so the branch isn't    // subtracted from itself.    let current = current_branch_name        .map(str::trim)        .filter(|branch| !branch.is_empty() && *branch != "HEAD");     let branch_exclude = current.map(|c| format!("--exclude={c}"));    let remote_exclude = current.map(|c| format!("--exclude=origin/{c}"));     let mut args: Vec<&str> = vec!["rev-list", "HEAD", "--not"];    args.extend(branch_exclude.as_deref());    args.push("--branches");    args.extend(remote_exclude.as_deref());    args.push("--remotes");     let unique = match run_git_command(repo_path, &args).await {        Ok(out) => out,        Err(e) => {            log::debug!("detect_fork_point: rev-list failed: {e}");            return Ok(None);        }    };     // Last non-empty line = oldest unique commit; its parent = fork point.    // No unique commits means HEAD is fully shared, so fork = HEAD.    let target = match unique.lines().rfind(|l| !l.trim().is_empty()) {        Some(sha) => format!("{}^", sha.trim()),        None => "HEAD".to_string(),    };    Ok(run_git_command(repo_path, &["rev-parse", &target])        .await        .ok()        .map(|s| s.trim().to_string()))} /// Git summary for a repo: current branch + uncommitted diff stats.#[derive(Debug, Clone)]#[cfg(feature = "local_fs")]pub struct RepoGitSummary {    pub branch: String,    pub lines_added: u32,    pub lines_removed: u32,} /// Runs git commands in `repo_root` to get current branch + diff stats./// Returns None if not a git repo or git is unavailable.#[cfg(feature = "local_fs")]pub async fn get_repo_git_summary(repo_root: &Path) -> Option<RepoGitSummary> {    use crate::context_chips::display_chip::GitLineChanges;     let branch = {        log::debug!("[GIT OPERATION] git.rs get_repo_git_summary git symbolic-ref --short HEAD");        let result = run_git_command(repo_root, &["symbolic-ref", "--short", "HEAD"]).await;        match result {            Ok(output) => Some(output.trim().to_string()),            Err(_) => {                // Fallback to rev-parse for detached HEAD                log::debug!(                    "[GIT OPERATION] git.rs get_repo_git_summary git rev-parse --short HEAD"                );                run_git_command(repo_root, &["rev-parse", "--short", "HEAD"])                    .await                    .ok()                    .map(|o| o.trim().to_string())            }        }    };     // Tracked file changes (git diff --shortstat HEAD doesn't include untracked files).    log::debug!("[GIT OPERATION] git.rs get_repo_git_summary git diff --shortstat HEAD");    let stats = run_git_command(repo_root, &["diff", "--shortstat", "HEAD"])        .await        .ok()        .and_then(|o| GitLineChanges::parse_from_git_output(&o));     let mut lines_added = stats.as_ref().map_or(0, |s| s.lines_added);    let lines_removed = stats.as_ref().map_or(0, |s| s.lines_removed);     // Also count lines in untracked files to match what the git diff chip shows.    log::debug!(        "[GIT OPERATION] git.rs get_repo_git_summary git ls-files --others --exclude-standard"    );    if let Ok(untracked_output) =        run_git_command(repo_root, &["ls-files", "--others", "--exclude-standard"]).await    {        for file_name in untracked_output.lines() {            if file_name.is_empty() {                continue;            }            lines_added += count_lines_if_text_file(&repo_root.join(file_name));        }    }     let branch = branch?;    Some(RepoGitSummary {        branch,        lines_added,        lines_removed,    })} /// Short summary of a commit: hash, subject line, and per-file changes.#[derive(Debug, Clone)]pub struct Commit {    pub hash: String,    pub subject: String,    pub files_changed: usize,    pub additions: usize,    pub deletions: usize,    pub files: Vec<FileChangeEntry>,} /// A single changed file with per-file addition/deletion counts.#[derive(Debug, Clone)]pub struct FileChangeEntry {    pub path: String,    pub additions: usize,    pub deletions: usize,} /// Returns per-file change entries. When `include_unstaged` is true, returns all/// uncommitted changes (staged + unstaged + untracked) vs HEAD; otherwise only staged changes.#[cfg(feature = "local_fs")]pub async fn get_file_change_entries(    repo_path: &Path,    include_unstaged: bool,) -> Result<Vec<FileChangeEntry>> {    let args: &[&str] = if include_unstaged {        &["diff", "--numstat", "HEAD"]    } else {        &["diff", "--cached", "--numstat"]    };    let output = run_git_command(repo_path, args).await.unwrap_or_default();    let mut entries = Vec::new();    for line in output.lines() {        if line.is_empty() {            continue;        }        let parts: Vec<&str> = line.split('\t').collect();        if parts.len() >= 3 {            entries.push(FileChangeEntry {                path: parts[2].to_string(),                additions: parts[0].parse().unwrap_or(0),                deletions: parts[1].parse().unwrap_or(0),            });        }    }     // Also include untracked files when showing all changes.    if include_unstaged        && let Ok(untracked) =            run_git_command(repo_path, &["ls-files", "--others", "--exclude-standard"]).await    {        for file_name in untracked.lines() {            if file_name.is_empty() {                continue;            }            let additions = count_lines_if_text_file(&repo_path.join(file_name)) as usize;            entries.push(FileChangeEntry {                path: file_name.to_string(),                additions,                deletions: 0,            });        }    }     Ok(entries)} #[cfg(not(feature = "local_fs"))]pub async fn get_file_change_entries(    _repo_path: &Path,    _include_unstaged: bool,) -> Result<Vec<FileChangeEntry>> {    Err(anyhow!("Not supported on wasm"))} /// Returns per-file change entries for the **committed** branch diff/// (`merge_base(HEAD, main)..HEAD`) — exactly what an opened PR would contain.////// Unlike [`get_file_change_entries`] and the `against_base_branch` metadata/// which diff the working tree against the merge base and append untracked files,/// this only includes committed changes. The base is the detected main branch,/// matching the `--base` that [`create_pr`] targets.////// Returns an empty list when the merge base can't be resolved (e.g. no commits/// yet, or the branch shares no history with main).#[cfg(feature = "local_fs")]pub async fn get_committed_branch_file_entries(repo_path: &Path) -> Result<Vec<FileChangeEntry>> {    let main_branch = detect_main_branch(repo_path).await?;    let merge_base =        match run_git_command(repo_path, &["merge-base", "HEAD", main_branch.trim()]).await {            Ok(output) => output.trim().to_string(),            Err(err) => {                log::warn!("Could not determine merge base against branch {main_branch}: {err:?}");                return Ok(Vec::new());            }        };     // `git diff --numstat <merge_base> HEAD` is the committed-only diff    // (equivalent to `main...HEAD`): no working-tree edits, no untracked files.    let output = run_git_command(repo_path, &["diff", "--numstat", &merge_base, "HEAD"])        .await        .unwrap_or_default();    let mut entries = Vec::new();    for line in output.lines() {        if line.is_empty() {            continue;        }        let parts: Vec<&str> = line.split('\t').collect();        if parts.len() >= 3 {            entries.push(FileChangeEntry {                path: parts[2].to_string(),                // Binary files render as "-\t-\t<path>"; parse failures fall back                // to 0, mirroring `get_file_change_entries`.                additions: parts[0].parse().unwrap_or(0),                deletions: parts[1].parse().unwrap_or(0),            });        }    }     Ok(entries)} #[cfg(not(feature = "local_fs"))]pub async fn get_committed_branch_file_entries(_repo_path: &Path) -> Result<Vec<FileChangeEntry>> {    Err(anyhow!("Not supported on wasm"))} /// Unpushed commits: `<upstream>..HEAD`, or `<fork_point>..HEAD` if no upstream.#[cfg(feature = "local_fs")]pub async fn get_unpushed_commits(    repo_path: &Path,    current_branch_name: Option<&str>,    upstream_ref: Option<&str>,) -> Result<Vec<Commit>> {    let output = if let Some(upstream_ref) = upstream_ref.map(str::trim).filter(|s| !s.is_empty()) {        let range = format!("{upstream_ref}..HEAD");        run_git_command(            repo_path,            &["log", &range, "--format=COMMIT:%H\t%s", "--numstat"],        )        .await?    } else {        // No upstream — fall back to the fork-point commit so we show        // exactly the commits unique to this branch        let fork_point = detect_fork_point(repo_path, current_branch_name)            .await            .ok()            .flatten();         let range = match fork_point {            Some(sha) => format!("{sha}..HEAD"),            None => "HEAD".to_string(),        };         run_git_command(            repo_path,            &["log", &range, "--format=COMMIT:%H\t%s", "--numstat"],        )        .await        .inspect_err(|e| log::warn!("Fallback unpushed-commits log failed: {e}"))        .unwrap_or_default()    };    parse_commit_log(&output)} #[cfg(feature = "local_fs")]fn parse_commit_log(output: &str) -> Result<Vec<Commit>> {    let mut commits = Vec::new();    let mut current: Option<Commit> = None;     for line in output.lines() {        if let Some(rest) = line.strip_prefix("COMMIT:") {            if let Some(commit) = current.take() {                commits.push(commit);            }            let parts: Vec<&str> = rest.splitn(2, '\t').collect();            if parts.len() == 2 {                current = Some(Commit {                    hash: parts[0].to_string(),                    subject: parts[1].to_string(),                    files_changed: 0,                    additions: 0,                    deletions: 0,                    files: Vec::new(),                });            }        } else if !line.is_empty() {            // numstat line: "<additions>\t<deletions>\t<path>"            if let Some(ref mut commit) = current {                let parts: Vec<&str> = line.splitn(3, '\t').collect();                if parts.len() == 3 {                    let additions = parts[0].parse::<usize>().unwrap_or(0);                    let deletions = parts[1].parse::<usize>().unwrap_or(0);                    commit.additions += additions;                    commit.deletions += deletions;                    commit.files_changed += 1;                    commit.files.push(FileChangeEntry {                        path: parts[2].to_string(),                        additions,                        deletions,                    });                }            }        }    }     if let Some(commit) = current {        commits.push(commit);    }     Ok(commits)} #[cfg(not(feature = "local_fs"))]pub async fn get_unpushed_commits(    _repo_path: &Path,    _current_branch_name: Option<&str>,    _upstream_ref: Option<&str>,) -> Result<Vec<Commit>> {    Err(anyhow!("Not supported on wasm"))} /// Computes the branch's unpushed commits together with its upstream/// tracking ref, so callers that need both (metadata refresh, the remote/// git-operation delta returned to the client) don't repeat the work./// Returns `(Vec::new(), None)` on failure rather than erroring, since the/// caller treats "no upstream" and "detection failed" the same way.#[cfg(feature = "local_fs")]pub async fn compute_unpushed_state(repo_path: &Path) -> (Vec<Commit>, Option<String>) {    let current_branch = detect_current_branch(repo_path).await.ok();    let upstream_ref = run_git_command(        repo_path,        &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],    )    .await    .ok()    .map(|s| s.trim().to_string())    .filter(|s| !s.is_empty());    let unpushed = get_unpushed_commits(        repo_path,        current_branch.as_deref(),        upstream_ref.as_deref(),    )    .await    .unwrap_or_default();    (unpushed, upstream_ref)} #[cfg(not(feature = "local_fs"))]pub async fn compute_unpushed_state(_repo_path: &Path) -> (Vec<Commit>, Option<String>) {    (Vec::new(), None)

Lines 1–500 of 1277.