use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use spinoff::{spinners, Color, Spinner};
use super::types::{is_binary_content, FileDiff, FileStatus};
use super::{DiffOptions, PrInfo};
use crate::commit_reference::CommitReference;
use crate::vcs::VcsBackend;
const PR_FETCH_CONCURRENCY: usize = 8;
pub fn get_current_branch(backend: &dyn VcsBackend) -> String {
backend
.get_current_branch()
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string())
}
pub enum DiffRefs {
WorkingTree,
Single(String),
Range { from: String, to: String },
RangeToWorkingTree { from: String },
}
impl DiffRefs {
pub fn from_options(options: &DiffOptions, backend: &dyn VcsBackend) -> Self {
match &options.reference {
None => DiffRefs::WorkingTree,
Some(CommitReference::Single(sha)) => DiffRefs::Single(sha.clone()),
Some(CommitReference::Range { from, to }) => DiffRefs::Range {
from: from.clone(),
to: to.clone(),
},
Some(CommitReference::TripleDots { from, to }) => {
let merge_base = backend.get_merge_base(from, to).unwrap_or_else(|e| {
eprintln!(
"Warning: failed to find merge-base for {}...{}: {}. Using '{}' as base.",
from, to, e, from
);
from.clone()
});
DiffRefs::Range {
from: merge_base,
to: to.clone(),
}
}
Some(CommitReference::RangeToWorkingTree { from }) => {
DiffRefs::RangeToWorkingTree { from: from.clone() }
}
}
}
}
pub fn get_changed_files(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec<String> {
let refs = DiffRefs::from_options(options, backend);
let files: Vec<String> = match refs {
DiffRefs::Single(sha) => backend.get_changed_files(&sha).unwrap_or_default(),
DiffRefs::Range { from, to } => backend
.get_range_changed_files(&from, &to)
.unwrap_or_default(),
DiffRefs::WorkingTree => backend.get_working_tree_changed_files().unwrap_or_default(),
DiffRefs::RangeToWorkingTree { from } => {
let head_ref = backend.working_copy_parent_ref();
let range_files = backend
.get_range_changed_files(&from, head_ref)
.unwrap_or_default();
let wt_files = backend.get_working_tree_changed_files().unwrap_or_default();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut combined: Vec<String> = Vec::new();
for f in range_files.into_iter().chain(wt_files.into_iter()) {
if seen.insert(f.clone()) {
combined.push(f);
}
}
combined
}
};
if let Some(ref filter) = options.file {
files.into_iter().filter(|f| filter.contains(f)).collect()
} else {
files
}
}
pub fn get_old_content(filename: &str, refs: &DiffRefs, backend: &dyn VcsBackend) -> String {
let ref_str = match refs {
DiffRefs::Single(sha) => {
backend.get_parent_ref_or_empty(sha).unwrap_or_default()
}
DiffRefs::Range { from, .. } => from.clone(),
DiffRefs::RangeToWorkingTree { from } => from.clone(),
DiffRefs::WorkingTree => backend.working_copy_parent_ref().to_string(),
};
if ref_str.is_empty() {
return String::new();
}
backend
.get_file_content_at_ref(&ref_str, Path::new(filename))
.unwrap_or_default()
}
pub fn get_new_content(filename: &str, refs: &DiffRefs, backend: &dyn VcsBackend) -> String {
match refs {
DiffRefs::Single(sha) => backend
.get_file_content_at_ref(sha, Path::new(filename))
.unwrap_or_default(),
DiffRefs::Range { to, .. } => backend
.get_file_content_at_ref(to, Path::new(filename))
.unwrap_or_default(),
DiffRefs::WorkingTree | DiffRefs::RangeToWorkingTree { .. } => {
fs::read_to_string(filename).unwrap_or_default()
}
}
}
pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec<FileDiff> {
let refs = DiffRefs::from_options(options, backend);
get_changed_files(options, backend)
.into_iter()
.map(|filename| {
let old_content = get_old_content(&filename, &refs, backend);
let new_content = get_new_content(&filename, &refs, backend);
let status = if old_content.is_empty() && !new_content.is_empty() {
FileStatus::Added
} else if !old_content.is_empty() && new_content.is_empty() {
FileStatus::Deleted
} else {
FileStatus::Modified
};
let is_binary =
is_binary_content(&old_content) || is_binary_content(&new_content);
FileDiff {
filename,
old_content,
new_content,
status,
is_binary,
}
})
.collect()
}
pub fn load_pr_file_diffs(pr_info: &PrInfo) -> Result<Vec<FileDiff>, String> {
let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name);
let mut spinner = Spinner::new(
spinners::Dots,
format!(
"Fetching file list for {}/{}#{}",
pr_info.repo_owner, pr_info.repo_name, pr_info.number
),
Color::Cyan,
);
let output = Command::new("gh")
.args([
"pr",
"diff",
&pr_info.number.to_string(),
"--repo",
&repo_arg,
])
.output();
let output = match output {
Ok(o) => o,
Err(e) => {
let msg = format!("Failed to run gh pr diff: {}", e);
spinner.fail(&msg);
return Err(msg);
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("gh pr diff failed: {}", stderr.trim());
spinner.fail(&msg);
return Err(msg);
}
let diff_output = String::from_utf8_lossy(&output.stdout);
let changed_files = parse_changed_files_from_diff(&diff_output);
let n = changed_files.len();
if n == 0 {
spinner.success("PR has no changed files");
return Ok(Vec::new());
}
let base_repo = format!("{}/{}", pr_info.base_repo_owner, pr_info.repo_name);
let head_repo = pr_info
.head_repo_owner
.as_ref()
.map(|owner| format!("{}/{}", owner, pr_info.repo_name))
.unwrap_or_else(|| base_repo.clone());
let contents = fetch_pr_file_contents_parallel(
&changed_files,
&base_repo,
&pr_info.base_ref,
&head_repo,
&pr_info.head_ref,
&mut spinner,
);
let file_diffs: Vec<FileDiff> = changed_files
.into_iter()
.zip(contents.into_iter())
.map(|(filename, (old_content, new_content))| {
let status = if old_content.is_empty() && !new_content.is_empty() {
FileStatus::Added
} else if !old_content.is_empty() && new_content.is_empty() {
FileStatus::Deleted
} else {
FileStatus::Modified
};
let is_binary =
is_binary_content(&old_content) || is_binary_content(&new_content);
FileDiff {
filename,
old_content,
new_content,
status,
is_binary,
}
})
.collect();
spinner.success(&format!("Fetched {} files", n));
Ok(file_diffs)
}
#[derive(Clone, Copy)]
enum Side {
Old,
New,
}
struct FetchTask {
idx: usize,
filename: String,
repo: String,
git_ref: String,
side: Side,
}
enum FetchEvent {
Started(String),
Finished {
idx: usize,
side: Side,
filename: String,
content: String,
},
}
fn fetch_pr_file_contents_parallel(
files: &[String],
base_repo: &str,
base_ref: &str,
head_repo: &str,
head_ref: &str,
spinner: &mut Spinner,
) -> Vec<(String, String)> {
let n = files.len();
let mut tasks: Vec<FetchTask> = Vec::with_capacity(2 * n);
for (idx, filename) in files.iter().enumerate() {
tasks.push(FetchTask {
idx,
filename: filename.clone(),
repo: base_repo.to_string(),
git_ref: base_ref.to_string(),
side: Side::Old,
});
tasks.push(FetchTask {
idx,
filename: filename.clone(),
repo: head_repo.to_string(),
git_ref: head_ref.to_string(),
side: Side::New,
});
}
tasks.reverse();
let total = tasks.len();
let queue = Arc::new(Mutex::new(tasks));
let (tx, rx) = mpsc::channel::<FetchEvent>();
let worker_count = PR_FETCH_CONCURRENCY.min(total);
let mut handles = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
let queue = Arc::clone(&queue);
let tx = tx.clone();
handles.push(thread::spawn(move || loop {
let task = { queue.lock().unwrap().pop() };
let Some(task) = task else { break };
let _ = tx.send(FetchEvent::Started(task.filename.clone()));
let content = fetch_file_content_from_github(&task.repo, &task.git_ref, &task.filename);
let _ = tx.send(FetchEvent::Finished {
idx: task.idx,
side: task.side,
filename: task.filename,
content,
});
}));
}
drop(tx);
let mut contents: Vec<(String, String)> = vec![(String::new(), String::new()); n];
let mut done = 0usize;
let mut in_flight: Vec<String> = Vec::new();
let mut last_finished: Option<String> = None;
while let Ok(ev) = rx.recv() {
match ev {
FetchEvent::Started(name) => {
in_flight.push(name);
}
FetchEvent::Finished {
idx,
side,
filename,
content,
} => {
if let Some(pos) = in_flight.iter().position(|f| f == &filename) {
in_flight.swap_remove(pos);
}
match side {
Side::Old => contents[idx].0 = content,
Side::New => contents[idx].1 = content,
}
done += 1;
last_finished = Some(filename);
}
}
spinner.update_text(format_fetch_progress(done, total, &in_flight, last_finished.as_deref()));
}
for h in handles {
let _ = h.join();
}
contents
}
fn format_fetch_progress(
done: usize,
total: usize,
in_flight: &[String],
last_finished: Option<&str>,
) -> String {
let current = if let Some(name) = in_flight.last() {
name.as_str()
} else if let Some(name) = last_finished {
name
} else {
""
};
if current.is_empty() {
format!("Fetching files [{}/{}]", done, total)
} else {
format!("Fetching files [{}/{}] · {}", done, total, current)
}
}
fn fetch_file_content_from_github(repo: &str, git_ref: &str, path: &str) -> String {
let api_path = format!("repos/{}/contents/{}?ref={}", repo, path, git_ref);
let output = Command::new("gh")
.args([
"api",
&api_path,
"-H",
"Accept: application/vnd.github.raw+json",
])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => String::new(),
}
}
fn parse_changed_files_from_diff(diff: &str) -> Vec<String> {
let mut files = Vec::new();
for line in diff.lines() {
if line.starts_with("diff --git") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let b_path = parts[3];
if let Some(filename) = b_path.strip_prefix("b/") {
files.push(filename.to_string());
} else {
files.push(b_path.to_string());
}
}
}
}
files
}
pub fn load_single_commit_diffs(
commit_id: &str,
file_filter: &Option<Vec<String>>,
backend: &dyn VcsBackend,
) -> Vec<FileDiff> {
let files = backend.get_changed_files(commit_id).unwrap_or_default();
let files: Vec<String> = if let Some(ref filter) = file_filter {
files.into_iter().filter(|f| filter.contains(f)).collect()
} else {
files
};
let parent_ref = backend
.get_parent_ref_or_empty(commit_id)
.unwrap_or_default();
files
.into_iter()
.map(|filename| {
let path = Path::new(&filename);
let old_content = if parent_ref.is_empty() {
String::new()
} else {
backend
.get_file_content_at_ref(&parent_ref, path)
.unwrap_or_default()
};
let new_content = backend
.get_file_content_at_ref(commit_id, path)
.unwrap_or_default();
let status = if old_content.is_empty() && !new_content.is_empty() {
FileStatus::Added
} else if !old_content.is_empty() && new_content.is_empty() {
FileStatus::Deleted
} else {
FileStatus::Modified
};
let is_binary =
is_binary_content(&old_content) || is_binary_content(&new_content);
FileDiff {
filename,
old_content,
new_content,
status,
is_binary,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vcs::test_utils::{git, make_temp_dir, RepoGuard};
use crate::vcs::GitBackend;
use std::fs;
#[test]
fn test_load_file_diffs_working_tree_untracked_in_new_dir() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-diff-wt-untracked-dir");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("existing.txt"), "existing content\n").expect("write existing");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "initial"]);
fs::create_dir_all(dir.join("new_dir")).expect("create new dir");
fs::write(dir.join("new_dir/file1.txt"), "file 1\n").expect("write file1");
fs::write(dir.join("new_dir/file2.txt"), "file 2\n").expect("write file2");
std::env::set_current_dir(&dir).expect("set cwd");
let backend = GitBackend::from_cwd().expect("should open repo");
let options = super::super::DiffOptions {
reference: None,
pr: None,
file: None,
watch: false,
theme: None,
stacked: false,
focus: None,
origin: None,
wrap: false,
};
let diffs = load_file_diffs(&options, &backend);
let filenames: Vec<&str> = diffs.iter().map(|d| d.filename.as_str()).collect();
assert!(
diffs.iter().any(|d| d.filename == "new_dir/file1.txt"),
"should include new_dir/file1.txt, got: {:?}",
filenames
);
assert!(
diffs.iter().any(|d| d.filename == "new_dir/file2.txt"),
"should include new_dir/file2.txt, got: {:?}",
filenames
);
let file1 = diffs
.iter()
.find(|d| d.filename == "new_dir/file1.txt")
.unwrap();
assert_eq!(file1.status, FileStatus::Added);
assert!(file1.old_content.is_empty());
assert_eq!(file1.new_content, "file 1\n");
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_file_diffs_range_to_working_tree() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-diff-range-to-wt");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("base.txt"), "base\n").expect("write base");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "base"]);
fs::write(dir.join("committed.txt"), "committed\n").expect("write committed");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "add committed"]);
fs::write(dir.join("uncommitted.txt"), "uncommitted\n").expect("write uncommitted");
fs::write(dir.join("base.txt"), "base modified\n").expect("modify base");
std::env::set_current_dir(&dir).expect("set cwd");
let backend = crate::vcs::GitBackend::from_cwd().expect("should open repo");
let options = super::super::DiffOptions {
reference: Some(crate::commit_reference::CommitReference::RangeToWorkingTree {
from: "HEAD~1".to_string(),
}),
pr: None,
file: None,
watch: false,
theme: None,
stacked: false,
focus: None,
origin: None,
wrap: false,
};
let diffs = load_file_diffs(&options, &backend);
let filenames: Vec<&str> = diffs.iter().map(|d| d.filename.as_str()).collect();
assert!(
filenames.contains(&"committed.txt"),
"should include committed file, got: {:?}",
filenames
);
assert!(
filenames.contains(&"uncommitted.txt"),
"should include untracked working tree file, got: {:?}",
filenames
);
assert!(
filenames.contains(&"base.txt"),
"should include modified working tree file, got: {:?}",
filenames
);
let base = diffs.iter().find(|d| d.filename == "base.txt").unwrap();
assert_eq!(base.old_content, "base\n");
assert_eq!(base.new_content, "base modified\n");
let committed = diffs.iter().find(|d| d.filename == "committed.txt").unwrap();
assert_eq!(committed.old_content, "");
assert_eq!(committed.new_content, "committed\n");
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_single_commit_diffs_added_file() {
let _repo = RepoGuard::new();
let backend = GitBackend::from_cwd().expect("should open repo");
let diffs = load_single_commit_diffs("HEAD", &None, &backend);
assert_eq!(diffs.len(), 1, "should have 1 file diff");
assert_eq!(diffs[0].filename, "README.md");
assert_eq!(diffs[0].status, FileStatus::Added);
assert!(
diffs[0].old_content.is_empty(),
"old content should be empty for added file"
);
assert_eq!(diffs[0].new_content.trim(), "hello");
}
#[test]
fn test_load_single_commit_diffs_modified_file() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-diff-modified");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("file.txt"), "original content\n").expect("write file");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "first"]);
fs::write(dir.join("file.txt"), "modified content\n").expect("modify file");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "second"]);
std::env::set_current_dir(&dir).expect("set cwd");
let backend = GitBackend::from_cwd().expect("should open repo");
let diffs = load_single_commit_diffs("HEAD", &None, &backend);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].filename, "file.txt");
assert_eq!(diffs[0].status, FileStatus::Modified);
assert_eq!(diffs[0].old_content, "original content\n");
assert_eq!(diffs[0].new_content, "modified content\n");
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_single_commit_diffs_multiple_files() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-diff-multi");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("a.txt"), "file a\n").expect("write a");
fs::write(dir.join("b.txt"), "file b\n").expect("write b");
fs::write(dir.join("c.txt"), "file c\n").expect("write c");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "multi"]);
std::env::set_current_dir(&dir).expect("set cwd");
let backend = GitBackend::from_cwd().expect("should open repo");
let diffs = load_single_commit_diffs("HEAD", &None, &backend);
assert_eq!(diffs.len(), 3, "should have 3 file diffs");
let filenames: Vec<&str> = diffs.iter().map(|d| d.filename.as_str()).collect();
assert!(filenames.contains(&"a.txt"));
assert!(filenames.contains(&"b.txt"));
assert!(filenames.contains(&"c.txt"));
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_load_single_commit_diffs_with_filter() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-diff-filter");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("wanted.txt"), "wanted\n").expect("write wanted");
fs::write(dir.join("unwanted.txt"), "unwanted\n").expect("write unwanted");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "filter test"]);
std::env::set_current_dir(&dir).expect("set cwd");
let backend = GitBackend::from_cwd().expect("should open repo");
let filter = Some(vec!["wanted.txt".to_string()]);
let diffs = load_single_commit_diffs("HEAD", &filter, &backend);
assert_eq!(diffs.len(), 1, "filter should limit to 1 file");
assert_eq!(diffs[0].filename, "wanted.txt");
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_stacked_diff_integration_git() {
let _lock = crate::vcs::test_utils::cwd_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = make_temp_dir("git-stacked-integration");
let original = std::env::current_dir().expect("get cwd");
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "user.name", "Test User"]);
fs::write(dir.join("base.txt"), "base\n").expect("write base");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "base"]);
fs::write(dir.join("a.txt"), "commit A\n").expect("write a");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "commit A"]);
fs::write(dir.join("b.txt"), "commit B\n").expect("write b");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "commit B"]);
std::env::set_current_dir(&dir).expect("set cwd");
let backend = GitBackend::from_cwd().expect("should open repo");
let commits = backend
.get_commits_in_range("HEAD~2", "HEAD")
.expect("should get commits");
assert_eq!(commits.len(), 2, "should have 2 commits");
assert_eq!(commits[0].summary, "commit A");
assert_eq!(commits[1].summary, "commit B");
let diffs_a = load_single_commit_diffs(&commits[0].commit_id, &None, &backend);
assert_eq!(diffs_a.len(), 1);
assert_eq!(diffs_a[0].filename, "a.txt");
assert_eq!(diffs_a[0].new_content, "commit A\n");
let diffs_b = load_single_commit_diffs(&commits[1].commit_id, &None, &backend);
assert_eq!(diffs_b.len(), 1);
assert_eq!(diffs_b[0].filename, "b.txt");
assert_eq!(diffs_b[0].new_content, "commit B\n");
let _ = std::env::set_current_dir(&original);
let _ = fs::remove_dir_all(&dir);
}
}