use std::io::{self, IsTerminal, Write};
use std::path::Path;
use serde::Serialize;
use crate::context::{open_repo, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
use void_core::cid;
use void_core::diff::{
content_diff_commits, content_diff_index, content_diff_staged, content_diff_working,
diff_commits, diff_index, diff_staged, diff_working, ContentDiff, DiffKind, TreeDiff,
};
use void_core::index::read_index;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileDiffOutput {
pub path: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rename_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rename_similarity: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub too_large: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct DiffStats {
pub added: usize,
pub modified: usize,
pub deleted: usize,
pub renamed: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffOutput {
pub files: Vec<FileDiffOutput>,
pub stats: DiffStats,
}
impl From<TreeDiff> for DiffOutput {
fn from(diff: TreeDiff) -> Self {
let mut stats = DiffStats::default();
let files = diff
.files
.into_iter()
.map(|f| {
let (kind, rename_from, rename_similarity) = match &f.kind {
DiffKind::Added => {
stats.added += 1;
("added".to_string(), None, None)
}
DiffKind::Modified => {
stats.modified += 1;
("modified".to_string(), None, None)
}
DiffKind::Deleted => {
stats.deleted += 1;
("deleted".to_string(), None, None)
}
DiffKind::Renamed { from, similarity } => {
stats.renamed += 1;
("renamed".to_string(), Some(from.clone()), Some(*similarity))
}
};
FileDiffOutput {
path: f.path,
kind,
old_hash: f.old_hash.map(hex::encode),
new_hash: f.new_hash.map(hex::encode),
rename_from,
rename_similarity,
binary: None,
too_large: None,
patch: None,
}
})
.collect();
DiffOutput { files, stats }
}
}
mod colors {
pub const GREEN: &str = "\x1b[32m";
pub const RED: &str = "\x1b[31m";
pub const BLUE: &str = "\x1b[34m";
pub const YELLOW: &str = "\x1b[33m";
pub const CYAN: &str = "\x1b[36m";
pub const BOLD: &str = "\x1b[1m";
pub const RESET: &str = "\x1b[0m";
}
fn format_stat_line(file: &FileDiffOutput, use_color: bool) -> String {
let (prefix, color) = match file.kind.as_str() {
"added" => ("+", colors::GREEN),
"modified" => ("M", colors::BLUE),
"deleted" => ("-", colors::RED),
"renamed" => ("R", colors::YELLOW),
_ => ("?", colors::RESET),
};
if use_color {
if file.kind == "renamed" {
let from = file.rename_from.as_deref().unwrap_or("?");
format!(
"{}{}{} {} (from {})",
color,
prefix,
colors::RESET,
file.path,
from
)
} else {
format!("{}{}{} {}", color, prefix, colors::RESET, file.path)
}
} else if file.kind == "renamed" {
let from = file.rename_from.as_deref().unwrap_or("?");
format!("{} {} (from {})", prefix, file.path, from)
} else {
format!("{} {}", prefix, file.path)
}
}
fn format_stat_summary(stats: &DiffStats) -> Option<String> {
let mut parts = Vec::new();
if stats.added > 0 {
parts.push(format!("{} added", stats.added));
}
if stats.modified > 0 {
parts.push(format!("{} modified", stats.modified));
}
if stats.deleted > 0 {
parts.push(format!("{} deleted", stats.deleted));
}
if stats.renamed > 0 {
parts.push(format!("{} renamed", stats.renamed));
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
fn print_stat_summary(output: &DiffOutput, use_color: bool) {
let stdout = io::stdout();
let mut handle = stdout.lock();
for file in &output.files {
let line = format_stat_line(file, use_color);
let _ = writeln!(handle, " {}", line);
}
if let Some(summary) = format_stat_summary(&output.stats) {
let _ = writeln!(handle);
let _ = writeln!(handle, " {}", summary);
}
}
fn format_patch(diff: &ContentDiff) -> String {
let old_path = diff.rename_from.as_deref().unwrap_or(&diff.path);
let new_path = &diff.path;
let is_add = matches!(diff.kind, DiffKind::Added);
let is_del = matches!(diff.kind, DiffKind::Deleted);
let mut out = String::new();
if is_add {
out.push_str("--- /dev/null\n");
} else {
out.push_str(&format!("--- a/{}\n", old_path));
}
if is_del {
out.push_str("+++ /dev/null\n");
} else {
out.push_str(&format!("+++ b/{}\n", new_path));
}
for hunk in &diff.hunks {
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
));
for line in &hunk.lines {
if line.tag == ' ' {
out.push_str(&format!(" {}\n", line.content));
} else {
out.push_str(&format!("{}{}\n", line.tag, line.content));
}
}
}
out
}
fn print_unified_diff(diffs: &[ContentDiff], use_color: bool) {
let stdout = io::stdout();
let mut handle = stdout.lock();
for diff in diffs {
let old_path = diff.rename_from.as_deref().unwrap_or(&diff.path);
let new_path = &diff.path;
let is_add = matches!(diff.kind, DiffKind::Added);
let is_del = matches!(diff.kind, DiffKind::Deleted);
if use_color {
let _ = writeln!(
handle,
"{}diff --void a/{} b/{}{}",
colors::BOLD,
old_path,
new_path,
colors::RESET
);
} else {
let _ = writeln!(handle, "diff --void a/{} b/{}", old_path, new_path);
}
if diff.rename_from.is_some() {
let _ = writeln!(handle, "rename from {}", old_path);
let _ = writeln!(handle, "rename to {}", new_path);
}
if diff.binary {
let _ = writeln!(handle, "Binary files differ");
continue;
}
if diff.too_large {
let _ = writeln!(handle, "File too large to diff");
continue;
}
let old_label = if is_add {
"/dev/null".to_string()
} else {
format!("a/{}", old_path)
};
let new_label = if is_del {
"/dev/null".to_string()
} else {
format!("b/{}", new_path)
};
if use_color {
let _ = writeln!(handle, "{}--- {}{}", colors::BOLD, old_label, colors::RESET);
let _ = writeln!(handle, "{}+++ {}{}", colors::BOLD, new_label, colors::RESET);
} else {
let _ = writeln!(handle, "--- {}", old_label);
let _ = writeln!(handle, "+++ {}", new_label);
}
for hunk in &diff.hunks {
let header = format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
);
if use_color {
let _ = writeln!(handle, "{}{}{}", colors::CYAN, header, colors::RESET);
} else {
let _ = writeln!(handle, "{}", header);
}
for line in &hunk.lines {
match (use_color, line.tag) {
(true, '+') => {
let _ = writeln!(
handle,
"{}+{}{}",
colors::GREEN,
line.content,
colors::RESET
);
}
(true, '-') => {
let _ = writeln!(
handle,
"{}-{}{}",
colors::RED,
line.content,
colors::RESET
);
}
(_, ' ') => {
let _ = writeln!(handle, " {}", line.content);
}
(_, tag) => {
let _ = writeln!(handle, "{}{}", tag, line.content);
}
}
}
}
}
}
pub fn run(
cwd: &Path,
commits: Vec<String>,
staged: bool,
stat: bool,
no_color: bool,
opts: &CliOptions,
) -> Result<(), CliError> {
run_command("diff", opts, |ctx| {
ctx.set_prefer_human();
if staged && !commits.is_empty() {
return Err(CliError::invalid_args(
"--staged does not accept commit arguments",
));
}
if commits.len() > 2 {
return Err(CliError::invalid_args("Too many commit arguments (max 2)"));
}
let repo = open_repo(cwd)?;
let use_color = !no_color && io::stdout().is_terminal();
let index = read_index(repo.void_dir().as_std_path(), repo.vault().index_key().map_err(|e| void_err_to_cli(e.into()))?)
.map_err(void_err_to_cli)?;
let store = repo.store().map_err(void_err_to_cli)?;
let tree_diff = match (staged, commits.len()) {
(true, _) => {
ctx.verbose("Computing staged changes (index vs HEAD)...");
diff_staged(
&store,
repo.vault(),
repo.void_dir().as_std_path(),
&index,
)
.map_err(void_err_to_cli)?
}
(false, 0) => {
ctx.verbose("Computing unstaged changes (working tree vs index)...");
diff_index(&index, repo.root().as_std_path()).map_err(void_err_to_cli)?
}
(false, 1) => {
ctx.verbose(&format!(
"Computing changes from {} to working tree...",
commits[0]
));
let commit_cid_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[0])?;
let commit_cid = cid::from_bytes(commit_cid_typed.as_bytes()).map_err(void_err_to_cli)?;
diff_working(
&store,
repo.vault(),
&commit_cid,
repo.root().as_std_path(),
)
.map_err(void_err_to_cli)?
}
(false, 2) => {
ctx.verbose(&format!(
"Computing changes from {} to {}...",
commits[0], commits[1]
));
let old_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[0])?;
let new_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[1])?;
let old_cid = cid::from_bytes(old_typed.as_bytes()).map_err(void_err_to_cli)?;
let new_cid = cid::from_bytes(new_typed.as_bytes()).map_err(void_err_to_cli)?;
diff_commits(&store, repo.vault(), Some(&old_cid), &new_cid)
.map_err(void_err_to_cli)?
}
_ => unreachable!(),
};
let content_diffs = if !stat && !tree_diff.is_empty() {
let result = match (staged, commits.len()) {
(true, _) => content_diff_staged(
&tree_diff,
repo.context(),
)
.map_err(void_err_to_cli)?,
(false, 0) => content_diff_index(
&tree_diff,
&index,
repo.root().as_std_path(),
repo.void_dir().as_std_path(),
repo.vault().staged_key().map_err(|e| void_err_to_cli(e.into()))?,
)
.map_err(void_err_to_cli)?,
(false, 1) => {
let commit_cid_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[0])?;
let commit_cid = cid::from_bytes(commit_cid_typed.as_bytes()).map_err(void_err_to_cli)?;
content_diff_working(
&tree_diff,
repo.context(),
&commit_cid,
repo.root().as_std_path(),
)
.map_err(void_err_to_cli)?
}
(false, 2) => {
let old_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[0])?;
let new_typed = resolve_ref(repo.void_dir().as_std_path(), &commits[1])?;
let old_cid = cid::from_bytes(old_typed.as_bytes()).map_err(void_err_to_cli)?;
let new_cid = cid::from_bytes(new_typed.as_bytes()).map_err(void_err_to_cli)?;
content_diff_commits(
&tree_diff,
repo.context(),
&old_cid,
&new_cid,
)
.map_err(void_err_to_cli)?
}
_ => unreachable!(),
};
Some(result)
} else {
None
};
let mut output: DiffOutput = tree_diff.into();
if let Some(ref diffs) = content_diffs {
for content_diff in diffs {
if let Some(file_out) = output
.files
.iter_mut()
.find(|f| f.path == content_diff.path)
{
if content_diff.binary {
file_out.binary = Some(true);
} else if content_diff.too_large {
file_out.too_large = Some(true);
} else if !content_diff.hunks.is_empty() {
file_out.patch = Some(format_patch(content_diff));
}
}
}
}
if !ctx.use_json() {
if output.files.is_empty() {
if staged {
ctx.info("No staged changes");
} else if commits.is_empty() {
ctx.info("No unstaged changes");
} else {
ctx.info("No changes");
}
} else if stat {
print_stat_summary(&output, use_color);
} else if let Some(ref diffs) = content_diffs {
print_unified_diff(diffs, use_color);
}
}
Ok(output)
})
}
#[cfg(test)]
mod tests {
use super::*;
use void_core::ContentHash;
use void_core::diff::{DiffKind, FileDiff, TreeDiff};
#[test]
fn test_diff_output_from_tree_diff() {
let tree_diff = TreeDiff {
files: vec![
FileDiff {
path: "new_file.rs".to_string(),
kind: DiffKind::Added,
old_hash: None,
new_hash: Some(ContentHash([0u8; 32])),
},
FileDiff {
path: "changed.rs".to_string(),
kind: DiffKind::Modified,
old_hash: Some(ContentHash([1u8; 32])),
new_hash: Some(ContentHash([2u8; 32])),
},
FileDiff {
path: "removed.rs".to_string(),
kind: DiffKind::Deleted,
old_hash: Some(ContentHash([3u8; 32])),
new_hash: None,
},
FileDiff {
path: "new_name.rs".to_string(),
kind: DiffKind::Renamed {
from: "old_name.rs".to_string(),
similarity: 95,
},
old_hash: Some(ContentHash([4u8; 32])),
new_hash: Some(ContentHash([4u8; 32])),
},
],
};
let output: DiffOutput = tree_diff.into();
assert_eq!(output.files.len(), 4);
assert_eq!(output.stats.added, 1);
assert_eq!(output.stats.modified, 1);
assert_eq!(output.stats.deleted, 1);
assert_eq!(output.stats.renamed, 1);
assert_eq!(output.files[0].path, "new_file.rs");
assert_eq!(output.files[0].kind, "added");
assert!(output.files[0].old_hash.is_none());
assert!(output.files[0].new_hash.is_some());
assert!(output.files[0].rename_from.is_none());
assert_eq!(output.files[1].path, "changed.rs");
assert_eq!(output.files[1].kind, "modified");
assert!(output.files[1].old_hash.is_some());
assert!(output.files[1].new_hash.is_some());
assert_eq!(output.files[2].path, "removed.rs");
assert_eq!(output.files[2].kind, "deleted");
assert!(output.files[2].old_hash.is_some());
assert!(output.files[2].new_hash.is_none());
assert_eq!(output.files[3].path, "new_name.rs");
assert_eq!(output.files[3].kind, "renamed");
assert_eq!(output.files[3].rename_from, Some("old_name.rs".to_string()));
assert_eq!(output.files[3].rename_similarity, Some(95));
}
#[test]
fn test_format_stat_line_no_color() {
let added = FileDiffOutput {
path: "src/new.rs".to_string(),
kind: "added".to_string(),
old_hash: None,
new_hash: Some("abc".to_string()),
rename_from: None,
rename_similarity: None,
binary: None,
too_large: None,
patch: None,
};
assert_eq!(format_stat_line(&added, false), "+ src/new.rs");
let modified = FileDiffOutput {
path: "src/changed.rs".to_string(),
kind: "modified".to_string(),
old_hash: Some("old".to_string()),
new_hash: Some("new".to_string()),
rename_from: None,
rename_similarity: None,
binary: None,
too_large: None,
patch: None,
};
assert_eq!(format_stat_line(&modified, false), "M src/changed.rs");
let deleted = FileDiffOutput {
path: "src/removed.rs".to_string(),
kind: "deleted".to_string(),
old_hash: Some("old".to_string()),
new_hash: None,
rename_from: None,
rename_similarity: None,
binary: None,
too_large: None,
patch: None,
};
assert_eq!(format_stat_line(&deleted, false), "- src/removed.rs");
let renamed = FileDiffOutput {
path: "new_name.rs".to_string(),
kind: "renamed".to_string(),
old_hash: Some("hash".to_string()),
new_hash: Some("hash".to_string()),
rename_from: Some("old_name.rs".to_string()),
rename_similarity: Some(100),
binary: None,
too_large: None,
patch: None,
};
assert_eq!(
format_stat_line(&renamed, false),
"R new_name.rs (from old_name.rs)"
);
}
#[test]
fn test_format_stat_summary() {
let stats = DiffStats {
added: 2,
modified: 1,
deleted: 0,
renamed: 1,
};
assert_eq!(
format_stat_summary(&stats),
Some("2 added, 1 modified, 1 renamed".to_string())
);
let empty_stats = DiffStats::default();
assert_eq!(format_stat_summary(&empty_stats), None);
}
#[test]
fn test_empty_diff_output() {
let tree_diff = TreeDiff { files: vec![] };
let output: DiffOutput = tree_diff.into();
assert!(output.files.is_empty());
assert_eq!(output.stats.added, 0);
assert_eq!(output.stats.modified, 0);
assert_eq!(output.stats.deleted, 0);
assert_eq!(output.stats.renamed, 0);
}
}