use console::{style, Term};
use indicatif::{ProgressBar, ProgressStyle};
use tokio::process::Command;
pub const EXCLUDED_FROM_DIFF: &[&str] = &[
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"bun.lockb",
"uv.lock",
".min.js",
".min.css",
".map",
"target/",
"node_modules/",
"dist/",
"build/",
".next/",
"__pycache__/",
];
pub const MAX_DIFF_CHARS: usize = 300_000;
pub fn should_exclude_from_diff(filename: &str) -> bool {
EXCLUDED_FROM_DIFF.iter().any(|pattern| {
if pattern.ends_with('/') {
let dir_name = pattern.trim_end_matches('/');
filename.contains(&format!("/{}/", dir_name))
|| filename.starts_with(&format!("{}/", dir_name))
} else if pattern.starts_with('.') {
filename.ends_with(pattern)
} else {
filename.ends_with(pattern) || filename.ends_with(&format!("/{}", pattern))
}
})
}
fn extract_filename_from_diff_header(header: &str) -> Option<&str> {
header
.lines()
.next()
.and_then(|line| line.strip_prefix("diff --git a/"))
.and_then(|rest| rest.split(" b/").next())
}
pub fn filter_excluded_diffs(diff: &str, verbose: bool) -> String {
if diff.is_empty() {
return diff.to_string();
}
let mut chunks: Vec<&str> = diff.split("\ndiff --git ").collect();
if chunks.is_empty() {
return diff.to_string();
}
let first = chunks.remove(0);
let mut file_diffs: Vec<String> = vec![];
let mut excluded_files: Vec<String> = vec![];
if !first.is_empty() {
if let Some(filename) = extract_filename_from_diff_header(&format!("diff --git {}", first))
{
if should_exclude_from_diff(filename) {
excluded_files.push(filename.to_string());
} else {
file_diffs.push(first.to_string());
}
} else {
file_diffs.push(first.to_string());
}
}
for chunk in chunks {
let full_header = format!("diff --git {}", chunk);
if let Some(filename) = extract_filename_from_diff_header(&full_header) {
if should_exclude_from_diff(filename) {
excluded_files.push(filename.to_string());
} else {
file_diffs.push(format!("\ndiff --git {}", chunk));
}
}
}
if verbose && !excluded_files.is_empty() {
eprintln!("— Excluded from diff ({} files):", excluded_files.len());
for file in &excluded_files {
eprintln!(" {}", file);
}
}
file_diffs.join("")
}
pub fn truncate_diff(diff: &str, verbose: bool) -> String {
if diff.len() <= MAX_DIFF_CHARS {
return diff.to_string();
}
let mut chunks: Vec<&str> = diff.split("\ndiff --git ").collect();
if chunks.is_empty() {
let keep_each = MAX_DIFF_CHARS / 2;
let start = &diff[..keep_each];
let end = &diff[diff.len() - keep_each..];
if verbose {
eprintln!(
"— Diff truncated: {} chars removed (fallback mode)",
diff.len() - MAX_DIFF_CHARS
);
}
return format!(
"{}\n\n[... {} characters truncated ...]\n\n{}",
start,
diff.len() - MAX_DIFF_CHARS,
end
);
}
let first = chunks.remove(0);
let mut file_diffs: Vec<String> = vec![first.to_string()];
for chunk in chunks {
file_diffs.push(format!("diff --git {}", chunk));
}
let mut result = String::new();
let mut total_len = 0;
let mut included = 0;
for file_diff in &file_diffs {
let chunk_len = file_diff.len();
if total_len + chunk_len + 200 > MAX_DIFF_CHARS {
break;
}
result.push_str(file_diff);
result.push('\n');
total_len += chunk_len + 1;
included += 1;
}
if included < file_diffs.len() {
if verbose {
eprintln!(
"— Diff truncated: showing {}/{} files ({} KB limit)",
included,
file_diffs.len(),
MAX_DIFF_CHARS / 1024
);
}
result.push_str(&format!(
"\n[... diff truncated: showing {}/{} files to fit context limit ...]\n",
included,
file_diffs.len()
));
}
result
}
pub async fn get_git_diff(
staged_only: bool,
verbose: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let args = if staged_only {
vec!["diff", "--staged"]
} else {
vec!["diff", "HEAD"]
};
let output = Command::new("git").args(&args).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff failed: {}", stderr).into());
}
let diff = String::from_utf8_lossy(&output.stdout).to_string();
let filtered_diff = filter_excluded_diffs(&diff, verbose);
Ok(truncate_diff(&filtered_diff, verbose))
}
pub async fn get_staged_files(verbose: bool) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["diff", "--staged", "--name-status"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff --name-status failed: {}", stderr).into());
}
let raw_output = String::from_utf8_lossy(&output.stdout).to_string();
let mut excluded_count = 0;
let annotated: Vec<String> = raw_output
.lines()
.map(|line| {
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.len() == 2 {
let filename = parts[1];
if should_exclude_from_diff(filename) {
excluded_count += 1;
format!("{}\t{} [excluded from diff]", parts[0], filename)
} else {
line.to_string()
}
} else {
line.to_string()
}
})
.collect();
if verbose {
let total = annotated.len();
eprintln!(
"— Staged files: {} total, {} excluded from diff",
total, excluded_count
);
}
Ok(annotated.join("\n"))
}
pub async fn run_git_commit(message: &str) -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["commit", "-m", message])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git commit failed: {}", stderr).into());
}
Ok(())
}
pub async fn stage_all_changes() -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("git").args(["add", "-A"]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git add failed: {}", stderr).into());
}
Ok(())
}
pub async fn get_current_branch() -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git rev-parse failed: {}", stderr).into());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn create_and_switch_branch(branch_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["checkout", "-b", branch_name])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git checkout -b failed: {}", stderr).into());
}
Ok(())
}
pub async fn get_recent_commits(limit: usize) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["log", "--oneline", &format!("-{}", limit), "--format=%s"])
.output()
.await?;
if !output.status.success() {
return Ok(String::new());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn branch_has_merge_base(branch: &str) -> bool {
let output = Command::new("git")
.args(["merge-base", branch, "HEAD"])
.output()
.await;
matches!(output, Ok(o) if o.status.success())
}
pub async fn get_cached_remote_head() -> Option<String> {
let output = Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
full_ref
.strip_prefix("refs/remotes/origin/")
.map(|s| s.to_string())
}
pub async fn get_remote_default_branch() -> Option<String> {
let output = Command::new("git")
.args(["ls-remote", "--symref", "origin", "HEAD"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("ref: refs/heads/") && line.contains("HEAD") {
if let Some(rest) = line.strip_prefix("ref: refs/heads/") {
if let Some(branch) = rest.split('\t').next() {
return Some(branch.to_string());
}
}
}
}
None
}
pub async fn get_upstream_remote() -> Result<Option<String>, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["remote", "get-url", "upstream"])
.output()
.await?;
if output.status.success() {
return Ok(Some("upstream".to_string()));
}
Ok(None)
}
pub async fn branch_needs_push(branch: &str) -> bool {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", &format!("{}@{{u}}", branch)])
.output()
.await;
match output {
Ok(o) if o.status.success() => {
let status = Command::new("git").args(["status", "-sb"]).output().await;
if let Ok(s) = status {
let out = String::from_utf8_lossy(&s.stdout);
out.contains("ahead")
} else {
false
}
}
_ => true, }
}
pub struct UncommittedChanges {
pub staged: Vec<String>,
pub unstaged: Vec<String>,
}
pub async fn get_uncommitted_changes() -> Result<UncommittedChanges, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.await?;
if !output.status.success() {
return Err("Failed to get git status".into());
}
let status = String::from_utf8_lossy(&output.stdout);
let mut staged = Vec::new();
let mut unstaged = Vec::new();
for line in status.lines() {
if line.len() < 3 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let file = &line[3..];
if index_status != ' ' && index_status != '?' {
staged.push(format!(" {} {}", index_status, file));
}
if worktree_status != ' ' {
let status_char = if worktree_status == '?' {
'?'
} else {
worktree_status
};
unstaged.push(format!(" {} {}", status_char, file));
}
}
Ok(UncommittedChanges { staged, unstaged })
}
pub async fn push_branch_with_spinner(branch: &str) -> Result<(), Box<dyn std::error::Error>> {
if !branch_needs_push(branch).await {
return Ok(());
}
let term = Term::stdout();
let _ = term.hide_cursor();
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
.template("{spinner:.cyan} Pushing branch to origin...")
.unwrap(),
);
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
let push_output = Command::new("git")
.args(["push", "-u", "origin", branch])
.output()
.await?;
spinner.finish_and_clear();
let _ = term.show_cursor();
if !push_output.status.success() {
let stderr = String::from_utf8_lossy(&push_output.stderr);
return Err(format!("Failed to push branch: {}", stderr).into());
}
println!("{} Pushed branch to origin", style("✓").green());
Ok(())
}
pub async fn get_branch_diff(
base: &str,
verbose: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["diff", &format!("{}...HEAD", base)])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff failed: {}", stderr).into());
}
let diff = String::from_utf8_lossy(&output.stdout).to_string();
let filtered_diff = filter_excluded_diffs(&diff, verbose);
Ok(truncate_diff(&filtered_diff, verbose))
}
pub async fn get_branch_commits(base: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["log", &format!("{}..HEAD", base), "--format=%s"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git log failed: {}", stderr).into());
}
let commits: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(commits)
}
pub async fn get_pr_changed_files(
base: &str,
verbose: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["diff", "--name-status", &format!("{}...HEAD", base)])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff --name-status failed: {}", stderr).into());
}
let raw_output = String::from_utf8_lossy(&output.stdout).to_string();
let mut excluded_count = 0;
let annotated: Vec<String> = raw_output
.lines()
.map(|line| {
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.len() == 2 {
let filename = parts[1];
if should_exclude_from_diff(filename) {
excluded_count += 1;
format!("{}\t{} [excluded from diff]", parts[0], filename)
} else {
line.to_string()
}
} else {
line.to_string()
}
})
.collect();
if verbose && excluded_count > 0 {
eprintln!(
"— PR files: {} total, {} excluded from diff",
annotated.len(),
excluded_count
);
}
Ok(annotated.join("\n"))
}