use anyhow::Context;
use std::collections::HashSet;
use std::path::Path;
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;
pub(crate) enum UntrackedFileRead {
Text(String),
TooLarge(u64),
Binary,
Skip,
}
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;
};
if content.contains(&0) {
return UntrackedFileRead::Binary;
}
String::from_utf8(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()
}
fn git_command() -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("git");
apply_safe_env(&mut cmd);
cmd.env("LC_ALL", "C");
cmd
}
pub(crate) async fn run_git_output(
repo_path: &Path,
args: &[&str],
) -> anyhow::Result<std::process::Output> {
let mut cmd = git_command();
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_with_stdin(
repo_path: &Path,
args: &[&str],
stdin_lines: &[String],
name: &str,
) -> anyhow::Result<std::process::Output> {
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
let mut cmd = git_command();
cmd.args(args)
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to spawn git {name}"))?;
let mut stdin = child
.stdin
.take()
.with_context(|| format!("Failed to capture stdin for git {name}"))?;
if !stdin_lines.is_empty() {
let input = stdin_lines.join("\n");
stdin
.write_all(input.as_bytes())
.await
.with_context(|| format!("Failed to write to git {name} stdin"))?;
}
drop(stdin);
let output = child
.wait_with_output()
.await
.with_context(|| format!("Failed to wait for git {name}"))?;
Ok(output)
}
pub async fn run_git_check_ignore(
repo_path: &Path,
paths: &[String],
) -> anyhow::Result<HashSet<String>> {
let output = run_git_with_stdin(
repo_path,
&["check-ignore", "--stdin"],
paths,
"check-ignore",
)
.await?;
if output.status.code() == Some(1) {
return Ok(HashSet::new());
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Git check-ignore failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let ignored: HashSet<String> = stdout.lines().map(ToString::to_string).collect();
Ok(ignored)
}
#[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> {
Ok(run_git_command(repo_path, &["rev-parse", "HEAD"])
.await?
.trim()
.to_string())
}
pub async fn run_git_write_tree(repo_path: &Path) -> anyhow::Result<String> {
Ok(run_git_command(repo_path, &["write-tree"])
.await?
.trim()
.to_string())
}
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,
})
}
#[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
}
pub(crate) 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();
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_command(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])
.await
.map(|s| s.trim().to_string())
}
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)
}
}
}
}
pub async fn run_git_diff_stats(repo_path: &Path) -> anyhow::Result<(i64, i64)> {
let (mut added, removed) = parse_numstat(repo_path, &["HEAD"]).await?;
let status_output = match run_git_status(repo_path).await {
Ok(output) => output,
Err(e) => {
warn!("Failed to run git status for untracked file counting: {e}");
return Ok((added, removed));
}
};
let untracked = parse_untracked_from_porcelain(&status_output);
for path in &untracked {
if let UntrackedFileRead::Text(text) =
read_untracked_file(&repo_path.join(path), MAX_UNTRACKED_SIZE).await
{
#[expect(clippy::cast_possible_wrap)]
let line_count = text.lines().count() as i64;
added += line_count;
}
}
Ok((added, removed))
}
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);
}
let out = run_git_command(repo_path, &args).await?;
Ok(out.trim().to_string())
}
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]
pub(crate) 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("?? "))
}
#[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}");
}
}
#[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_git_has_commits_false() {
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 has = git_has_commits(&repo_path).await;
assert!(!has, "empty repo should not 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_diff_stats_clean_tree() {
let (_dir, repo_path) = init_temp_repo();
let (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(added, 0);
assert_eq!(removed, 0);
}
#[tokio::test]
async fn test_run_git_diff_stats_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 (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(added, 2, "two lines added (modified + new line)");
assert_eq!(removed, 1, "one line removed (line2)");
}
#[tokio::test]
async fn test_run_git_diff_stats_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 (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(added, 3, "should count lines from untracked file");
assert_eq!(removed, 0, "no removed lines");
}
#[tokio::test]
async fn test_run_git_diff_stats_skips_binary_untracked() {
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 (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(
added, 0,
"binary untracked file should not be counted as added"
);
assert_eq!(removed, 0, "no removed lines");
}
#[tokio::test]
async fn test_run_git_diff_stats_skips_large_untracked() {
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 (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(
added, 0,
"large untracked file should not be counted as added"
);
assert_eq!(removed, 0, "no removed lines");
}
#[tokio::test]
async fn test_run_git_diff_stats_skips_directory_untracked() {
let (_dir, repo_path) = init_temp_repo();
std::fs::create_dir(repo_path.join("new_dir")).expect("create directory");
let (added, removed) = run_git_diff_stats(&repo_path).await.expect("diff stats");
assert_eq!(
added, 0,
"untracked directory should not be counted as added"
);
assert_eq!(removed, 0, "no removed lines");
}
#[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"
);
}
#[test]
fn parse_numstat_lines_normal() {
let output = "10\t3\tsrc/main.rs\n0\t1\tsrc/lib.rs\n42\t7\tCargo.toml\n";
let entries = parse_numstat_lines(output);
assert_eq!(entries.len(), 3);
assert_eq!(
entries[0],
NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string()
}
);
assert_eq!(
entries[1],
NumstatEntry {
additions: Some(0),
deletions: Some(1),
path: "src/lib.rs".to_string()
}
);
assert_eq!(
entries[2],
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "Cargo.toml".to_string()
}
);
}
#[test]
fn parse_numstat_lines_binary() {
let output = "-\t-\timage.png\n42\t7\tsrc/main.rs\n";
let entries = parse_numstat_lines(output);
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0],
NumstatEntry {
additions: None,
deletions: None,
path: "image.png".to_string()
}
);
assert_eq!(
entries[1],
NumstatEntry {
additions: Some(42),
deletions: Some(7),
path: "src/main.rs".to_string()
}
);
}
#[test]
fn parse_numstat_lines_skips_malformed() {
let output = "\n\n10\t3\tsrc/main.rs\n\t\t\nnot-enough-fields\n";
let entries = parse_numstat_lines(output);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
NumstatEntry {
additions: Some(10),
deletions: Some(3),
path: "src/main.rs".to_string()
}
);
}
#[test]
fn parse_numstat_lines_empty() {
assert!(parse_numstat_lines("").is_empty());
assert!(parse_numstat_lines("\n\n\n").is_empty());
}
#[tokio::test]
async fn test_run_git_with_stdin_pipes_stdin() {
let (_dir, repo_path) = init_temp_repo();
let output = run_git_with_stdin(
&repo_path,
&["hash-object", "--stdin"],
&["hello world".to_string()],
"hash-object",
)
.await
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.trim().is_empty(), "Expected a non-empty hash");
}
#[tokio::test]
async fn test_run_git_with_stdin_empty_lines() {
let (_dir, repo_path) = init_temp_repo();
let output = run_git_with_stdin(
&repo_path,
&["hash-object", "--stdin"],
&[] as &[String],
"hash-object",
)
.await
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.trim().is_empty(),
"Expected a non-empty hash for empty input"
);
}
#[tokio::test]
async fn test_run_git_check_ignore_matches_ignored_path() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join(".gitignore"), "*.log\n").unwrap();
let ignored = run_git_check_ignore(&repo_path, &["test.log".to_string()])
.await
.unwrap();
assert!(
ignored.contains("test.log"),
"test.log should be ignored by *.log pattern"
);
}
#[tokio::test]
async fn test_run_git_check_ignore_non_ignored_path() {
let (_dir, repo_path) = init_temp_repo();
std::fs::write(repo_path.join(".gitignore"), "*.log\n").unwrap();
let ignored = run_git_check_ignore(&repo_path, &["test.txt".to_string()])
.await
.unwrap();
assert!(
ignored.is_empty(),
"test.txt should not be ignored by *.log pattern"
);
}
#[tokio::test]
async fn test_run_git_check_ignore_empty_paths() {
let (_dir, repo_path) = init_temp_repo();
let ignored = run_git_check_ignore(&repo_path, &[]).await.unwrap();
assert!(
ignored.is_empty(),
"Empty path list should produce empty result"
);
}
#[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_empty() {
assert!(!has_unstaged_changes(""));
assert!(!has_unstaged_changes("\n\n"));
}
#[test]
fn has_unstaged_changes_fully_staged() {
assert!(!has_unstaged_changes("M Cargo.toml\n"));
assert!(!has_unstaged_changes("A src/lib.rs\n"));
assert!(!has_unstaged_changes(
"M Cargo.toml\nA src/lib.rs\nD old.rs\n"
));
}
#[test]
fn has_unstaged_changes_unstaged_modifications() {
assert!(has_unstaged_changes(" M src/lib.rs\n"));
assert!(has_unstaged_changes(" D src/old.rs\n"));
}
#[test]
fn has_unstaged_changes_dual_status() {
assert!(has_unstaged_changes("MM src/lib.rs\n"));
assert!(has_unstaged_changes("AM src/new.rs\n"));
assert!(has_unstaged_changes("MD src/old.rs\n"));
}
#[test]
fn has_unstaged_changes_untracked() {
assert!(has_unstaged_changes("?? new_file.rs\n"));
assert!(has_unstaged_changes("?? dir/untracked.txt\n"));
}
#[test]
fn has_unstaged_changes_mixed() {
assert!(!has_unstaged_changes("M Cargo.toml\nA src/main.rs\n"));
assert!(has_unstaged_changes(
"M Cargo.toml\n M src/main.rs\n?? new.rs\n"
));
}
#[test]
fn has_unstaged_changes_trailing_newline() {
assert!(has_unstaged_changes(" M file.rs\n"));
assert!(!has_unstaged_changes("M file.rs\n"));
}
}