use anyhow::Result;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::process::Command;
use crate::cmd::Cmd;
use super::GitStatus;
use super::branch::{get_branch_base_in, get_default_branch_in};
fn bg_git<'a>() -> Cmd<'a> {
Cmd::new("git").arg("--no-optional-locks")
}
pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
let output = bg_git()
.workdir(worktree_path)
.args(&["status", "--porcelain"])
.run_and_capture_stdout()?;
Ok(!output.is_empty())
}
pub fn has_tracked_changes(worktree_path: &Path) -> Result<bool> {
let output = bg_git()
.workdir(worktree_path)
.args(&["status", "--porcelain"])
.run_and_capture_stdout()?;
for line in output.lines() {
if !line.starts_with("??") && !line.is_empty() {
return Ok(true);
}
}
Ok(false)
}
pub fn has_untracked_files(worktree_path: &Path) -> Result<bool> {
let output = bg_git()
.workdir(worktree_path)
.args(&["status", "--porcelain"])
.run_and_capture_stdout()?;
for line in output.lines() {
if line.starts_with("??") {
return Ok(true);
}
}
Ok(false)
}
pub fn has_staged_changes(worktree_path: &Path) -> Result<bool> {
let no_changes = bg_git()
.workdir(worktree_path)
.args(&["diff", "--cached", "--quiet"])
.run_as_check()?;
Ok(!no_changes)
}
pub fn has_unstaged_changes(worktree_path: &Path) -> Result<bool> {
let no_changes = bg_git()
.workdir(worktree_path)
.args(&["diff", "--quiet"])
.run_as_check()?;
Ok(!no_changes)
}
fn parse_porcelain_v2_status(output: &str) -> (Option<String>, usize, usize, bool, bool) {
let mut branch_name: Option<String> = None;
let mut ahead: usize = 0;
let mut behind: usize = 0;
let mut is_dirty = false;
let mut has_upstream = false;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("# branch.head ") {
if rest != "(detached)" {
branch_name = Some(rest.to_string());
}
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
has_upstream = true;
let mut parts = rest.split_whitespace();
if let (Some(part_a), Some(part_b)) = (parts.next(), parts.next()) {
if let Some(a) = part_a.strip_prefix('+') {
ahead = a.parse().unwrap_or(0);
}
if let Some(b) = part_b.strip_prefix('-') {
behind = b.parse().unwrap_or(0);
}
}
} else if !line.starts_with('#') && !line.is_empty() {
is_dirty = true;
break;
}
}
(branch_name, ahead, behind, is_dirty, has_upstream)
}
fn count_lines(path: &Path) -> std::io::Result<usize> {
use std::fs::File;
let mut file = File::open(path)?;
let mut buffer = [0; 8192];
let n = file.read(&mut buffer)?;
if buffer[..n].contains(&0) {
return Ok(0);
}
file.seek(SeekFrom::Start(0))?;
let mut count = 0;
let mut buf = [0; 32 * 1024];
let mut last_byte = None;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
count += buf[..n].iter().filter(|&&b| b == b'\n').count();
last_byte = Some(buf[n - 1]);
}
if let Some(b) = last_byte
&& b != b'\n'
{
count += 1;
}
Ok(count)
}
struct DiffStats {
committed_added: usize,
committed_removed: usize,
uncommitted_added: usize,
uncommitted_removed: usize,
}
fn get_diff_stats(worktree_path: &Path, base_ref: &str) -> DiffStats {
let mut committed_added = 0;
let mut committed_removed = 0;
let mut uncommitted_added = 0;
let mut uncommitted_removed = 0;
let parse_numstat = |output: &str| -> (usize, usize) {
let mut a = 0;
let mut r = 0;
for line in output.lines() {
let mut parts = line.split_whitespace();
if let (Some(added), Some(removed)) = (parts.next(), parts.next()) {
a += added.parse::<usize>().unwrap_or(0);
r += removed.parse::<usize>().unwrap_or(0);
}
}
(a, r)
};
if let Ok(output) = bg_git()
.workdir(worktree_path)
.args(&["diff", "--numstat", &format!("{}...HEAD", base_ref)])
.run_and_capture_stdout()
{
let (a, r) = parse_numstat(&output);
committed_added += a;
committed_removed += r;
}
if let Ok(output) = bg_git()
.workdir(worktree_path)
.args(&["diff", "--numstat", "HEAD"])
.run_and_capture_stdout()
{
let (a, r) = parse_numstat(&output);
uncommitted_added += a;
uncommitted_removed += r;
}
if let Ok(output) = bg_git()
.workdir(worktree_path)
.args(&["ls-files", "--others", "--exclude-standard", "-z"])
.run_and_capture_stdout()
{
for file_path in output.split('\0') {
if file_path.is_empty() {
continue;
}
let full_path = worktree_path.join(file_path);
if let Ok(metadata) = std::fs::symlink_metadata(&full_path)
&& metadata.file_type().is_symlink()
{
uncommitted_added += 1;
continue;
}
if let Ok(lines) = count_lines(&full_path) {
uncommitted_added += lines;
}
}
}
DiffStats {
committed_added,
committed_removed,
uncommitted_added,
uncommitted_removed,
}
}
fn is_rebasing(worktree_path: &Path) -> bool {
let dot_git = worktree_path.join(".git");
let git_dir = if dot_git.is_dir() {
dot_git
} else if dot_git.is_file() {
let content = std::fs::read_to_string(&dot_git).unwrap_or_default();
match content.strip_prefix("gitdir: ") {
Some(gitdir) => {
let path = std::path::PathBuf::from(gitdir.trim());
if path.is_absolute() {
path
} else {
worktree_path.join(path)
}
}
None => return false,
}
} else {
return false;
};
git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir()
}
pub fn get_git_status(worktree_path: &Path, main_branch: Option<&str>) -> GitStatus {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.ok();
let is_rebasing = is_rebasing(worktree_path);
let (branch, ahead, behind, is_dirty, has_upstream) = match bg_git()
.workdir(worktree_path)
.args(&["status", "--porcelain=v2", "--branch"])
.run_and_capture_stdout()
{
Ok(output) => parse_porcelain_v2_status(&output),
Err(_) => {
return GitStatus {
cached_at: now,
branch: None,
is_rebasing,
..Default::default()
};
}
};
let branch = match branch {
Some(b) => b,
None => {
return GitStatus {
is_dirty,
cached_at: now,
branch: None,
has_upstream,
is_rebasing,
..Default::default()
};
}
};
let base_branch = get_branch_base_in(&branch, Some(worktree_path))
.ok()
.or_else(|| main_branch.filter(|s| !s.is_empty()).map(|s| s.to_string()))
.or_else(|| get_default_branch_in(Some(worktree_path)).ok())
.unwrap_or_else(|| "main".to_string());
if branch == base_branch {
let stats = get_diff_stats(worktree_path, &branch);
return GitStatus {
ahead,
behind,
is_dirty,
uncommitted_added: stats.uncommitted_added,
uncommitted_removed: stats.uncommitted_removed,
cached_at: now,
base_branch,
branch: Some(branch),
has_upstream,
is_rebasing,
..Default::default()
};
}
let base_ref = base_branch.clone();
let has_conflict = {
let status = Command::new("git")
.current_dir(worktree_path)
.args([
"--no-optional-locks",
"merge-tree",
"--write-tree",
&base_ref,
"HEAD",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
matches!(status, Ok(s) if s.code() == Some(1))
};
let diff_stats = get_diff_stats(worktree_path, &base_ref);
GitStatus {
ahead,
behind,
has_conflict,
is_dirty,
lines_added: diff_stats.committed_added,
lines_removed: diff_stats.committed_removed,
uncommitted_added: diff_stats.uncommitted_added,
uncommitted_removed: diff_stats.uncommitted_removed,
cached_at: now,
base_branch,
branch: Some(branch),
has_upstream,
is_rebasing,
}
}
#[cfg(test)]
mod tests {
use super::parse_porcelain_v2_status;
#[test]
fn test_parse_porcelain_v2_clean_repo() {
let output = "# branch.oid abc123def456\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n";
let (branch, ahead, behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("main".to_string()));
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
assert!(!is_dirty);
assert!(has_upstream);
}
#[test]
fn test_parse_porcelain_v2_dirty_repo() {
let output = "# branch.oid abc123\n# branch.head feature\n# branch.upstream origin/feature\n# branch.ab +1 -2\n1 .M N... 100644 100644 100644 abc123 def456 src/file.rs\n? untracked.txt\n";
let (branch, ahead, behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("feature".to_string()));
assert_eq!(ahead, 1);
assert_eq!(behind, 2);
assert!(is_dirty);
assert!(has_upstream);
}
#[test]
fn test_parse_porcelain_v2_no_upstream() {
let output = "# branch.oid abc123\n# branch.head new-branch\n";
let (branch, ahead, behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("new-branch".to_string()));
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
assert!(!is_dirty);
assert!(!has_upstream);
}
#[test]
fn test_parse_porcelain_v2_detached_head() {
let output = "# branch.oid abc123\n# branch.head (detached)\n";
let (branch, ahead, behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, None);
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
assert!(!is_dirty);
assert!(!has_upstream);
}
#[test]
fn test_parse_porcelain_v2_untracked_only() {
let output = "# branch.oid abc123\n# branch.head main\n? untracked.txt\n";
let (branch, _ahead, _behind, is_dirty, _has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("main".to_string()));
assert!(is_dirty);
}
#[test]
fn test_parse_porcelain_v2_renamed_file() {
let output = "# branch.oid abc123\n# branch.head main\n2 R. N... 100644 100644 100644 abc123 def456 R100 old.rs\tnew.rs\n";
let (branch, _ahead, _behind, is_dirty, _has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("main".to_string()));
assert!(is_dirty);
}
#[test]
fn test_parse_porcelain_v2_initial_commit() {
let output = "# branch.oid (initial)\n# branch.head master\n";
let (branch, ahead, behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("master".to_string()));
assert_eq!(ahead, 0);
assert_eq!(behind, 0);
assert!(!is_dirty);
assert!(!has_upstream);
}
#[test]
fn test_parse_porcelain_v2_unmerged_conflict() {
let output = "# branch.oid abc123\n# branch.head feature\n# branch.upstream origin/feature\n# branch.ab +0 -0\nu UU N... 100644 100644 100644 100644 abc def ghi jkl src/conflict.rs\n";
let (branch, _ahead, _behind, is_dirty, has_upstream) = parse_porcelain_v2_status(output);
assert_eq!(branch, Some("feature".to_string()));
assert!(is_dirty);
assert!(has_upstream);
}
}