use std::collections::HashSet;
use std::path::Path;
use tracing::warn;
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub hash: String,
pub lines_added: i64,
pub lines_removed: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffFileStatus {
Modified,
Added,
Deleted,
Renamed,
Untracked,
}
#[derive(Debug, Clone)]
pub struct DiffFile {
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<DiffHunk>,
pub status: DiffFileStatus,
pub is_binary: bool,
pub too_large_size: Option<u64>,
}
impl DiffFile {
#[must_use]
pub const fn placeholder(path: String, is_binary: bool, too_large_size: Option<u64>) -> Self {
Self {
path,
old_path: None,
hunks: Vec::new(),
status: DiffFileStatus::Untracked,
is_binary,
too_large_size,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub old_line_number: Option<usize>,
pub new_line_number: Option<usize>,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
Added,
Removed,
Context,
}
impl DiffLineKind {
#[must_use]
pub const fn prefix(self) -> char {
match self {
Self::Added => '+',
Self::Removed => '-',
Self::Context => ' ',
}
}
}
#[derive(Default)]
struct DiffParser {
files: Vec<DiffFile>,
current_file: Option<DiffFile>,
current_hunk: Option<DiffHunk>,
old_counter: usize,
new_counter: usize,
}
impl DiffParser {
fn flush(&mut self) {
if let Some(mut file) = self.current_file.take() {
if let Some(hunk) = self.current_hunk.take() {
file.hunks.push(hunk);
}
self.files.push(file);
}
}
fn handle_diff_git_header(&mut self, line: &str) {
self.flush();
self.old_counter = 0;
self.new_counter = 0;
if let Some(path) = parse_diff_git_line(line) {
self.current_file = Some(DiffFile {
path,
old_path: None,
hunks: Vec::new(),
status: DiffFileStatus::Modified,
is_binary: false,
too_large_size: None,
});
}
}
fn handle_rename_from(&mut self, line: &str) {
let Some(f) = self.current_file.as_mut() else {
return;
};
f.status = DiffFileStatus::Renamed;
let raw = line.strip_prefix("rename from ").unwrap_or("");
let Some(old_path) = unquote_c_style(raw) else {
warn!(
line = %line,
"rename from: malformed C-style escape, dropping rename info"
);
f.status = DiffFileStatus::Modified;
return;
};
f.old_path = Some(old_path);
}
fn handle_hunk_header(&mut self, line: &str) {
if let Some(hunk) = self.current_hunk.take()
&& let Some(f) = &mut self.current_file
{
f.hunks.push(hunk);
}
let (old_start, new_start) = parse_hunk_header(line);
self.old_counter = old_start;
self.new_counter = new_start;
self.current_hunk = Some(DiffHunk {
header: line.to_string(),
lines: Vec::new(),
});
}
fn handle_diff_content_line(&mut self, line: &str) {
let Some(hunk) = self.current_hunk.as_mut() else {
return;
};
let line_kind = if line.starts_with('+') {
DiffLineKind::Added
} else if line.starts_with('-') {
DiffLineKind::Removed
} else if line.starts_with(' ') {
DiffLineKind::Context
} else if line == r"\ No newline at end of file" {
return;
} else {
return;
};
let content = line[1..].trim_end_matches('\r');
let (old_num, new_num) = match line_kind {
DiffLineKind::Added => {
let n = Some(self.new_counter);
self.new_counter += 1;
(None, n)
}
DiffLineKind::Removed => {
let n = Some(self.old_counter);
self.old_counter += 1;
(n, None)
}
DiffLineKind::Context => {
let o = Some(self.old_counter);
let n = Some(self.new_counter);
self.old_counter += 1;
self.new_counter += 1;
(o, n)
}
};
hunk.lines.push(DiffLine {
kind: line_kind,
old_line_number: old_num,
new_line_number: new_num,
content: content.to_string(),
});
}
fn process_line(&mut self, line: &str) {
if line.starts_with("diff --git ") {
self.handle_diff_git_header(line);
} else if line.starts_with("index ")
|| line.starts_with("new file mode ")
|| line.starts_with("deleted file mode ")
|| line.starts_with("old mode ")
|| line.starts_with("new mode ")
{
} else if line.starts_with("--- ") || line.starts_with("+++ ") {
if let Some(ref mut f) = self.current_file {
if line.starts_with("--- /dev/null") && f.status != DiffFileStatus::Renamed {
f.status = DiffFileStatus::Added;
} else if line.starts_with("+++ /dev/null") && f.status != DiffFileStatus::Renamed {
f.status = DiffFileStatus::Deleted;
}
}
} else if line.starts_with("rename from ") {
self.handle_rename_from(line);
} else if line.starts_with("rename to ") {
} else if line.starts_with("Binary files ") {
if let Some(ref mut f) = self.current_file {
f.is_binary = true;
}
} else if line.starts_with("@@") {
self.handle_hunk_header(line);
} else {
self.handle_diff_content_line(line);
}
}
}
#[must_use]
pub fn parse_git_diff(diff_output: &str) -> Vec<DiffFile> {
let mut parser = DiffParser::default();
for line in diff_output.lines() {
parser.process_line(line);
}
parser.flush();
parser.files
}
#[must_use]
pub fn make_untracked_diff_file(path: &str, content: &str) -> DiffFile {
let lines: Vec<DiffLine> = content
.lines()
.enumerate()
.map(|(idx, line)| DiffLine {
kind: DiffLineKind::Added,
old_line_number: None,
new_line_number: Some(idx + 1),
content: line.trim_end_matches('\r').to_string(),
})
.collect();
let hunk = DiffHunk {
header: format!("@@ -0,0 +1,{} @@ new file", lines.len()),
lines,
};
DiffFile {
path: path.to_string(),
old_path: None,
hunks: vec![hunk],
status: DiffFileStatus::Untracked,
is_binary: false,
too_large_size: None,
}
}
#[must_use]
pub fn unquote_c_style(raw: &str) -> Option<String> {
if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
unescape_c_style(inner)
} else {
Some(raw.to_string())
}
}
fn unescape_c_style(input: &str) -> Option<String> {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i: usize = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 1; if i >= bytes.len() {
warn!(
input = %input,
"unescape_c_style: dangling backslash at end of string"
);
return None;
}
match bytes[i] {
b'"' => result.push('"'),
b'\\' => result.push('\\'),
b't' => result.push('\t'),
b'n' => result.push('\n'),
b'a' => result.push('\x07'),
b'b' => result.push('\x08'),
b'f' => result.push('\x0c'),
b'r' => result.push('\r'),
b'v' => result.push('\x0b'),
b'0'..=b'3' => {
let digits_start = i;
i += 1;
let mut digit_count = 1;
while digit_count < 3 && i < bytes.len() && bytes[i].is_ascii_digit() {
if !(b'0'..=b'7').contains(&bytes[i]) {
break;
}
i += 1;
digit_count += 1;
}
let octal_str = std::str::from_utf8(&bytes[digits_start..i]).ok()?;
let Ok(byte_val) = u8::from_str_radix(octal_str, 8) else {
warn!(
input = %input, octal = %octal_str,
"unescape_c_style: invalid octal escape"
);
return None;
};
result.push_str(&String::from_utf8_lossy(&[byte_val]));
continue; }
b'4'..=b'7' => {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: invalid octal prefix \\4–\\7 followed by digit"
);
return None;
}
result.push(bytes[i] as char);
}
_ => {
warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: unrecognized escape sequence"
);
return None;
}
}
i += 1;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
Some(result)
}
fn parse_diff_git_line(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let b_part = find_b_part(rest)?;
extract_b_path(b_part)
}
fn find_b_part(rest: &str) -> Option<&str> {
let bytes = rest.as_bytes();
if bytes.first() == Some(&b'"') {
let mut i: usize = 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2; } else if bytes[i] == b'"' {
i += 1; break;
} else {
i += 1;
}
}
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() {
return None;
}
Some(&rest[i..])
} else {
if let Some(idx) = rest.find(" \"b/") {
Some(&rest[idx + 1..])
} else {
rest.find(" b/").map(|idx| &rest[idx + 1..])
}
}
}
fn extract_b_path(b_part: &str) -> Option<String> {
let path = unquote_c_style(b_part)?;
Some(path.strip_prefix("b/")?.to_string())
}
fn parse_range_token(part: &str, prefix: char, header: &str) -> Option<usize> {
let remainder = part.strip_prefix(prefix)?;
if remainder.is_empty() || !remainder.chars().all(|c| c.is_ascii_digit() || c == ',') {
warn!("Skipping non-numeric '{prefix}' token {part:?} in hunk header {header:?}");
return None;
}
remainder
.split(',')
.next()
.and_then(|s| s.parse::<usize>().ok())
}
fn parse_hunk_header(header: &str) -> (usize, usize) {
let after_open = header.trim_start_matches('@');
let mut old_start = 1;
let mut new_start = 1;
for part in after_open.split_whitespace() {
if part.starts_with('@') {
break;
}
if part.starts_with('-')
&& let Some(n) = parse_range_token(part, '-', header)
{
old_start = n;
} else if part.starts_with('+')
&& let Some(n) = parse_range_token(part, '+', header)
{
new_start = n;
}
}
(old_start, new_start)
}
#[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>) -> Result<String, 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) -> Result<String, 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>,
) -> Result<Option<String>, String> {
let show_arg = if let Some(hash) = commit_ref {
format!("{hash}:{file_path}")
} else {
format!("HEAD:{file_path}")
};
match run_git_command(repo_path, &["show", &show_arg]).await {
Ok(output) => Ok(Some(output)),
Err(_) => Ok(None),
}
}
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> Result<String, String> {
let output = tokio::process::Command::new("git")
.args(args)
.current_dir(repo_path)
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(format!("Git command failed: {stderr}"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn run_git_check_ignore(
repo_path: &Path,
paths: &[String],
) -> Result<HashSet<String>, String> {
use std::process::Stdio;
let mut child = tokio::process::Command::new("git")
.args(["check-ignore", "--stdin"])
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn git check-ignore: {e}"))?;
let mut stdin = child.stdin.take().expect("stdin not captured");
for path in paths {
use tokio::io::AsyncWriteExt;
stdin
.write_all(path.as_bytes())
.await
.map_err(|e| format!("Failed to write to git stdin: {e}"))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write newline to git stdin: {e}"))?;
}
drop(stdin);
let output = child
.wait_with_output()
.await
.map_err(|e| format!("Failed to wait for git check-ignore: {e}"))?;
if output.status.code() == Some(1) {
return Ok(HashSet::new());
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(format!("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)
}
pub async fn run_git_commit(repo_path: &Path, message: &str) -> Result<CommitInfo, String> {
run_git_command(repo_path, &["add", "-A"]).await?;
run_git_command(repo_path, &["commit", "-m", message])
.await
.map_err(|e| format!("Commit failed: {}", e.trim()))?;
let hash = match run_git_command(repo_path, &["rev-parse", "HEAD"]).await {
Ok(out) => out.trim().to_string(),
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,
});
}
};
if let Ok((lines_added, lines_removed)) =
parse_numstat(repo_path, &["diff", "--numstat", "HEAD~1..HEAD"]).await
{
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
} else {
let (lines_added, lines_removed) = parse_numstat(
repo_path,
&[
"diff",
"--numstat",
"4b825dc642cb6eb9a060e54bf899dcee6a7b9e2a",
"HEAD",
],
)
.await
.unwrap_or((0, 0));
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
}
}
async fn parse_numstat(repo_path: &Path, args: &[&str]) -> Result<(i64, i64), String> {
let stdout = run_git_command(repo_path, args).await?;
let mut lines_added: i64 = 0;
let mut lines_removed: i64 = 0;
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
lines_added += parts[0].parse::<i64>().unwrap_or(0);
lines_removed += parts[1].parse::<i64>().unwrap_or(0);
}
}
Ok((lines_added, lines_removed))
}
pub async fn git_is_installed() -> bool {
tokio::process::Command::new("git")
.arg("--version")
.output()
.await
.is_ok_and(|o| o.status.success())
}
pub async fn git_has_commits(repo_path: &Path) -> Result<bool, String> {
let output = tokio::process::Command::new("git")
.args(["rev-list", "-n", "1", "HEAD"])
.current_dir(repo_path)
.output()
.await
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
pub async fn run_git_current_branch(repo_path: &Path) -> Result<String, 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) -> Result<(usize, usize), String> {
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) if e.contains("no upstream") || e.contains("upstream") => Ok((0, 0)),
Err(e) => Err(e),
}
}
pub async fn run_git_diff_stats(repo_path: &Path) -> Result<(i64, i64), String> {
parse_numstat(repo_path, &["diff", "--numstat", "HEAD"]).await
}
pub async fn run_git_list_branches(repo_path: &Path) -> Result<Vec<String>, String> {
let out = run_git_command(repo_path, &["branch", "--format=%(refname:short)"]).await?;
Ok(out.lines().map(ToString::to_string).collect())
}
pub async fn run_git_switch_branch(repo_path: &Path, branch: &str) -> Result<(), String> {
run_git_command(repo_path, &["switch", branch]).await?;
Ok(())
}
pub async fn run_git_create_branch(repo_path: &Path, branch: &str) -> Result<(), String> {
run_git_command(repo_path, &["switch", "-c", branch]).await?;
Ok(())
}
pub async fn run_git_sync(repo_path: &Path) -> Result<String, 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_commit_message(repo_path: &Path) -> Result<String, String> {
let out = run_git_command(repo_path, &["log", "-1", "--format=%s"]).await?;
Ok(out.trim().to_string())
}
pub(crate) async fn list_untracked_files(repo_path: &Path) -> Result<Vec<String>> {
let porcelain = run_git_status(repo_path)
.await
.map_err(anyhow::Error::msg)?;
Ok(parse_untracked_from_porcelain(&porcelain))
}
#[must_use]
pub(crate) fn parse_untracked_from_porcelain(porcelain: &str) -> Vec<String> {
porcelain
.lines()
.filter(|line| line.starts_with("?? ") || line.starts_with('A'))
.filter_map(|line| {
let path = line.get(3..)?;
if path.is_empty() {
None
} else {
Some(path.to_string())
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::init_temp_repo;
#[test]
fn test_parse_simple_diff() {
let diff = r#"diff --git a/src/main.rs b/src/main.rs
index abc123..def456 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
- println!("hello");
+ println!("hello world");
println!("goodbye");
}
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].path, "src/main.rs");
assert_eq!(output[0].hunks.len(), 1);
assert!(output[0].hunks[0].lines.len() >= 3);
assert_eq!(output[0].hunks[0].lines[0].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[0].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[0].lines[2].kind, DiffLineKind::Added);
}
#[test]
fn test_new_file() {
let diff = r#"diff --git a/new.rs b/new.rs
new file mode 100644
index 0000000..abc123
--- /dev/null
+++ b/new.rs
@@ -0,0 +1,2 @@
+fn hello() {
+ println!("new");
+}
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Added);
assert!(!output[0].hunks[0].lines.is_empty());
assert_eq!(output[0].hunks[0].lines[0].new_line_number, Some(1));
}
#[test]
fn test_binary() {
let diff = r"diff --git a/image.png b/image.png
index abc..def 100644
Binary files a/image.png and b/image.png differ
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert!(output[0].is_binary);
}
#[test]
fn test_empty_diff() {
let output = parse_git_diff("");
assert!(output.is_empty());
}
#[test]
fn test_parse_diff_git_line() {
let cases: &[(&str, Option<&str>)] = &[
(
"diff --git a/src/main.rs b/src/main.rs",
Some("src/main.rs"),
),
(
r#"diff --git "a/file\"name.rs" "b/file\"name.rs""#,
Some("file\"name.rs"),
),
(
r#"diff --git "a/path\\with\\backslash.rs" "b/path\\with\\backslash.rs""#,
Some("path\\with\\backslash.rs"),
),
(
r#"diff --git "a/file\tname.rs" "b/file\tname.rs""#,
Some("file\tname.rs"),
),
(
r#"diff --git "a/file\t\"quote\"\\n.rs" "b/file\t\"quote\"\\n.rs""#,
Some("file\t\"quote\"\\n.rs"),
),
(
r#"diff --git "a/file\"x.rs" b/normal.rs"#,
Some("normal.rs"),
),
(
r#"diff --git a/normal.rs "b/file\"x.rs""#,
Some("file\"x.rs"),
),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let result = parse_diff_git_line(input);
assert_eq!(
result.as_deref(),
*expected,
"case {i}: parse_diff_git_line({input:?})"
);
}
}
#[test]
fn test_unescape_c_style() {
let cases: &[(&str, Option<&str>)] = &[
(
r#"hello\"world\\test\nline\there"#,
Some("hello\"world\\test\nline\there"),
),
(r"\a\b\f\r\v", Some("\x07\x08\x0c\r\x0b")),
(r"\0\1", Some("\0\x01")),
(r"\12\37", Some("\n\x1f")),
(r"\101\377", Some("A\u{FFFD}")),
(r"\12x", Some("\nx")),
(r"\18", Some("\x018")),
("plain/path.rs", Some("plain/path.rs")),
("", Some("")),
(r"path\", None),
(r"\x", None),
(r"\q", None),
(r"\40", None),
(r"\77", None),
(r"\70", None),
(r"\4", Some("4")),
(r"\7x", Some("7x")),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let result = unescape_c_style(input);
assert_eq!(
result.as_deref(),
*expected,
"case {i}: unescape_c_style({input:?})"
);
}
}
#[test]
fn test_rename_from_cases() {
let cases: &[(&str, &str, Option<&str>, &str)] = &[
(
"unquoted",
r"diff --git a/old.rs b/new.rs
similarity index 100%
rename from old.rs
rename to new.rs
",
Some("old.rs"),
"new.rs",
),
(
"quoted_with_escapes",
r#"diff --git "a/old\"name.rs" "b/new\"name.rs"
similarity index 100%
rename from "old\"name.rs"
rename to "new\"name.rs"
"#,
Some("old\"name.rs"),
"new\"name.rs",
),
(
"quoted_tab_in_name",
r#"diff --git "a/old\tname.rs" "b/new\tname.rs"
similarity index 100%
rename from "old\tname.rs"
rename to "new\tname.rs"
"#,
Some("old\tname.rs"),
"new\tname.rs",
),
(
"no_trigger_chars",
r"diff --git a/old name.rs b/new name.rs
similarity index 100%
rename from old name.rs
rename to new name.rs
",
Some("old name.rs"),
"new name.rs",
),
];
for (i, (name, diff, expected_old_path, expected_path)) in cases.iter().enumerate() {
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1, "case {i} ({name})");
assert_eq!(
output[0].status,
DiffFileStatus::Renamed,
"case {i} ({name})"
);
assert_eq!(
output[0].old_path.as_deref(),
*expected_old_path,
"case {i} ({name}): old_path mismatch"
);
assert_eq!(
output[0].path, *expected_path,
"case {i} ({name}): path mismatch"
);
}
}
#[test]
fn test_untracked_file() {
let file = make_untracked_diff_file("src/untracked.rs", "fn main() {}");
assert_eq!(file.status, DiffFileStatus::Untracked);
assert_eq!(file.hunks[0].lines.len(), 1);
assert_eq!(file.hunks[0].lines[0].kind, DiffLineKind::Added);
}
#[test]
fn test_multi_hunk_diff() {
let diff = r"diff --git a/src/lib.rs b/src/lib.rs
index abc123..def456 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
fn top() {
- old_top();
+ new_top();
}
@@ -10,3 +10,3 @@
fn bottom() {
- old_bottom();
+ new_bottom();
}
@@ -100,6 +100,6 @@
fn middle() {
- old_line1();
- old_line2();
+ new_line1();
+ new_line2();
context_line();
}
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].path, "src/lib.rs");
assert_eq!(output[0].hunks.len(), 3);
assert_eq!(output[0].hunks[0].header, "@@ -1,3 +1,3 @@");
assert_eq!(output[0].hunks[0].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[0].lines[2].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[1].header, "@@ -10,3 +10,3 @@");
assert_eq!(output[0].hunks[1].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[1].lines[2].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].header, "@@ -100,6 +100,6 @@");
assert_eq!(output[0].hunks[2].lines.len(), 7);
assert_eq!(output[0].hunks[2].lines[0].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[2].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[2].lines[2].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[2].lines[3].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].lines[4].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].lines[5].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[2].lines[6].kind, DiffLineKind::Context);
}
#[test]
fn test_no_newline_annotation_skipped_and_counters_correct() {
let diff = r"diff --git a/foo.rs b/foo.rs
index abc123..def456 100644
--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
line1
line2
line3
\ No newline at end of file
@@ -10,2 +10,2 @@
other1
other2
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].hunks.len(), 2);
assert_eq!(output[0].hunks[0].lines.len(), 3);
assert_eq!(output[0].hunks[0].lines[0].old_line_number, Some(1));
assert_eq!(output[0].hunks[0].lines[2].old_line_number, Some(3));
assert_eq!(output[0].hunks[1].lines.len(), 2);
assert_eq!(output[0].hunks[1].lines[0].old_line_number, Some(10));
assert_eq!(output[0].hunks[1].lines[1].old_line_number, Some(11));
}
#[test]
fn test_parse_hunk_header_cases() {
let cases: &[(&str, &str, usize, usize)] = &[
(
"hunk_context_with_arrow",
"@@ -10,7 +10,9 @@ fn process() -> Result<()>",
10,
10,
),
(
"hunk_context_with_plus",
"@@ -5,3 +5,4 @@ fn add(a: i32, b: i32) -> i32 { let x = a + b; }",
5,
5,
),
("hunk_at_at_in_context", "@@ -1,3 +1,3 @@ @@ -this", 1, 1),
(
"hunk_plain_context",
"@@ -100,6 +200,7 @@ fn main() {",
100,
200,
),
("hunk_no_context", "@@ -0,0 +1,5 @@", 0, 1),
("hunk_no_count_first", "@@ -1 +1 @@ fn single_line()", 1, 1),
("hunk_no_count_second", "@@ -5 +3 @@ fn another()", 5, 3),
(
"hunk_negative_number_in_context",
"@@ -3,2 +3,2 @@ fn check() { if x < -1 { } }",
3,
3,
),
];
for (i, (name, input, expected_old, expected_new)) in cases.iter().enumerate() {
let (old, new) = parse_hunk_header(input);
assert_eq!(
old, *expected_old,
"case {i} ({name}): old_start mismatch. Input: {input:?}"
);
assert_eq!(
new, *expected_new,
"case {i} ({name}): new_start mismatch. Input: {input:?}"
);
}
}
#[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_list_branches_single() {
let (_dir, repo_path) = init_temp_repo();
let branches = run_git_list_branches(&repo_path)
.await
.expect("list branches");
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_create_branch(&repo_path, "feature/test")
.await
.expect("create branch");
let current = run_git_current_branch(&repo_path)
.await
.expect("current branch");
assert_eq!(current, "feature/test");
let branches = run_git_list_branches(&repo_path)
.await
.expect("list branches");
assert!(branches.contains(&"feature/test".to_string()));
run_git_switch_branch(&repo_path, &default_branch)
.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)
.await
.expect("commit message");
assert_eq!(msg, "Initial 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.contains("remote") || err.contains("push") || err.contains("pull"),
"error should mention remote/push/pull: {err}"
);
}
#[test]
fn parse_untracked_from_porcelain_extracts_new_files() {
let porcelain = "\
?? 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
";
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(&"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(&"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_untracked_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_untracked_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_untracked_from_porcelain(bad_line);
assert!(
files.is_empty(),
"Malformed line {bad_line:?} should produce empty result, got {files:?}"
);
}
}
}