use anyhow::Context;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tracing::warn;
use crate::tools::shell::apply_safe_env;
use crate::util::unquote_c_style;
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub hash: String,
pub lines_added: i64,
pub lines_removed: i64,
}
impl CommitInfo {
#[must_use]
pub fn short_hash(&self) -> &str {
self.hash.get(..7).unwrap_or(&self.hash)
}
}
pub(crate) const MAX_UNTRACKED_SIZE: u64 = 1024 * 1024;
const MAX_UNTRACKED_DIRS_ENUMERATED: usize = 10;
const MAX_UNTRACKED_FILES_COUNTED: usize = 500;
const MAX_UNTRACKED_BYTES_READ: u64 = 10 * 1024 * 1024;
pub(crate) enum UntrackedFileRead {
Text(String),
TooLarge(u64),
Binary,
Skip,
}
fn classify_untracked_content(content: Vec<u8>) -> Option<String> {
if content.contains(&0) {
return None;
}
String::from_utf8(content).ok()
}
pub(crate) async fn read_untracked_file(path: &Path, max_size: u64) -> UntrackedFileRead {
if !path.is_file() {
return UntrackedFileRead::Skip;
}
let Ok(meta) = tokio::fs::metadata(path).await else {
return UntrackedFileRead::Skip;
};
if meta.len() > max_size {
return UntrackedFileRead::TooLarge(meta.len());
}
let Ok(content) = tokio::fs::read(path).await else {
return UntrackedFileRead::Skip;
};
classify_untracked_content(content).map_or(UntrackedFileRead::Binary, UntrackedFileRead::Text)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscardTarget {
File,
Directory,
}
#[must_use]
pub fn is_git_repo(path: &Path) -> bool {
path.join(".git").exists()
}
pub async fn run_git_diff(repo_path: &Path, commit_ref: Option<&str>) -> anyhow::Result<String> {
if let Some(hash) = commit_ref {
run_git_command(
repo_path,
&[
"show",
"-m",
hash,
"--no-color",
"--find-renames",
"--format=",
],
)
.await
} else {
run_git_command(repo_path, &["diff", "HEAD", "--no-color", "--find-renames"]).await
}
}
pub async fn run_git_status(repo_path: &Path) -> anyhow::Result<String> {
run_git_command(repo_path, &["status", "--porcelain"]).await
}
pub async fn run_git_show(
repo_path: &Path,
file_path: &str,
commit_ref: Option<&str>,
) -> Option<String> {
let show_arg = if let Some(hash) = commit_ref {
format!("{hash}:{file_path}")
} else {
format!("HEAD:{file_path}")
};
run_git_command(repo_path, &["show", &show_arg]).await.ok()
}
pub(crate) fn resolve_git_top_level(repo_path: &Path) -> Option<PathBuf> {
let dir = repo_path.ancestors().find(|dir| is_git_repo(dir))?;
Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()))
}
fn inject_safe_directory(cmd: &mut tokio::process::Command, repo_path: &Path) {
let Some(top_level) = resolve_git_top_level(repo_path) else {
return;
};
cmd.env("GIT_CONFIG_COUNT", "1");
cmd.env("GIT_CONFIG_KEY_0", "safe.directory");
cmd.env("GIT_CONFIG_VALUE_0", top_level.as_path());
}
fn git_command(repo_root: Option<&Path>) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("git");
apply_safe_env(&mut cmd);
cmd.env("LC_ALL", "C");
if let Some(path) = repo_root {
inject_safe_directory(&mut cmd, path);
}
cmd
}
pub(crate) async fn run_git_output(
repo_path: &Path,
args: &[&str],
) -> anyhow::Result<std::process::Output> {
let mut cmd = git_command(Some(repo_path));
cmd.args(args).current_dir(repo_path);
cmd.output()
.await
.with_context(|| format!("Failed to run git {}", args.join(" ")))
}
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = run_git_output(repo_path, args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git {} failed: {stderr}", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn run_git_trimmed(repo_path: &Path, args: &[&str]) -> anyhow::Result<String> {
Ok(run_git_command(repo_path, args).await?.trim().to_string())
}
#[must_use]
pub fn has_unstaged_changes(porcelain: &str) -> bool {
porcelain.lines().any(|line| {
let line = line.trim_end();
if line.is_empty() {
return false;
}
line.as_bytes().get(1).is_some_and(|&b| b != b' ')
})
}
pub async fn run_git_add_all(repo_path: &Path) -> anyhow::Result<String> {
run_git_command(repo_path, &["add", "-A"]).await
}
pub async fn run_git_head(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["rev-parse", "HEAD"]).await
}
pub async fn run_git_write_tree(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["write-tree"]).await
}
pub async fn run_git_commit(repo_path: &Path, message: &str) -> anyhow::Result<CommitInfo> {
run_git_add_all(repo_path).await?;
run_git_command(repo_path, &["commit", "-m", message])
.await
.context("Failed to commit changes")?;
let hash = match run_git_head(repo_path).await {
Ok(hash) => hash,
Err(e) => {
warn!(
error = %e,
"git rev-parse HEAD failed after successful commit — commit exists, returning unknown hash"
);
return Ok(CommitInfo {
hash: "unknown".into(),
lines_added: 0,
lines_removed: 0,
});
}
};
let (lines_added, lines_removed) =
if let Ok(stats) = parse_numstat(repo_path, &["HEAD~1..HEAD"]).await {
stats
} else {
parse_numstat(
repo_path,
&["4b825dc642cb6eb9a060e54bf8d69288fbee4904", "HEAD"],
)
.await
.unwrap_or((0, 0))
};
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
}
pub(crate) static GIT_COMMIT_TX: OnceLock<tokio::sync::broadcast::Sender<PathBuf>> =
OnceLock::new();
pub(crate) fn init_git_commit_tx() {
let _ = GIT_COMMIT_TX.set(tokio::sync::broadcast::channel(64).0);
}
pub(crate) fn notify_git_commit(repo_path: &Path) {
if let Some(tx) = GIT_COMMIT_TX.get() {
let _ = tx.send(repo_path.to_path_buf());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumstatEntry {
pub additions: Option<i64>,
pub deletions: Option<i64>,
pub path: String,
}
#[must_use]
pub fn parse_numstat_lines(stdout: &str) -> Vec<NumstatEntry> {
let mut result = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() != 3 {
continue;
}
let additions_str = parts[0];
let deletions_str = parts[1];
let path = parts[2].to_string();
if additions_str == "-" || deletions_str == "-" {
result.push(NumstatEntry {
additions: None,
deletions: None,
path,
});
continue;
}
let additions: i64 = additions_str.parse().unwrap_or(0);
let deletions: i64 = deletions_str.parse().unwrap_or(0);
result.push(NumstatEntry {
additions: Some(additions),
deletions: Some(deletions),
path,
});
}
result
}
async fn run_git_diff_numstat(
repo_path: &Path,
range: &[&str],
) -> anyhow::Result<Vec<NumstatEntry>> {
let mut args = vec!["diff", "--numstat"];
args.extend_from_slice(range);
let stdout = run_git_command(repo_path, &args).await?;
Ok(parse_numstat_lines(&stdout))
}
async fn parse_numstat(repo_path: &Path, range: &[&str]) -> anyhow::Result<(i64, i64)> {
let entries = run_git_diff_numstat(repo_path, range).await?;
let mut lines_added: i64 = 0;
let mut lines_removed: i64 = 0;
for entry in entries {
if let Some(added) = entry.additions {
lines_added += added;
}
if let Some(removed) = entry.deletions {
lines_removed += removed;
}
}
Ok((lines_added, lines_removed))
}
pub async fn git_is_installed() -> bool {
let mut cmd = git_command(None);
cmd.arg("--version");
cmd.output().await.is_ok_and(|o| o.status.success())
}
pub async fn git_has_commits(repo_path: &Path) -> bool {
run_git_head(repo_path).await.is_ok()
}
pub async fn run_git_current_branch(repo_path: &Path) -> anyhow::Result<String> {
run_git_trimmed(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]).await
}
pub async fn run_git_behind_ahead(repo_path: &Path) -> anyhow::Result<(usize, usize)> {
match run_git_command(
repo_path,
&["rev-list", "--count", "--left-right", "HEAD...@{upstream}"],
)
.await
{
Ok(out) => {
let parts: Vec<&str> = out.trim().split('\t').collect();
if parts.len() == 2 {
let ahead = parts[0].parse::<usize>().unwrap_or(0);
let behind = parts[1].parse::<usize>().unwrap_or(0);
Ok((behind, ahead))
} else {
Ok((0, 0))
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("fatal: no upstream") || msg.contains("HEAD does not point to a branch")
{
Ok((0, 0))
} else {
Err(e)
}
}
}
}
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
static FETCH_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
struct FetchInFlightGuard;
impl Drop for FetchInFlightGuard {
fn drop(&mut self) {
FETCH_IN_FLIGHT.store(false, Ordering::Release);
}
}
pub async fn run_git_fetch(repo_path: &Path) -> anyhow::Result<String> {
if FETCH_IN_FLIGHT.swap(true, Ordering::AcqRel) {
return Ok(String::new());
}
let _guard = FetchInFlightGuard;
let mut cmd = git_command(Some(repo_path));
cmd.arg("fetch").arg("--quiet").current_dir(repo_path);
cmd.env("GIT_TERMINAL_PROMPT", "0");
if let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") {
cmd.env("SSH_AUTH_SOCK", sock);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn().context("Failed to spawn git fetch")?;
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let wait = child.wait();
let read_stdout = async {
if let Some(out) = &mut stdout {
let _ = out.read_to_end(&mut stdout_buf).await;
}
};
let read_stderr = async {
if let Some(err) = &mut stderr {
let _ = err.read_to_end(&mut stderr_buf).await;
}
};
let status = if let Ok(res) = tokio::time::timeout(FETCH_TIMEOUT, async {
tokio::join!(wait, read_stdout, read_stderr).0
})
.await
{
res.context("git fetch wait failed")?
} else {
let _ = child.kill().await;
let _ = child.wait().await;
anyhow::bail!("git fetch timed out after {}s", FETCH_TIMEOUT.as_secs());
};
if !status.success() {
anyhow::bail!("git fetch failed: {}", String::from_utf8_lossy(&stderr_buf));
}
Ok(String::from_utf8_lossy(&stdout_buf).to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub added: i64,
pub removed: i64,
pub huge_binary_file_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitFileStatus {
Modified,
Added,
}
#[derive(Default)]
struct UntrackedAccumulator {
added: i64,
huge_binary_file_count: usize,
files_processed: usize,
bytes_read: u64,
}
impl UntrackedAccumulator {
fn can_process(&self) -> bool {
self.files_processed < MAX_UNTRACKED_FILES_COUNTED
&& self.bytes_read < MAX_UNTRACKED_BYTES_READ
}
}
async fn count_untracked_file(repo_path: &Path, path: &str, accum: &mut UntrackedAccumulator) {
if !accum.can_process() {
return;
}
let full_path = repo_path.join(path);
if !full_path.is_file() {
return;
}
let Ok(meta) = tokio::fs::metadata(&full_path).await else {
return;
};
accum.files_processed += 1;
if meta.len() > MAX_UNTRACKED_SIZE {
accum.huge_binary_file_count += 1;
return;
}
if accum.bytes_read + meta.len() > MAX_UNTRACKED_BYTES_READ {
return;
}
let Ok(content) = tokio::fs::read(&full_path).await else {
return;
};
accum.bytes_read += content.len() as u64;
match classify_untracked_content(content) {
Some(text) => {
#[expect(clippy::cast_possible_wrap)]
let line_count = text.lines().count() as i64;
accum.added += line_count;
}
None => accum.huge_binary_file_count += 1,
}
}
async fn enumerate_untracked_dir(repo_path: &Path, dir: &str) -> anyhow::Result<Vec<String>> {
let pathspec = format!(":(literal){dir}");
let out = run_git_command(
repo_path,
&[
"status",
"--porcelain",
"--untracked-files=all",
"--",
&pathspec,
],
)
.await?;
Ok(parse_untracked_from_porcelain(&out))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitWorktreeSnapshot {
pub stats: DiffStats,
pub file_statuses: HashMap<String, GitFileStatus>,
pub unborn_head: bool,
}
pub async fn run_git_worktree_snapshot(repo_path: &Path) -> anyhow::Result<GitWorktreeSnapshot> {
let porcelain = run_git_status(repo_path).await?;
let file_statuses = parse_git_status_porcelain(&porcelain);
let untracked = parse_untracked_from_porcelain(&porcelain);
let (added, removed, unborn_head) = match parse_numstat(repo_path, &["HEAD"]).await {
Ok(stats) => (stats.0, stats.1, false),
Err(_) if !git_has_commits(repo_path).await => (0, 0, true),
Err(e) => return Err(e.context("Failed to compute working-tree diff stats")),
};
let mut accum = UntrackedAccumulator {
added,
..Default::default()
};
let mut dirs_enumerated = 0;
for path in &untracked {
if path.ends_with('/') {
if dirs_enumerated >= MAX_UNTRACKED_DIRS_ENUMERATED {
continue;
}
dirs_enumerated += 1;
match enumerate_untracked_dir(repo_path, path).await {
Ok(files) => {
for file in &files {
count_untracked_file(repo_path, file, &mut accum).await;
}
}
Err(e) => warn!(error = %e, dir = %path, "Failed to enumerate untracked directory"),
}
} else {
count_untracked_file(repo_path, path, &mut accum).await;
}
}
Ok(GitWorktreeSnapshot {
stats: DiffStats {
added: accum.added,
removed,
huge_binary_file_count: accum.huge_binary_file_count,
},
file_statuses,
unborn_head,
})
}
pub async fn run_git_sync(repo_path: &Path) -> anyhow::Result<String> {
let pull_out = run_git_command(repo_path, &["pull", "--ff-only"]).await?;
let push_out = run_git_command(repo_path, &["push"]).await?;
let combined = if pull_out.trim().is_empty() {
push_out
} else if push_out.trim().is_empty() {
pull_out
} else {
format!("{pull_out}\n{push_out}")
};
Ok(combined)
}
pub async fn run_git_discard(
repo_path: &Path,
path: &str,
target: DiscardTarget,
) -> anyhow::Result<()> {
let _ = run_git_command(repo_path, &["checkout", "HEAD", "--", path]).await;
let _ = run_git_command(repo_path, &["reset", "HEAD", "--", path]).await;
let clean_args: &[&str] = match target {
DiscardTarget::Directory => &["clean", "-fd", "--", path],
DiscardTarget::File => &["clean", "-f", "--", path],
};
let _ = run_git_command(repo_path, clean_args).await;
match run_git_command(repo_path, &["status", "--porcelain", "--", path]).await {
Ok(status) if status.trim().is_empty() => Ok(()),
Ok(status) => anyhow::bail!("Changes remain after discard:\n{}", status.trim()),
Err(e) => anyhow::bail!("Discard ran but verification failed: {e}"),
}
}
pub async fn run_git_commit_message(
repo_path: &Path,
commit_hash: Option<&str>,
) -> anyhow::Result<String> {
let mut args = vec!["log", "-1", "--format=%s"];
if let Some(hash) = commit_hash {
args.push(hash);
}
run_git_trimmed(repo_path, &args).await
}
pub(crate) async fn list_new_or_untracked_files(repo_path: &Path) -> anyhow::Result<Vec<String>> {
let porcelain = run_git_status(repo_path).await?;
Ok(parse_new_files_from_porcelain(&porcelain))
}
fn parse_porcelain_paths(porcelain: &str, predicate: impl FnMut(&&str) -> bool) -> Vec<String> {
porcelain
.lines()
.filter(predicate)
.filter_map(|line| {
let path = line.get(3..)?;
if path.is_empty() {
None
} else {
Some(unquote_c_style(path).unwrap_or_else(|| path.to_string()))
}
})
.collect()
}
#[must_use]
fn parse_new_files_from_porcelain(porcelain: &str) -> Vec<String> {
parse_porcelain_paths(porcelain, |line| {
line.starts_with("?? ") || line.starts_with('A')
})
}
#[must_use]
pub(crate) fn parse_untracked_from_porcelain(porcelain: &str) -> Vec<String> {
parse_porcelain_paths(porcelain, |line| line.starts_with("?? "))
}
#[must_use]
pub(crate) fn parse_git_status_porcelain(output: &str) -> HashMap<String, GitFileStatus> {
let mut map: HashMap<String, GitFileStatus> = HashMap::new();
for line in output.lines() {
let trimmed = line.trim_end();
if trimmed.len() < 2 {
continue;
}
let chars: Vec<char> = trimmed.chars().take(2).collect();
if chars.len() < 2 {
continue;
}
let ix = chars[0];
let wt = chars[1];
if ix == 'D' || wt == 'D' {
continue;
}
if ix == 'R' {
let rest = &trimmed[2..];
let rest = rest.trim_start();
let new_path: String = if rest.starts_with('"') {
if let Some((_, tail)) = rest.rsplit_once("\" -> \"") {
format!("\"{tail}")
} else {
continue;
}
} else {
if let Some((_, tail)) = rest.rsplit_once(" -> ") {
tail.to_string()
} else {
continue;
}
};
if let Some(unquoted) = unquote_c_style(&new_path) {
map.insert(unquoted, GitFileStatus::Modified);
}
continue;
}
let path = trimmed[2..].trim_start();
if path.is_empty() {
continue;
}
let status = if ix == 'M' || wt == 'M' {
GitFileStatus::Modified
} else if ix == 'A' || wt == 'A' || (ix == '?' && wt == '?') {
GitFileStatus::Added
} else {
continue;
};
let Some(path) = unquote_c_style(path) else {
continue;
};
let path = path.strip_suffix('/').unwrap_or(&path).to_string();
let entry = map.entry(path).or_insert(status);
if status == GitFileStatus::Modified && *entry == GitFileStatus::Added {
*entry = GitFileStatus::Modified;
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::init_temp_repo;
#[test]
fn short_hash_cases() {
let cases = [
(
"long hash truncated to 7 chars",
"abc1234def5678",
"abc1234",
),
("short hash returned as-is", "abc12", "abc12"),
("exactly 7 chars returned as-is", "abc1234", "abc1234"),
];
for (name, hash, expected) in &cases {
let info = CommitInfo {
hash: hash.to_string(),
lines_added: 0,
lines_removed: 0,
};
assert_eq!(info.short_hash(), *expected, "{name}");
}
}
#[test]
fn resolve_git_top_level_repo_root() {
let (_dir, repo_path) = init_temp_repo();
let expected = std::fs::canonicalize(&repo_path).expect("canonicalize repo");
assert_eq!(resolve_git_top_level(&repo_path), Some(expected));
}
#[test]
fn resolve_git_top_level_from_subdir() {
let (_dir, repo_path) = init_temp_repo();
let subdir = repo_path.join("src");
std::fs::create_dir(&subdir).expect("create subdir");
let expected = std::fs::canonicalize(&repo_path).expect("canonicalize repo");
assert_eq!(resolve_git_top_level(&subdir), Some(expected));
}
#[test]
fn resolve_git_top_level_non_repo_is_none() {
let dir = tempfile::tempdir().expect("create temp dir");
assert_eq!(
resolve_git_top_level(dir.path()),
None,
"non-repo dir must resolve to None"
);
}
#[test]
fn resolve_git_top_level_accepts_git_file() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join(".git"), b"gitdir: /elsewhere").expect("write .git file");
let expected = std::fs::canonicalize(dir.path()).expect("canonicalize dir");
assert_eq!(resolve_git_top_level(dir.path()), Some(expected));
}
fn hermetic_git_command(repo_root: Option<&Path>) -> tokio::process::Command {
let mut cmd = git_command(repo_root);
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null");
cmd.env("GIT_CONFIG_NOSYSTEM", "1");
cmd.env("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1");
cmd
}
#[tokio::test]
async fn test_safe_directory_injection_bypasses_dubious_ownership() {
let (_dir, repo_path) = init_temp_repo();
let mut cmd = hermetic_git_command(Some(&repo_path));
cmd.arg("status").arg("--porcelain").current_dir(&repo_path);
let out = cmd.output().await.expect("run git status");
assert!(
out.status.success(),
"git status must succeed with safe.directory injected: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn test_without_safe_directory_fails_dubious_ownership() {
let (_dir, repo_path) = init_temp_repo();
let mut cmd = hermetic_git_command(None);
cmd.arg("status").arg("--porcelain").current_dir(&repo_path);
let out = cmd.output().await.expect("run git status");
assert!(
!out.status.success(),
"git must refuse dubious ownership without safe.directory injection"
);
}
#[tokio::test]
async fn test_safe_directory_injects_top_level_for_subdir_workspace() {
let (_dir, repo_path) = init_temp_repo();
let subdir = repo_path.join("src");
std::fs::create_dir(&subdir).expect("create subdir");
let mut cmd = hermetic_git_command(Some(&subdir));
cmd.arg("status").arg("--porcelain").current_dir(&subdir);
let out = cmd.output().await.expect("run git status");
assert!(
out.status.success(),
"git from a repo subdir must match the repo top-level safe.directory: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
async fn test_git_has_commits_true() {
let (_dir, repo_path) = init_temp_repo();
let has = git_has_commits(&repo_path).await;
assert!(has, "repo with initial commit should have commits");
}
#[tokio::test]
async fn test_run_git_head_and_write_tree_fingerprint() {
let (_dir, repo_path) = init_temp_repo();
let head1 = run_git_head(&repo_path).await.expect("repo has commits");
let tree1 = run_git_write_tree(&repo_path)
.await
.expect("index writable");
std::fs::write(repo_path.join("test.txt"), b"line1\nline2\n").expect("write file");
run_git_add_all(&repo_path).await.expect("git add");
let head2 = run_git_head(&repo_path).await.expect("repo has commits");
let tree2 = run_git_write_tree(&repo_path)
.await
.expect("index writable");
assert_eq!(head1, head2, "staging must not change HEAD");
assert_ne!(tree1, tree2, "staging must change the index tree");
}
#[tokio::test]
async fn test_run_git_head_none_without_commits() {
let dir = tempfile::tempdir().expect("create temp dir");
let repo_path = dir.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()
.expect("git init");
assert!(status.success());
let head = run_git_head(&repo_path).await.ok();
assert!(head.is_none(), "commit-less repo must not resolve HEAD");
}
#[tokio::test]
async fn test_run_git_current_branch_default() {
let (_dir, repo_path) = init_temp_repo();
let branch = run_git_current_branch(&repo_path).await.expect("branch");
assert!(!branch.is_empty(), "branch name should not be empty");
}
#[tokio::test]
async fn test_run_git_behind_ahead_no_upstream() {
let (_dir, repo_path) = init_temp_repo();
let (behind, ahead) = run_git_behind_ahead(&repo_path)
.await
.expect("behind/ahead");
assert_eq!(behind, 0);
assert_eq!(ahead, 0);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_clean_tree() {
let (_dir, repo_path) = init_temp_repo();
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(snap.stats.added, 0);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert!(snap.file_statuses.is_empty(), "clean tree has no statuses");
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_with_changes() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(
repo_path.join("test.txt"),
b"line1\nline2 modified\nline3\nline4\n",
)
.expect("write modified file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(snap.stats.added, 2, "two lines added (modified + new line)");
assert_eq!(snap.stats.removed, 1, "one line removed (line2)");
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(
snap.file_statuses.get("test.txt"),
Some(&GitFileStatus::Modified)
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_with_untracked() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(
repo_path.join("new_file.rs"),
b"fn foo() {\n bar();\n}\n",
)
.expect("write untracked file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 3,
"should count lines from untracked file"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert_eq!(
snap.file_statuses.get("new_file.rs"),
Some(&GitFileStatus::Added)
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_binary_untracked_as_huge() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join("binary.bin"), b"line1\nline2\x00\n")
.expect("write binary file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 0,
"binary untracked file should not be counted as added"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"binary untracked file should be surfaced as huge/binary"
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_large_untracked_as_huge() {
let (_dir, repo_path) = init_temp_repo();
let size = usize::try_from(MAX_UNTRACKED_SIZE).unwrap() + 1;
let mut content = Vec::with_capacity(size);
content.resize(size, b'a');
std::fs::write(repo_path.join("large.bin"), &content).expect("write large file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 0,
"large untracked file should not be counted as added"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"large untracked file should be surfaced as huge/binary"
);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_counts_untracked_directory_files() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir_all(repo_path.join("new_dir")).expect("create directory");
std::fs::write(
repo_path.join("new_dir/a.rs"),
b"fn foo() {\n bar();\n}\n",
)
.expect("write normal file");
std::fs::write(repo_path.join("new_dir/binary.bin"), b"line1\x00\n")
.expect("write binary file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 3,
"should count lines from directory files"
);
assert_eq!(snap.stats.removed, 0, "no removed lines");
assert_eq!(
snap.stats.huge_binary_file_count, 1,
"binary file inside the directory surfaces as huge/binary"
);
}
#[expect(clippy::cast_possible_wrap)] #[tokio::test]
async fn test_run_git_worktree_snapshot_bounded_file_count_cap() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir_all(repo_path.join("big_dir")).expect("create directory");
for i in 0..(MAX_UNTRACKED_FILES_COUNTED + 50) {
std::fs::write(
repo_path.join("big_dir").join(format!("f{i}")),
format!("x{i}\n"),
)
.expect("write counted file");
}
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, MAX_UNTRACKED_FILES_COUNTED as i64,
"only the first cap files are line-counted"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_gitignore_respected_in_directory() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join(".gitignore"), b"ignored.log\n").expect("write gitignore");
run_git_command(&repo_path, &["add", ".gitignore"])
.await
.expect("git add .gitignore");
run_git_command(&repo_path, &["commit", "-m", "add .gitignore"])
.await
.expect("git commit .gitignore");
std::fs::create_dir_all(repo_path.join("new_dir")).expect("create directory");
std::fs::write(repo_path.join("new_dir/keep.rs"), b"aa\nbb\n").expect("write kept file");
std::fs::write(repo_path.join("new_dir/ignored.log"), b"ignored\n")
.expect("write ignored file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 2,
"gitignored file inside a directory is not counted"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_glob_chars_in_directory_path() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir_all(repo_path.join("we*ird?dir")).expect("create directory");
std::fs::write(repo_path.join("we*ird?dir/x.rs"), b"xx\n").expect("write glob-dir file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 1,
"glob chars in the directory path are literal"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
}
#[tokio::test]
async fn test_run_git_worktree_snapshot_unborn_head() {
let dir = tempfile::tempdir().expect("create temp dir");
let repo_path = dir.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()
.expect("git init");
assert!(status.success());
std::fs::write(repo_path.join("first.rs"), b"fn main() {}\n")
.expect("write untracked file");
let snap = run_git_worktree_snapshot(&repo_path)
.await
.expect("worktree snapshot");
assert_eq!(
snap.stats.added, 1,
"untracked line counted despite unborn HEAD"
);
assert_eq!(snap.stats.removed, 0);
assert_eq!(snap.stats.huge_binary_file_count, 0);
assert!(snap.unborn_head, "unborn HEAD must be flagged");
assert_eq!(
snap.file_statuses.get("first.rs"),
Some(&GitFileStatus::Added),
"untracked file status flows on an unborn repo"
);
}
#[tokio::test]
async fn test_run_git_list_branches_single() {
let (_dir, repo_path) = init_temp_repo();
let out = run_git_command(&repo_path, &["branch", "--format=%(refname:short)"])
.await
.expect("list branches");
let branches: Vec<String> = out.lines().map(ToString::to_string).collect();
assert_eq!(branches.len(), 1, "single branch in new repo");
}
#[tokio::test]
async fn test_run_git_switch_and_create_branch() {
let (_dir, repo_path) = init_temp_repo();
let default_branch = run_git_current_branch(&repo_path)
.await
.expect("current branch");
run_git_command(&repo_path, &["switch", "-c", "feature/test"])
.await
.expect("create branch");
let current = run_git_current_branch(&repo_path)
.await
.expect("current branch");
assert_eq!(current, "feature/test");
let out = run_git_command(&repo_path, &["branch", "--format=%(refname:short)"])
.await
.expect("list branches");
let branches: Vec<String> = out.lines().map(ToString::to_string).collect();
assert!(branches.contains(&"feature/test".to_string()));
run_git_command(&repo_path, &["switch", default_branch.as_str()])
.await
.expect("switch back");
let switched = run_git_current_branch(&repo_path)
.await
.expect("current branch");
assert_eq!(switched, default_branch, "should be back on default branch");
}
#[tokio::test]
async fn test_run_git_commit_message() {
let (_dir, repo_path) = init_temp_repo();
let msg = run_git_commit_message(&repo_path, None)
.await
.expect("commit message without hash");
assert_eq!(msg, "Initial commit");
std::fs::write(repo_path.join("test.txt"), b"line1\nline2\n").expect("write test file");
let status = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&repo_path)
.status()
.expect("git add");
assert!(status.success());
let status = std::process::Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(&repo_path)
.status()
.expect("git commit");
assert!(status.success());
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.expect("git rev-parse");
let second_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = run_git_commit_message(&repo_path, Some(&second_hash))
.await
.expect("commit message with hash");
assert_eq!(msg, "Second commit");
let msg = run_git_commit_message(&repo_path, None)
.await
.expect("commit message without hash");
assert_eq!(msg, "Second commit");
}
#[tokio::test]
async fn test_run_git_sync_no_remote() {
let (_dir, repo_path) = init_temp_repo();
let result = run_git_sync(&repo_path).await;
assert!(result.is_err(), "sync without remote should fail");
let err = result.unwrap_err();
assert!(
err.to_string().contains("remote")
|| err.to_string().contains("push")
|| err.to_string().contains("pull"),
"error should mention remote/push/pull: {err}"
);
}
const PORCELAIN_INPUT: &str = "\
?? new_file.rs
M modified.rs
?? another_new.py
A staged_new.js
?? dir/untracked.txt
M working_tree_only.txt
?? temp.log
AM staged_then_modified.js
A working_tree_new.txt
?? \"file\\tname.rs\"
A \"staged\\\"file.js\"
?? \"file\\\\backslash.rs\"
";
#[test]
fn parse_new_files_from_porcelain_extracts_new_files() {
let porcelain = PORCELAIN_INPUT;
let files = parse_new_files_from_porcelain(porcelain);
assert_eq!(files.len(), 9);
assert!(files.contains(&"new_file.rs".to_string()));
assert!(files.contains(&"another_new.py".to_string()));
assert!(files.contains(&"staged_new.js".to_string()));
assert!(files.contains(&"dir/untracked.txt".to_string()));
assert!(files.contains(&"temp.log".to_string()));
assert!(files.contains(&"staged_then_modified.js".to_string()));
assert!(files.contains(&"file\tname.rs".to_string()));
assert!(files.contains(&"staged\"file.js".to_string()));
assert!(files.contains(&"file\\backslash.rs".to_string()));
assert!(!files.contains(&"modified.rs".to_string()));
assert!(!files.contains(&"working_tree_only.txt".to_string()));
assert!(!files.contains(&"working_tree_new.txt".to_string()));
}
#[test]
fn parse_new_files_from_porcelain_returns_empty() {
let porcelain = "\
M modified.rs
M working_tree_only.txt
D deleted.rs
A working_tree_new.txt
";
let files = parse_new_files_from_porcelain(porcelain);
assert!(
files.is_empty(),
"Should be empty when no new/untracked files"
);
let short_lines = ["A", "A ", "?? ", "??"];
for &bad_line in &short_lines {
let files = parse_new_files_from_porcelain(bad_line);
assert!(
files.is_empty(),
"Malformed line {bad_line:?} should produce empty result, got {files:?}"
);
}
for &bad_line in &short_lines {
let files = parse_untracked_from_porcelain(bad_line);
assert!(
files.is_empty(),
"Malformed line {bad_line:?} should produce empty result from ??-only parser, got {files:?}"
);
}
}
#[test]
fn parse_untracked_from_porcelain_returns_only_untracked() {
let porcelain = PORCELAIN_INPUT;
let files = parse_untracked_from_porcelain(porcelain);
assert_eq!(files.len(), 6);
assert!(files.contains(&"new_file.rs".to_string()));
assert!(files.contains(&"another_new.py".to_string()));
assert!(files.contains(&"dir/untracked.txt".to_string()));
assert!(files.contains(&"temp.log".to_string()));
assert!(files.contains(&"file\tname.rs".to_string()));
assert!(files.contains(&"file\\backslash.rs".to_string()));
assert!(!files.contains(&"staged_new.js".to_string()));
assert!(!files.contains(&"staged_then_modified.js".to_string()));
assert!(!files.contains(&"modified.rs".to_string()));
assert!(!files.contains(&"working_tree_only.txt".to_string()));
assert!(!files.contains(&"working_tree_new.txt".to_string()));
assert!(!files.contains(&"staged\"file.js".to_string()));
}
#[test]
fn parse_untracked_from_porcelain_no_untracked() {
let porcelain = "\
M modified.rs
A staged_new.js
M working_tree_only.txt
AM staged_then_modified.js
A working_tree_new.txt
";
let files = parse_untracked_from_porcelain(porcelain);
assert!(
files.is_empty(),
"Should be empty when no `?? ` entries present"
);
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_parse_git_status_porcelain() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static [(&'static str, Option<GitFileStatus>)],
}
let cases: &[Case] = &[
Case {
name: "unstaged modified file",
input: " M src/main.rs\n",
expected: &[("src/main.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added file",
input: "A new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "untracked file",
input: "?? new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "staged and unstaged modified (MM)",
input: "MM both.rs\n",
expected: &[("both.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added + unstaged modified (AM)",
input: "AM partial.rs\n",
expected: &[("partial.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename (old -> new)",
input: "R old.rs -> new.rs\n",
expected: &[("new.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename with arrow in old path",
input: "R \"old -> name.rs\" -> \"new -> name.rs\"\n",
expected: &[("new -> name.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "untracked directory (trailing slash stripped)",
input: "?? new_dir/\n",
expected: &[("new_dir", Some(GitFileStatus::Added))],
},
Case {
name: "quoted path with spaces",
input: " M \"path with spaces.rs\"\n",
expected: &[("path with spaces.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "deleted file (unstaged) skipped",
input: " D gone.rs\n",
expected: &[],
},
Case {
name: "deleted file (staged) skipped",
input: "D gone.rs\n",
expected: &[],
},
Case {
name: "clean (unrecognized status) not present",
input: " clean.rs\n",
expected: &[("clean.rs", None)],
},
Case {
name: "multiple entries same file — modified wins over added",
input: "A dup.rs\n M dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "multiple entries same file — added sticks",
input: "?? dup.rs\nA dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Added))],
},
Case {
name: "empty output",
input: "",
expected: &[],
},
Case {
name: "mixed statuses",
input: concat!(
" M src/main.rs\n",
"?? new_file.rs\n",
"A staged.rs\n",
" D deleted.rs\n",
),
expected: &[
("src/main.rs", Some(GitFileStatus::Modified)),
("new_file.rs", Some(GitFileStatus::Added)),
("staged.rs", Some(GitFileStatus::Added)),
("deleted.rs", None),
],
},
];
for case in cases {
let map = parse_git_status_porcelain(case.input);
if case.expected.is_empty() {
assert!(
map.is_empty(),
"case '{}' (input={:?}): expected empty map, got {:#?}",
case.name,
case.input,
map
);
} else {
let expected_count = case.expected.iter().filter(|(_, s)| s.is_some()).count();
assert_eq!(
map.len(),
expected_count,
"case '{}' (input={:?}): map has unexpected entries",
case.name,
case.input,
);
for &(path, expected_status) in case.expected {
match expected_status {
Some(status) => {
assert_eq!(
map.get(path),
Some(&status),
"case '{}' (input={:?}): path={:?}",
case.name,
case.input,
path
);
}
None => {
assert!(
!map.contains_key(path),
"case '{}' (input={:?}): path={:?} should be absent, got {:?}",
case.name,
case.input,
path,
map.get(path)
);
}
}
}
}
}
}
#[test]
fn parse_numstat_lines_cases() {
let cases: &[(&str, &str, Vec<NumstatEntry>)] = &[
(
"normal",
"10\t3\tsrc/main.rs\n0\t1\tsrc/lib.rs\n42\t7\tCargo.toml\n",
vec![
NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string(),
},
NumstatEntry {
additions: Some(0),
deletions: Some(1),
path: "src/lib.rs".to_string(),
},
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "Cargo.toml".to_string(),
},
],
),
(
"binary",
"-\t-\timage.png\n42\t7\tsrc/main.rs\n",
vec![
NumstatEntry {
additions: None,
deletions: None,
path: "image.png".to_string(),
},
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "src/main.rs".to_string(),
},
],
),
(
"skips_malformed",
"\n\n10\t3\tsrc/main.rs\n\t\t\nnot-enough-fields\n",
vec![NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string(),
}],
),
("empty_string", "", vec![]),
("empty_lines", "\n\n\n", vec![]),
];
for (name, input, expected) in cases {
assert_eq!(parse_numstat_lines(input), *expected, "case: {name}");
}
}
#[tokio::test]
async fn test_run_git_discard_modified_file() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join("test.txt"), b"modified content\n")
.expect("write modified file");
let status = run_git_command(&repo_path, &["status", "--porcelain", "test.txt"])
.await
.expect("status before discard");
assert!(
!status.trim().is_empty(),
"file should be dirty before discard"
);
run_git_discard(&repo_path, "test.txt", DiscardTarget::File)
.await
.expect("run_git_discard should succeed");
let status = run_git_command(&repo_path, &["status", "--porcelain", "test.txt"])
.await
.expect("status after discard");
assert!(
status.trim().is_empty(),
"file should be clean after discard"
);
let content = std::fs::read_to_string(repo_path.join("test.txt")).expect("read file");
assert_eq!(
content, "line1\nline2\nline3\n",
"content should be restored to HEAD"
);
}
#[tokio::test]
async fn test_run_git_discard_new_file() {
let (_dir, repo_path) = init_temp_repo();
let new_path = repo_path.join("new_file.rs");
std::fs::write(&new_path, b"fn new() {}").expect("write new file");
assert!(new_path.exists(), "new file should exist before discard");
run_git_discard(&repo_path, "new_file.rs", DiscardTarget::File)
.await
.expect("run_git_discard should succeed");
assert!(
!new_path.exists(),
"new file should be removed after discard"
);
}
#[tokio::test]
async fn test_run_git_discard_directory() {
let (_dir, repo_path) = init_temp_repo();
let sub_dir = repo_path.join("subdir");
std::fs::create_dir(&sub_dir).expect("create subdir");
let sub_file = sub_dir.join("nested.rs");
std::fs::write(&sub_file, b"fn nested() {}").expect("write nested file");
assert!(sub_file.exists(), "nested file should exist before discard");
run_git_discard(&repo_path, "subdir", DiscardTarget::Directory)
.await
.expect("run_git_discard should succeed");
assert!(
!sub_dir.exists(),
"directory should be removed after discard"
);
}
#[tokio::test]
async fn test_run_git_discard_clean_file() {
let (_dir, repo_path) = init_temp_repo();
let result = run_git_discard(&repo_path, "test.txt", DiscardTarget::File).await;
assert!(result.is_ok(), "discarding a clean file should succeed");
}
#[test]
fn has_unstaged_changes_cases() {
let cases: &[(&str, &str, bool)] = &[
("empty_string", "", false),
("empty_lines", "\n\n", false),
("fully_staged_modified", "M Cargo.toml\n", false),
("fully_staged_added", "A src/lib.rs\n", false),
(
"fully_staged_mixed",
"M Cargo.toml\nA src/lib.rs\nD old.rs\n",
false,
),
("unstaged_modified", " M src/lib.rs\n", true),
("unstaged_deleted", " D src/old.rs\n", true),
("dual_status_modified", "MM src/lib.rs\n", true),
("dual_status_added", "AM src/new.rs\n", true),
("dual_status_deleted", "MD src/old.rs\n", true),
("untracked_file", "?? new_file.rs\n", true),
("untracked_directory", "?? dir/untracked.txt\n", true),
(
"mixed_only_staged",
"M Cargo.toml\nA src/main.rs\n",
false,
),
(
"mixed_staged_and_unstaged",
"M Cargo.toml\n M src/main.rs\n?? new.rs\n",
true,
),
("trailing_newline_unstaged", " M file.rs\n", true),
("trailing_newline_staged", "M file.rs\n", false),
];
for (name, input, expected) in cases {
assert_eq!(has_unstaged_changes(input), *expected, "case: {name}");
}
}
}