use regex::Regex;
use std::collections::HashMap;
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
static DIFF_FILE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^diff --git a/(.+) b/(.+)$").unwrap());
static HUNK_HEADER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^@@ -(\d+),?\d* \+(\d+),?\d* @@").unwrap());
static ANSI_ESCAPE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[.*?m").unwrap());
pub type LineChange = (usize, String);
pub type FileChanges = HashMap<String, (Vec<LineChange>, Vec<LineChange>)>;
pub fn get_changes_with_args(args: &str) -> Result<(FileChanges, String, String), Box<dyn Error>> {
let args_vec: Vec<&str> = args.split_whitespace().collect();
let diff_output = get_diff_output_with_args(&args_vec)?;
let left_label = extract_left_label(args);
let right_label = extract_right_label(args);
Ok((parse_diff_output(&diff_output)?, left_label, right_label))
}
pub fn get_uncommitted_changes() -> Result<(FileChanges, String, String), Box<dyn Error>> {
let diff_output = get_diff_output_with_args(&[])?;
Ok((
parse_diff_output(&diff_output)?,
"HEAD".to_string(),
"Working Tree".to_string(),
))
}
pub fn get_changes_to_ref(
reference: &str,
) -> Result<(FileChanges, String, String), Box<dyn Error>> {
let diff_output = get_diff_output_with_args(&[reference])?;
Ok((
parse_diff_output(&diff_output)?,
reference.to_string(),
"Working Tree".to_string(),
))
}
pub fn get_changes_between(
from: &str,
to: &str,
) -> Result<(FileChanges, String, String), Box<dyn Error>> {
let diff_output = get_diff_output_with_args(&[&format!("{}..{}", from, to)])?;
Ok((
parse_diff_output(&diff_output)?,
from.to_string(),
to.to_string(),
))
}
pub fn get_upstream_branch() -> Result<Option<String>, Box<dyn Error>> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD@{u}"])
.output()?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}
fn get_diff_output_with_args(args: &[&str]) -> Result<String, Box<dyn Error>> {
let mut cmd_args = vec!["diff", "--no-color"];
cmd_args.extend_from_slice(args);
let output = Command::new("git").args(&cmd_args).output()?;
if !output.status.success() {
return Err(format!(
"Failed to execute git diff command: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(String::from_utf8(output.stdout)
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()))
}
fn extract_left_label(args: &str) -> String {
args.split("..")
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Base".to_string())
}
fn extract_right_label(args: &str) -> String {
args.split("..")
.nth(1)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Target".to_string())
}
fn parse_diff_output(diff_output: &str) -> Result<FileChanges, Box<dyn Error>> {
let mut file_changes = HashMap::new();
let mut current_file = String::new();
let mut base_lines = Vec::new();
let mut head_lines = Vec::new();
let mut base_line_number = 1;
let mut head_line_number = 1;
for line in diff_output.lines() {
let trimmed = line.trim_end();
if trimmed.starts_with("\\ ") {
continue;
}
let trimmed_line = ANSI_ESCAPE_RE.replace_all(trimmed, "");
if let Some(caps) = DIFF_FILE_RE.captures(trimmed_line.as_ref()) {
if !current_file.is_empty() {
file_changes.insert(
std::mem::take(&mut current_file),
(
std::mem::take(&mut base_lines),
std::mem::take(&mut head_lines),
),
);
}
current_file = match caps.get(2) {
Some(m) => m.as_str().to_string(),
None => continue,
};
base_line_number = 1;
head_line_number = 1;
continue;
}
if let Some(caps) = HUNK_HEADER_RE.captures(trimmed_line.as_ref()) {
base_line_number = caps
.get(1)
.and_then(|m| m.as_str().parse::<usize>().ok())
.unwrap_or(1);
head_line_number = caps
.get(2)
.and_then(|m| m.as_str().parse::<usize>().ok())
.unwrap_or(1);
continue;
}
if trimmed_line.starts_with("index")
|| trimmed_line.starts_with("---")
|| trimmed_line.starts_with("+++")
|| trimmed_line.starts_with("@@")
|| trimmed_line.starts_with("new file mode")
|| trimmed_line.starts_with("new mode")
|| trimmed_line.starts_with("old mode")
|| trimmed_line.starts_with("deleted file mode")
|| trimmed_line.starts_with("rename from")
|| trimmed_line.starts_with("rename to")
|| trimmed_line.starts_with("copy from")
|| trimmed_line.starts_with("copy to")
|| trimmed_line.starts_with("similarity index")
|| trimmed_line.starts_with("dissimilarity index")
|| trimmed_line.starts_with("Binary files")
{
continue;
}
if trimmed_line.starts_with('-') {
base_lines.push((base_line_number, trimmed_line.to_string()));
base_line_number += 1;
} else if trimmed_line.starts_with('+') {
head_lines.push((head_line_number, trimmed_line.to_string()));
head_line_number += 1;
} else {
base_lines.push((base_line_number, trimmed_line.to_string()));
head_lines.push((head_line_number, trimmed_line.to_string()));
base_line_number += 1;
head_line_number += 1;
}
}
if !current_file.is_empty() {
file_changes.insert(current_file, (base_lines, head_lines));
}
Ok(file_changes)
}
#[derive(Clone)]
pub enum ChangeOp {
Replace(usize, String),
Delete(usize),
Insert {
base_pos: usize,
order: usize,
content: String,
},
}
impl ChangeOp {
fn line_num(&self) -> usize {
match self {
ChangeOp::Replace(n, _) | ChangeOp::Delete(n) => *n,
ChangeOp::Insert { base_pos, .. } => *base_pos,
}
}
}
fn git_repo_root() -> Result<String, Box<dyn Error>> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()?;
if !output.status.success() {
return Err("Not in a git repository".into());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn has_uncommitted_changes() -> Result<bool, Box<dyn Error>> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()?;
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
fn resolve_diff_path(file_path: &str) -> Result<PathBuf, Box<dyn Error>> {
let repo_root = git_repo_root()?;
Ok(Path::new(&repo_root).join(file_path))
}
pub fn apply_operations(lines: &[String], operations: &[ChangeOp]) -> Vec<String> {
let mut lines: Vec<String> = lines.to_vec();
let mut base_ops: Vec<&ChangeOp> = operations
.iter()
.filter(|op| matches!(op, ChangeOp::Replace(..) | ChangeOp::Delete(..)))
.collect();
base_ops.sort_by_key(|op| std::cmp::Reverse(op.line_num()));
let mut deleted_positions: Vec<usize> = Vec::new();
for op in &base_ops {
match op {
ChangeOp::Replace(line_num, content) => {
if *line_num == 0 {
continue;
}
let idx = line_num - 1;
if idx < lines.len() {
lines[idx] = content.clone();
}
}
ChangeOp::Delete(line_num) => {
if *line_num == 0 {
continue;
}
let idx = line_num - 1;
if idx < lines.len() {
lines.remove(idx);
deleted_positions.push(*line_num);
}
}
_ => {}
}
}
let mut insert_ops: Vec<&ChangeOp> = operations
.iter()
.filter(|op| matches!(op, ChangeOp::Insert { .. }))
.collect();
insert_ops.sort_by(|a, b| {
let pos_cmp = b.line_num().cmp(&a.line_num());
if pos_cmp != std::cmp::Ordering::Equal {
return pos_cmp;
}
let a_order = if let ChangeOp::Insert { order, .. } = a {
*order
} else {
0
};
let b_order = if let ChangeOp::Insert { order, .. } = b {
*order
} else {
0
};
b_order.cmp(&a_order)
});
deleted_positions.sort_unstable();
for op in &insert_ops {
if let ChangeOp::Insert {
base_pos, content, ..
} = op
{
if *base_pos == 0 {
continue;
}
let deletes_before = deleted_positions.partition_point(|&d| d < *base_pos);
let adjusted = base_pos.saturating_sub(deletes_before);
let idx = adjusted.saturating_sub(1).min(lines.len());
lines.insert(idx, content.clone());
}
}
lines
}
pub fn apply_changes(file_path: &str, operations: &[ChangeOp]) -> Result<(), Box<dyn Error>> {
if operations.is_empty() {
return Ok(());
}
let full_path = resolve_diff_path(file_path)?;
let original_content = std::fs::read_to_string(&full_path)?;
let has_trailing_newline = original_content.ends_with('\n');
let lines: Vec<String> = original_content.lines().map(|s| s.to_string()).collect();
let result_lines = apply_operations(&lines, operations);
let mut result = result_lines.join("\n");
if has_trailing_newline {
result.push('\n');
}
std::fs::write(&full_path, result)?;
Ok(())
}
pub fn check_rebase_needed() -> Result<Option<String>, Box<dyn Error>> {
let status = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()?;
if !status.status.success() {
return Ok(None);
}
let branch_output = Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.output()?;
if !branch_output.status.success() {
return Ok(None);
}
let current_branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
let upstream_output = match Command::new("git")
.args([
"rev-parse",
"--abbrev-ref",
&format!("{}@{{u}}", current_branch),
])
.output()
{
Ok(output) if output.status.success() => output,
_ => return Ok(None), };
let upstream_name = String::from_utf8_lossy(&upstream_output.stdout)
.trim()
.to_string();
let status_output = Command::new("git").args(["status", "-sb"]).output()?;
let status_text = String::from_utf8_lossy(&status_output.stdout).to_string();
if status_text.contains("ahead") && status_text.contains("behind") {
return Ok(Some(format!(
"Your branch '{}' has diverged from '{}'.\nConsider rebasing to integrate changes cleanly.",
current_branch, upstream_name
)));
}
if status_text.contains("[behind") {
return Ok(Some(format!(
"Your branch '{}' is behind '{}'. A rebase is recommended.",
current_branch, upstream_name
)));
}
Ok(None)
}
pub fn perform_rebase(upstream: &str) -> Result<bool, Box<dyn Error>> {
if has_uncommitted_changes()? {
return Err(
"Cannot rebase: you have uncommitted changes. Please commit or stash them first."
.into(),
);
}
let output = Command::new("git").args(["rebase", upstream]).output()?;
if !output.status.success() {
let _ = Command::new("git").args(["rebase", "--abort"]).output();
return Ok(false);
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn left_label_from_range() {
assert_eq!(extract_left_label("main..feature"), "main");
}
#[test]
fn right_label_from_range() {
assert_eq!(extract_right_label("main..feature"), "feature");
}
#[test]
fn left_label_no_dots_returns_input() {
assert_eq!(extract_left_label("--cached"), "--cached");
}
#[test]
fn right_label_no_dots_returns_default() {
assert_eq!(extract_right_label("--cached"), "Target");
}
#[test]
fn labels_with_empty_sides() {
assert_eq!(extract_left_label("..feature"), "Base");
assert_eq!(extract_right_label("main.."), "Target");
}
#[test]
fn labels_trim_whitespace() {
assert_eq!(extract_left_label(" main .. feature "), "main");
assert_eq!(extract_right_label(" main .. feature "), "feature");
}
#[test]
fn parse_skips_rename_metadata() {
let diff = "\
diff --git a/old.rs b/new.rs
similarity index 95%
rename from old.rs
rename to new.rs
index abc..def 100644
--- a/old.rs
+++ b/new.rs
@@ -1,3 +1,3 @@
fn main() {
- old();
+ new();
}
";
let changes = parse_diff_output(diff).unwrap();
let (base, head) = changes.get("new.rs").expect("file should be present");
assert!(
!base
.iter()
.any(|(_, l)| l.contains("similarity") || l.contains("rename")),
"metadata leaked into base lines: {:?}",
base
);
assert!(
!head
.iter()
.any(|(_, l)| l.contains("similarity") || l.contains("rename")),
"metadata leaked into head lines: {:?}",
head
);
}
#[test]
fn parse_skips_no_newline_marker() {
let diff = "\
diff --git a/file.rs b/file.rs
index abc..def 100644
--- a/file.rs
+++ b/file.rs
@@ -1,2 +1,2 @@
-old line
\\ No newline at end of file
+new line
\\ No newline at end of file
";
let changes = parse_diff_output(diff).unwrap();
let (base, head) = changes.get("file.rs").expect("file should be present");
assert!(
!base.iter().any(|(_, l)| l.contains("No newline")),
"no-newline marker leaked into base lines: {:?}",
base
);
assert!(
!head.iter().any(|(_, l)| l.contains("No newline")),
"no-newline marker leaked into head lines: {:?}",
head
);
}
#[test]
fn parse_skips_binary_files_line() {
let diff = "\
diff --git a/image.png b/image.png
Binary files a/image.png and b/image.png differ
";
let changes = parse_diff_output(diff).unwrap();
if let Some((base, head)) = changes.get("image.png") {
assert!(base.is_empty());
assert!(head.is_empty());
}
}
}