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. Load more
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)