use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use anyhow::{Result, anyhow};
use objects::{
object::{
AnnotationStatus, Blob, ChangeId, ContextTarget, DiffKind, EntryType, FileChangeSet,
FileMode, State, Tree, TreeEntry,
},
store::ObjectStore,
worktree::diff_blobs,
};
use repo::Repository;
use sley::{EntryKind, Repository as SleyRepository};
#[cfg(not(feature = "semantic"))]
use super::super::advice::RecoveryAdvice;
use super::{
super::{
git_overlay_health::{
PlainGitVerificationProbe, build_plain_git_verification_probe,
build_repository_verification_state, plain_git_setup_advice,
trust_visible_worktree_status,
},
history_target::{require_resolved_state, resolve_state_id},
},
diff_output::{
print_context, print_diff, print_diff_patch, print_semantic_changes, print_stat,
render_diff_patch,
},
diff_types::{
ContextSnippet, DiffOutput, DiffStats, FileChange, FileContextEntry, FileEolState,
LineDiff, SemanticChangeEntry, SymlinkChange, change_line_counts,
},
};
#[cfg(feature = "semantic")]
use crate::semantic::{
SemanticDiffOptions, SemanticDiffResult, semantic_diff, semantic_diff_worktree,
};
use crate::{
cli::{Cli, should_output_json, worktree_status_options},
config::UserConfig,
};
const BINARY_DIFF_ERROR: &str = "binary file";
#[cfg(not(feature = "semantic"))]
struct SemanticDiffResult {
changes: Vec<objects::object::SemanticChange>,
file_changes: FileChangeSet,
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_diff(
cli: &Cli,
from: Option<String>,
to: Option<String>,
semantic: bool,
stat: bool,
name_only: bool,
unified: usize,
show_context: bool,
patch: bool,
) -> Result<()> {
let cwd = std::env::current_dir()?;
let start = cli.repo.as_ref().unwrap_or(&cwd);
let from_is_head_or_default = from
.as_deref()
.map(|spec| matches!(spec, "HEAD" | "@"))
.unwrap_or(true);
if to.is_none()
&& from_is_head_or_default
&& let Some(probe) = build_plain_git_verification_probe(start)?
{
if probe.changes.is_clean() {
return Err(anyhow!(plain_git_setup_advice(&probe, "diff", None)));
}
return render_plain_git_head_diff(cli, &probe, stat, name_only, patch, unified);
}
let repo = Repository::open(start)?;
let trust = build_repository_verification_state(&repo);
if to.is_none()
&& from_is_head_or_default
&& let Some(status) = trust_visible_worktree_status(&repo, &trust)?
{
return render_worktree_status_diff(
cli,
&status,
stat,
name_only,
true,
patch,
unified,
Some(&repo),
);
}
let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
&& to.is_none()
&& matches!(from.as_deref(), Some("HEAD" | "@"));
if !git_overlay_head_worktree_diff
&& repo.current_state()?.is_none()
&& (matches!(from.as_deref(), Some("HEAD" | "@"))
|| matches!(to.as_deref(), Some("HEAD" | "@")))
{
crate::cli::commands::snapshot::ensure_current_state(
&repo,
&UserConfig::load_default().unwrap_or_default(),
Some("Bootstrap git-overlay before diffing HEAD".to_string()),
)?;
}
let from_id = if git_overlay_head_worktree_diff {
None
} else if let Some(ref spec) = from {
Some(resolve_state_id(&repo, spec)?)
} else {
repo.head()?
};
let from_state = if let Some(id) = from_id {
Some(require_resolved_state(&repo, &id)?)
} else {
None
};
let from_tree = if let Some(ref state) = from_state {
repo.store().get_tree(&state.tree)?
} else {
None
};
let status_options = worktree_status_options(Some(repo.config()));
let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
#[cfg(not(feature = "semantic"))]
{
return Err(anyhow!(RecoveryAdvice::feature_unavailable(
"semantic diff",
"semantic"
)));
}
#[cfg(feature = "semantic")]
{
let options = SemanticDiffOptions::default();
if let Some(ref to_spec) = to {
let to_id = resolve_state_id(&repo, to_spec)?;
let to_state = require_resolved_state(&repo, &to_id)?;
let from_hash = from_state
.as_ref()
.map(|s| s.tree)
.unwrap_or_else(|| Tree::new().hash());
Some(semantic_diff(&repo, &from_hash, &to_state.tree, &options)?)
} else {
let from_hash = from_state
.as_ref()
.map(|s| s.tree)
.unwrap_or_else(|| Tree::new().hash());
Some(semantic_diff_worktree(
&repo,
&from_hash,
&options,
&status_options,
)?)
}
}
} else {
None
};
let mut to_tree: Option<Tree> = None;
if let Some(ref to_spec) = to {
let to_id = resolve_state_id(&repo, to_spec)?;
let to_state = require_resolved_state(&repo, &to_id)?;
to_tree = repo.store().get_tree(&to_state.tree)?;
}
let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
result.file_changes.clone()
} else if let Some(ref to_spec) = to {
let to_id = resolve_state_id(&repo, to_spec)?;
let to_state = require_resolved_state(&repo, &to_id)?;
let from_hash = from_state
.as_ref()
.map(|s| s.tree)
.unwrap_or_else(|| Tree::new().hash());
repo.diff_trees(&from_hash, &to_state.tree)?
} else if git_overlay_head_worktree_diff {
let status = repo.git_overlay_worktree_status()?.unwrap_or_default();
let mut changes = FileChangeSet::with_capacity(status.change_count());
for path in status.modified {
changes.push_modified(path.display().to_string());
}
for path in status.added {
changes.push_added(path.display().to_string());
}
for path in status.deleted {
changes.push_deleted(path.display().to_string());
}
changes
} else {
let tree = from_tree.clone().unwrap_or_default();
let status = repo.compare_worktree_cached_with_options(&tree, &status_options)?;
let mut changes = FileChangeSet::with_capacity(status.change_count());
for path in status.modified {
changes.push_modified(path.display().to_string());
}
for path in status.added {
changes.push_added(path.display().to_string());
}
for path in status.deleted {
changes.push_deleted(path.display().to_string());
}
changes
};
let json = should_output_json(cli, Some(repo.config()));
let patch_text_needed = patch || json;
let want_hunks = patch_text_needed || !(name_only || stat);
let file_changes: Vec<FileChange> = if name_only && !patch_text_needed {
changes
.iter()
.map(|change| {
make_status_only_change(
Some(&repo),
from_tree.as_ref(),
to_tree.as_ref(),
&change.path,
&change.kind.to_string(),
)
})
.collect()
} else {
changes
.iter()
.map(|change| {
let effective_kind = if to_tree.is_none() {
worktree_modified_type_change(repo.root(), &change.path, change.kind)
.map(|(_, diff_kind)| diff_kind)
.unwrap_or(change.kind)
} else {
change.kind
};
let diff_result = if let Some(ref tree) = to_tree {
get_state_diff(
&repo,
from_tree.as_ref(),
tree,
&change.path,
&effective_kind,
)
} else {
get_worktree_diff(&repo, from_tree.as_ref(), &change.path, &effective_kind)
};
let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
let (raw_lines, eol) = match diff_result {
Ok((lines, eol)) => (Some(lines), eol),
Err(_) => (None, FileEolState::default()),
};
let (lines, line_counts) = if stat && !patch_text_needed {
let counts = change_line_counts(raw_lines.as_deref());
(None, Some(counts))
} else {
(
raw_lines.map(|lines| unified_hunks(lines, unified, &eol)),
None,
)
};
let kind = effective_kind.to_string();
let (old_mode, mode) = change_file_modes(
&repo,
from_tree.as_ref(),
to_tree.as_ref(),
&change.path,
&kind,
);
let symlink = symlink_change_for_paths(
&repo,
from_tree.as_ref(),
to_tree.as_ref(),
&kind,
&change.path,
&change.path,
old_mode,
mode,
);
FileChange {
path: change.path.clone(),
kind,
binary: binary && symlink.is_none(),
lines,
line_counts,
eol,
mode,
old_mode,
symlink,
..Default::default()
}
})
.collect()
};
let file_changes = sort_changes_by_path(file_changes);
let file_changes = expand_type_changes(
&repo,
from_tree.as_ref(),
to_tree.as_ref(),
file_changes,
want_hunks,
unified,
)?;
let file_changes = detect_clear_renames(
&repo,
from_tree.as_ref(),
to_tree.as_ref(),
file_changes,
want_hunks,
unified,
)?;
let semantic_changes = semantic_diff_result.map(|r| {
r.changes
.into_iter()
.map(SemanticChangeEntry::from)
.collect()
});
let context_state = if show_context {
if let Some(ref to_spec) = to {
let to_id = resolve_state_id(&repo, to_spec)?;
Some(require_resolved_state(&repo, &to_id)?)
} else if let Some(state) = from_state.clone() {
Some(state)
} else {
repo.current_state()?
}
} else {
None
};
let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
let mut output = DiffOutput::with_stats(
from_id.map(|id| id.short()),
to.clone(),
file_changes,
semantic_changes,
context_state
.as_ref()
.map(|state| collect_file_context(&repo, state, &changes))
.transpose()?,
context_state
.as_ref()
.map(|state| collect_state_guidance(&repo, state))
.transpose()?,
stats,
);
populate_patch_text(&mut output);
if stat {
output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
}
if json {
if to.is_none() {
println!("{}", worktree_diff_json_string(&output)?);
} else {
println!("{}", serde_json::to_string(&output)?);
}
} else if name_only {
for change in &output.changes {
println!("{}", change.path);
}
} else if stat {
print_stat(&output);
} else if patch {
print_diff_patch(&output);
} else {
if show_context {
print_context(&output);
}
print_diff(&output);
if let Some(ref semantic) = output.semantic_changes {
print_semantic_changes(semantic);
}
}
Ok(())
}
fn populate_patch_text(output: &mut DiffOutput) {
let text = render_diff_patch(output);
if !text.is_empty() {
output.patch = Some(text);
}
}
fn worktree_diff_json_string(output: &DiffOutput) -> Result<String> {
let mut value = serde_json::to_value(output)?;
if let serde_json::Value::Object(map) = &mut value {
map.insert(
"changes".to_string(),
group_changes_by_category(&output.changes),
);
}
Ok(serde_json::to_string(&value)?)
}
fn group_changes_by_category(changes: &[FileChange]) -> serde_json::Value {
let mut modified = Vec::new();
let mut added = Vec::new();
let mut deleted = Vec::new();
for change in changes {
let entry = serde_json::to_value(change).unwrap_or(serde_json::Value::Null);
match change.kind.as_str() {
"added" => added.push(entry),
"deleted" => deleted.push(entry),
_ => modified.push(entry),
}
}
let mut map = serde_json::Map::new();
map.insert("modified".to_string(), serde_json::Value::Array(modified));
map.insert("added".to_string(), serde_json::Value::Array(added));
map.insert("deleted".to_string(), serde_json::Value::Array(deleted));
serde_json::Value::Object(map)
}
fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
changes.sort_by(|a, b| a.path.cmp(&b.path));
changes
}
fn render_plain_git_head_diff(
cli: &Cli,
probe: &PlainGitVerificationProbe,
stat: bool,
name_only: bool,
patch: bool,
unified: usize,
) -> Result<()> {
let json = should_output_json(cli, None);
if patch || json {
let changes = plain_git_file_changes_with_hunks(probe, unified)?;
return render_status_changes(cli, changes, stat, name_only, patch);
}
render_worktree_status_diff(
cli,
&probe.changes,
stat,
name_only,
false,
patch,
unified,
None,
)
}
fn render_status_changes(
cli: &Cli,
changes: Vec<FileChange>,
stat: bool,
name_only: bool,
patch: bool,
) -> Result<()> {
let mut output = DiffOutput::new(Some("HEAD".to_string()), None, changes, None, None, None);
populate_patch_text(&mut output);
if stat {
output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
}
if should_output_json(cli, None) {
println!("{}", worktree_diff_json_string(&output)?);
} else if name_only {
for change in &output.changes {
println!("{}", change.path);
}
} else if stat {
print_stat(&output);
} else if patch {
print_diff_patch(&output);
} else {
print_diff(&output);
}
Ok(())
}
fn plain_git_file_changes_with_hunks(
probe: &PlainGitVerificationProbe,
unified: usize,
) -> Result<Vec<FileChange>> {
let git_repo = SleyRepository::discover(&probe.root)?;
let head_has_tree = !git_repo.head()?.is_unborn();
let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
let mut changes = Vec::with_capacity(probe.changes.change_count());
for path in &probe.changes.modified {
push_plain_git_modified(
&git_repo,
head_has_tree,
&probe.root,
path,
unified,
&mut changes,
)?;
}
for path in &probe.changes.added {
if deleted_set.contains(path.as_path()) {
push_plain_git_modified(
&git_repo,
head_has_tree,
&probe.root,
path,
unified,
&mut changes,
)?;
} else {
changes.push(plain_git_file_change(
&git_repo,
head_has_tree,
&probe.root,
path,
"added",
DiffKind::Added,
unified,
)?);
}
}
for path in &probe.changes.deleted {
if added_set.contains(path.as_path()) {
continue;
}
changes.push(plain_git_file_change(
&git_repo,
head_has_tree,
&probe.root,
path,
"deleted",
DiffKind::Deleted,
unified,
)?);
}
Ok(changes)
}
#[allow(clippy::too_many_arguments)]
fn plain_git_file_change(
git_repo: &SleyRepository,
head_has_tree: bool,
root: &Path,
path: &std::path::Path,
kind: &str,
diff_kind: DiffKind,
unified: usize,
) -> Result<FileChange> {
let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
(true, DiffKind::Modified | DiffKind::Deleted) => {
match plain_git_lookup_blob_and_mode(git_repo, path)? {
Some((blob, mode)) => (Some(blob), Some(mode)),
None => (None, None),
}
}
_ => (None, None),
};
let new_blob = match diff_kind {
DiffKind::Added | DiffKind::Modified => {
read_worktree_blob_for_diff(&root.join(path)).ok()
}
_ => None,
};
let (old_mode_field, mode) = match diff_kind {
DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
DiffKind::Deleted => (None, old_mode),
DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
DiffKind::Unchanged => (None, None),
};
let (lines, eol, binary) =
compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
let symlink = symlink_change_from_blobs(
kind,
old_blob.as_ref(),
old_mode_field,
new_blob.as_ref(),
mode,
);
Ok(FileChange {
path: path.display().to_string(),
kind: kind.to_string(),
binary: binary && symlink.is_none(),
lines,
eol,
mode,
old_mode: old_mode_field,
symlink,
..Default::default()
})
}
fn plain_git_lookup_blob_and_mode(
git_repo: &SleyRepository,
path: &std::path::Path,
) -> Result<Option<(Blob, FileMode)>> {
let tree_path = plain_git_tree_path(path);
let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
return Ok(None);
};
let Some(entry_mode) = entry.mode else {
return Ok(None);
};
let mode = match EntryKind::from_mode(entry_mode) {
Some(EntryKind::Symlink) => FileMode::Symlink,
Some(EntryKind::BlobExecutable) => FileMode::Executable,
Some(EntryKind::Blob) => FileMode::Normal,
_ => return Ok(None),
};
let object = git_repo.read_object(&entry.oid)?;
Ok(Some((Blob::new(object.body.clone()), mode)))
}
fn plain_git_tree_path(path: &std::path::Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn plain_git_old_side_kind(
git_repo: &SleyRepository,
head_has_tree: bool,
path: &std::path::Path,
) -> Result<SideKind> {
if !head_has_tree {
return Ok(SideKind::Absent);
}
let tree_path = plain_git_tree_path(path);
let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
return Ok(SideKind::Absent);
};
Ok(match entry.mode.and_then(EntryKind::from_mode) {
Some(EntryKind::Symlink) => SideKind::Symlink,
Some(EntryKind::Tree) => SideKind::Dir,
_ => SideKind::Regular,
})
}
fn push_plain_git_modified(
git_repo: &SleyRepository,
head_has_tree: bool,
root: &Path,
path: &std::path::Path,
unified: usize,
out: &mut Vec<FileChange>,
) -> Result<()> {
let new_kind = worktree_side_kind(&root.join(path));
let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
if is_type_change(old_kind, new_kind) {
out.push(plain_git_file_change(
git_repo,
head_has_tree,
root,
path,
"deleted",
DiffKind::Deleted,
unified,
)?);
if new_kind != SideKind::Dir {
out.push(plain_git_file_change(
git_repo,
head_has_tree,
root,
path,
"added",
DiffKind::Added,
unified,
)?);
}
} else {
out.push(plain_git_file_change(
git_repo,
head_has_tree,
root,
path,
"modified",
DiffKind::Modified,
unified,
)?);
}
Ok(())
}
fn compute_plain_git_hunks(
old: Option<&Blob>,
new: Option<&Blob>,
diff_kind: &DiffKind,
unified: usize,
) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
match diff_kind {
DiffKind::Added => {
let Some(new) = new else {
return Ok((Vec::new(), FileEolState::default()));
};
ensure_text_diffable(new)?;
let eol = eol_for_added(new);
Ok((number_lines(blob_lines(new, "+")?), eol))
}
DiffKind::Deleted => {
let Some(old) = old else {
return Ok((Vec::new(), FileEolState::default()));
};
ensure_text_diffable(old)?;
let eol = eol_for_deleted(old);
Ok((number_lines(blob_lines(old, "-")?), eol))
}
DiffKind::Modified => match (old, new) {
(Some(old), Some(new)) => modified_blob_hunks(old, new),
(None, Some(new)) => {
ensure_text_diffable(new)?;
let eol = eol_for_added(new);
Ok((number_lines(blob_lines(new, "+")?), eol))
}
(Some(old), None) => {
ensure_text_diffable(old)?;
let eol = eol_for_deleted(old);
Ok((number_lines(blob_lines(old, "-")?), eol))
}
(None, None) => Ok((Vec::new(), FileEolState::default())),
},
DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
}
};
match attempt() {
Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
Err(_) => (None, FileEolState::default(), false),
}
}
#[allow(clippy::too_many_arguments)]
fn render_worktree_status_diff(
cli: &Cli,
status: &objects::worktree::WorktreeStatus,
stat: bool,
name_only: bool,
detect_renames: bool,
patch: bool,
unified: usize,
repo: Option<&Repository>,
) -> Result<()> {
let json = should_output_json(cli, None);
let want_hunks = (patch || json) && repo.is_some();
let from_tree = match repo {
Some(repo) => head_from_tree(repo)?,
None => None,
};
let changes = file_changes_from_status(status, want_hunks, repo, from_tree.as_ref(), unified);
let changes = match repo {
Some(repo) => {
expand_type_changes(repo, from_tree.as_ref(), None, changes, want_hunks, unified)?
}
None => changes,
};
let changes = if detect_renames {
detect_clear_renames_for_worktree_status(cli, changes, want_hunks, unified)?
} else {
changes
};
let mut output = DiffOutput::new(Some("HEAD".to_string()), None, changes, None, None, None);
populate_patch_text(&mut output);
if stat {
output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
}
if should_output_json(cli, None) {
println!("{}", worktree_diff_json_string(&output)?);
} else if name_only {
for change in &output.changes {
println!("{}", change.path);
}
} else if stat {
print_stat(&output);
} else if patch {
print_diff_patch(&output);
} else {
print_diff(&output);
}
Ok(())
}
fn file_changes_from_status(
status: &objects::worktree::WorktreeStatus,
want_hunks: bool,
repo: Option<&Repository>,
from_tree: Option<&Tree>,
unified: usize,
) -> Vec<FileChange> {
let mut changes = Vec::with_capacity(status.change_count());
for path in &status.modified {
changes.push(make_status_file_change(
path,
"modified",
DiffKind::Modified,
want_hunks,
repo,
from_tree,
unified,
));
}
for path in &status.added {
changes.push(make_status_file_change(
path,
"added",
DiffKind::Added,
want_hunks,
repo,
from_tree,
unified,
));
}
for path in &status.deleted {
changes.push(make_status_file_change(
path,
"deleted",
DiffKind::Deleted,
want_hunks,
repo,
from_tree,
unified,
));
}
changes
}
#[allow(clippy::too_many_arguments)]
fn make_status_file_change(
path: &std::path::Path,
kind: &str,
diff_kind: DiffKind,
want_hunks: bool,
repo: Option<&Repository>,
from_tree: Option<&Tree>,
unified: usize,
) -> FileChange {
let path_str = path.display().to_string();
let (kind, diff_kind) = match repo
.and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
{
Some(reclassified) => reclassified,
None => (kind, diff_kind),
};
match repo {
Some(repo) if want_hunks => {
build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
}
_ => make_status_only_change(repo, from_tree, None, &path_str, kind),
}
}
fn make_status_only_change(
repo: Option<&Repository>,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
path_str: &str,
kind: &str,
) -> FileChange {
let (old_mode, mode) = match repo {
Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
None => (None, None),
};
FileChange {
path: path_str.to_string(),
kind: kind.to_string(),
mode,
old_mode,
..Default::default()
}
}
fn build_worktree_change(
repo: &Repository,
from_tree: Option<&Tree>,
path_str: &str,
kind: &str,
diff_kind: DiffKind,
unified: usize,
) -> FileChange {
let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
Err(_) => (None, FileEolState::default(), false),
};
let symlink = symlink_change_for_paths(
repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
);
FileChange {
path: path_str.to_string(),
kind: kind.to_string(),
binary: binary && symlink.is_none(),
lines,
eol,
mode,
old_mode,
symlink,
..Default::default()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SideKind {
Absent,
Dir,
Regular,
Symlink,
}
fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
let Some(tree) = tree else {
return Ok(SideKind::Absent);
};
if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
return Ok(if entry.entry_type == EntryType::Symlink {
SideKind::Symlink
} else {
SideKind::Regular
});
}
if dir_subtree_in_tree(repo, tree, path)?.is_some() {
Ok(SideKind::Dir)
} else {
Ok(SideKind::Absent)
}
}
fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
match to_tree {
Some(tree) => tree_side_kind(repo, Some(tree), path),
None => Ok(worktree_side_kind(&repo.root().join(path))),
}
}
fn worktree_side_kind(path: &Path) -> SideKind {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return SideKind::Absent;
};
if meta.file_type().is_symlink() {
SideKind::Symlink
} else if meta.is_dir() {
SideKind::Dir
} else {
SideKind::Regular
}
}
fn is_type_change(old: SideKind, new: SideKind) -> bool {
use SideKind::{Dir, Regular, Symlink};
matches!(
(old, new),
(Dir, Regular)
| (Dir, Symlink)
| (Regular, Dir)
| (Symlink, Dir)
| (Regular, Symlink)
| (Symlink, Regular)
)
}
fn expand_type_changes(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
changes: Vec<FileChange>,
want_hunks: bool,
unified: usize,
) -> Result<Vec<FileChange>> {
let mut output = Vec::with_capacity(changes.len());
for change in changes {
if change.kind != "modified" {
output.push(change);
continue;
}
let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
let new_kind = new_side_kind(repo, to_tree, &change.path)?;
if !is_type_change(old_kind, new_kind) {
output.push(change);
continue;
}
if old_kind == SideKind::Dir {
if let Some(from_tree) = from_tree
&& let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
{
let mut nested = Vec::new();
collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
for nested_path in nested {
output.push(make_type_change_part(
repo,
Some(from_tree),
to_tree,
&nested_path,
DiffKind::Deleted,
want_hunks,
unified,
));
}
}
} else {
output.push(make_type_change_part(
repo,
from_tree,
to_tree,
&change.path,
DiffKind::Deleted,
want_hunks,
unified,
));
}
if new_kind == SideKind::Dir {
if let Some(to_tree) = to_tree
&& let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
{
let mut nested = Vec::new();
collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
for nested_path in nested {
output.push(make_type_change_part(
repo,
from_tree,
Some(to_tree),
&nested_path,
DiffKind::Added,
want_hunks,
unified,
));
}
}
} else {
output.push(make_type_change_part(
repo,
from_tree,
to_tree,
&change.path,
DiffKind::Added,
want_hunks,
unified,
));
}
}
Ok(output)
}
fn make_type_change_part(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
path_str: &str,
diff_kind: DiffKind,
want_hunks: bool,
unified: usize,
) -> FileChange {
let kind = diff_kind.to_string();
if !want_hunks {
return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
}
match to_tree {
Some(to_tree) => build_state_change(
repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
),
None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
}
}
fn build_state_change(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: &Tree,
path_str: &str,
kind: &str,
diff_kind: DiffKind,
unified: usize,
) -> FileChange {
let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
{
Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
Err(_) => (None, FileEolState::default(), false),
};
let symlink = symlink_change_for_paths(
repo,
from_tree,
Some(to_tree),
kind,
path_str,
path_str,
old_mode,
mode,
);
FileChange {
path: path_str.to_string(),
kind: kind.to_string(),
binary: binary && symlink.is_none(),
lines,
eol,
mode,
old_mode,
symlink,
..Default::default()
}
}
fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
let mut current = tree.clone();
let mut parts = path.split('/').peekable();
while let Some(name) = parts.next() {
let Some(entry) = current.get(name) else {
return Ok(None);
};
if !entry.is_tree() {
return Ok(None);
}
let Some(subtree) = repo.store().get_tree(&entry.hash)? else {
return Ok(None);
};
if parts.peek().is_none() {
return Ok(Some(subtree));
}
current = subtree;
}
Ok(None)
}
fn collect_subtree_blob_paths(
repo: &Repository,
subtree: &Tree,
prefix: &str,
out: &mut Vec<String>,
) -> Result<()> {
for entry in subtree.entries() {
let child_path = format!("{prefix}/{}", entry.name);
if entry.is_tree() {
if let Some(nested) = repo.store().get_tree(&entry.hash)? {
collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
}
} else {
out.push(child_path);
}
}
Ok(())
}
fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
let Some(head_id) = repo.head()? else {
return Ok(None);
};
let Some(state) = repo.store().get_state(&head_id)? else {
return Ok(None);
};
Ok(repo.store().get_tree(&state.tree)?)
}
pub fn compute_state_diff(
repo: &Repository,
from_change_id: &ChangeId,
to_change_id: &ChangeId,
semantic: bool,
unified: usize,
) -> Result<DiffOutput> {
let from_state = repo.store().get_state(from_change_id)?;
let from_tree = if let Some(ref state) = from_state {
repo.store().get_tree(&state.tree)?
} else {
None
};
let to_state = require_resolved_state(repo, to_change_id)?;
let to_tree = repo
.store()
.get_tree(&to_state.tree)?
.ok_or_else(|| anyhow!("Tree not found for state {}", to_change_id.short()))?;
let from_hash = from_state
.as_ref()
.map(|s| s.tree)
.unwrap_or_else(|| Tree::new().hash());
let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
#[cfg(not(feature = "semantic"))]
{
return Err(anyhow!(RecoveryAdvice::feature_unavailable(
"semantic diff",
"semantic"
)));
}
#[cfg(feature = "semantic")]
{
let options = SemanticDiffOptions::default();
Some(semantic_diff(repo, &from_hash, &to_state.tree, &options)?)
}
} else {
None
};
let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
result.file_changes.clone()
} else {
repo.diff_trees(&from_hash, &to_state.tree)?
};
let file_changes: Vec<FileChange> = changes
.iter()
.map(|change| {
build_state_change(
repo,
from_tree.as_ref(),
&to_tree,
&change.path,
&change.kind.to_string(),
change.kind,
unified,
)
})
.collect();
let file_changes = sort_changes_by_path(file_changes);
let file_changes = expand_type_changes(
repo,
from_tree.as_ref(),
Some(&to_tree),
file_changes,
true,
unified,
)?;
let file_changes = detect_clear_renames(
repo,
from_tree.as_ref(),
Some(&to_tree),
file_changes,
true,
unified,
)?;
let semantic_changes = semantic_diff_result.map(|r| {
r.changes
.into_iter()
.map(SemanticChangeEntry::from)
.collect()
});
let mut output = DiffOutput::new(
Some(from_change_id.short()),
Some(to_change_id.short()),
file_changes,
semantic_changes,
None,
None,
);
populate_patch_text(&mut output);
Ok(output)
}
pub fn compute_tree_diff(
repo: &Repository,
from_change_id: &ChangeId,
to_tree: &Tree,
to_label: impl Into<String>,
semantic: bool,
unified: usize,
) -> Result<DiffOutput> {
let from_state = repo.store().get_state(from_change_id)?;
let from_tree = if let Some(ref state) = from_state {
repo.store().get_tree(&state.tree)?
} else {
None
};
let from_hash = from_state
.as_ref()
.map(|s| s.tree)
.unwrap_or_else(|| Tree::new().hash());
let to_hash = repo.store().put_tree(to_tree)?;
let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
#[cfg(not(feature = "semantic"))]
{
return Err(anyhow!(RecoveryAdvice::feature_unavailable(
"semantic diff",
"semantic"
)));
}
#[cfg(feature = "semantic")]
{
let options = SemanticDiffOptions::default();
Some(semantic_diff(repo, &from_hash, &to_hash, &options)?)
}
} else {
None
};
let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
result.file_changes.clone()
} else {
repo.diff_trees(&from_hash, &to_hash)?
};
let file_changes: Vec<FileChange> = changes
.iter()
.map(|change| {
build_state_change(
repo,
from_tree.as_ref(),
to_tree,
&change.path,
&change.kind.to_string(),
change.kind,
unified,
)
})
.collect();
let file_changes = sort_changes_by_path(file_changes);
let file_changes = expand_type_changes(
repo,
from_tree.as_ref(),
Some(to_tree),
file_changes,
true,
unified,
)?;
let file_changes = detect_clear_renames(
repo,
from_tree.as_ref(),
Some(to_tree),
file_changes,
true,
unified,
)?;
let semantic_changes = semantic_diff_result.map(|r| {
r.changes
.into_iter()
.map(SemanticChangeEntry::from)
.collect()
});
let mut output = DiffOutput::new(
Some(from_change_id.short()),
Some(to_label.into()),
file_changes,
semantic_changes,
None,
None,
);
populate_patch_text(&mut output);
Ok(output)
}
fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
changes
.into_iter()
.map(|mut change| {
change.lines = None;
change
})
.collect()
}
fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
if lines.is_empty() {
return lines;
}
if !lines.iter().any(|line| line.prefix != " ") {
if eol.old_has_final_newline == eol.new_has_final_newline {
return lines;
}
return eol_only_tail_hunk(lines, context);
}
let mut ranges = Vec::<(usize, usize)>::new();
let mut cursor = 0usize;
while cursor < lines.len() {
while cursor < lines.len() && lines[cursor].prefix == " " {
cursor += 1;
}
if cursor >= lines.len() {
break;
}
let start = cursor.saturating_sub(context);
while cursor < lines.len() && lines[cursor].prefix != " " {
cursor += 1;
}
let mut end = (cursor + context).min(lines.len());
while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
cursor += 1;
}
while cursor < lines.len() && lines[cursor].prefix != " " {
end = (cursor + 1 + context).min(lines.len());
cursor += 1;
}
if let Some((_, previous_end)) = ranges.last_mut()
&& start <= *previous_end
{
*previous_end = end;
continue;
}
ranges.push((start, end));
}
let mut output = Vec::new();
for (start, end) in ranges {
let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
output.push(LineDiff {
prefix: "@".to_string(),
content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
old_line: None,
new_line: None,
});
output.extend_from_slice(&lines[start..end]);
}
output
}
fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
let end = lines.len();
let start = end.saturating_sub(context + 1);
let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
let mut output = Vec::with_capacity(end - start + 1);
output.push(LineDiff {
prefix: "@".to_string(),
content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
old_line: None,
new_line: None,
});
output.extend_from_slice(&lines[start..end]);
output
}
pub(crate) fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
let mut output = Vec::with_capacity(lines.len());
let mut body_start = 0usize;
for (index, line) in lines.iter().enumerate() {
if line.prefix == "@" {
if body_start < index {
output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
}
output.push(line.clone());
body_start = index + 1;
}
}
if body_start < lines.len() {
output.extend(trim_trailing_added_decorations(&lines[body_start..]));
}
output
}
fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
let mut trimmed = Vec::with_capacity(lines.len());
let mut index = 0usize;
while index < lines.len() {
if lines[index].prefix == "+"
&& is_visual_decoration_line(&lines[index].content)
&& let Some(next_context) = next_context_line(lines, index + 1)
&& next_context.content == lines[index].content
{
let added_block_has_code = lines[index + 1..next_context.index]
.iter()
.any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
if added_block_has_code {
index += 1;
continue;
}
}
trimmed.push(lines[index].clone());
index += 1;
}
trimmed
}
struct IndexedLine<'a> {
index: usize,
content: &'a str,
}
fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
lines[start..]
.iter()
.enumerate()
.find(|(_, line)| line.prefix == " ")
.map(|(offset, line)| IndexedLine {
index: start + offset,
content: &line.content,
})
}
fn is_blank_or_visual_decoration(line: &str) -> bool {
line.trim().is_empty() || is_visual_decoration_line(line)
}
fn is_visual_decoration_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("#[")
|| trimmed.starts_with("#![")
|| trimmed.starts_with('@')
|| trimmed.starts_with("///")
|| trimmed.starts_with("//!")
}
fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
let old_before = lines[..start]
.iter()
.filter(|line| line.prefix != "+")
.count();
let new_before = lines[..start]
.iter()
.filter(|line| line.prefix != "-")
.count();
let old_len = lines[start..end]
.iter()
.filter(|line| line.prefix != "+")
.count();
let new_len = lines[start..end]
.iter()
.filter(|line| line.prefix != "-")
.count();
let old_start = if old_len == 0 {
old_before
} else {
old_before + 1
};
let new_start = if new_len == 0 {
new_before
} else {
new_before + 1
};
(old_start, old_len, new_start, new_len)
}
fn collect_file_context(
repo: &Repository,
state: &State,
changes: &FileChangeSet,
) -> Result<Vec<FileContextEntry>> {
let Some(context_root) = &state.context else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
for change in changes {
let target = ContextTarget::file(change.path.clone())?;
let Some(blob) = repo.get_context_blob(context_root, &target)? else {
continue;
};
let annotations = blob
.annotations
.iter()
.filter(|annotation| annotation.status == AnnotationStatus::Active)
.filter_map(|annotation| {
annotation
.current_revision()
.map(|revision| ContextSnippet {
annotation_id: annotation.annotation_id.clone(),
kind: revision.kind.to_string(),
content: summarize_context(&revision.content),
revision_count: annotation.revisions.len(),
})
})
.collect::<Vec<_>>();
if !annotations.is_empty() {
entries.push(FileContextEntry {
path: change.path.clone(),
annotations,
});
}
}
Ok(entries)
}
fn collect_state_guidance(repo: &Repository, state: &State) -> Result<Vec<ContextSnippet>> {
let Some(context_root) = &state.context else {
return Ok(Vec::new());
};
let target = ContextTarget::state(state.change_id);
let Some(blob) = repo.get_context_blob(context_root, &target)? else {
return Ok(Vec::new());
};
Ok(blob
.annotations
.iter()
.filter(|annotation| annotation.status == AnnotationStatus::Active)
.filter_map(|annotation| {
annotation
.current_revision()
.map(|revision| ContextSnippet {
annotation_id: annotation.annotation_id.clone(),
kind: revision.kind.to_string(),
content: summarize_context(&revision.content),
revision_count: annotation.revisions.len(),
})
})
.collect())
}
fn summarize_context(content: &str) -> String {
let first_line = content
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
if first_line.len() <= 88 {
first_line.to_string()
} else {
format!("{}...", &first_line[..85])
}
}
fn get_worktree_diff(
repo: &Repository,
from_tree: Option<&Tree>,
path: &str,
kind: &DiffKind,
) -> Result<(Vec<LineDiff>, FileEolState)> {
let worktree_path = repo.root().join(path);
match kind {
DiffKind::Added => {
let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
let eol = eol_for_added(&new_blob);
Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
}
DiffKind::Deleted => {
if let Some(tree) = from_tree
&& let Some(blob) = find_blob_in_tree(repo, tree, path)?
{
let eol = eol_for_deleted(&blob);
return Ok((number_lines(blob_lines(&blob, "-")?), eol));
}
Ok((vec![], FileEolState::default()))
}
DiffKind::Modified => {
let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
if let Some(tree) = from_tree
&& let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
{
return modified_blob_hunks(&old_blob, &new_blob);
}
let eol = eol_for_added(&new_blob);
Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
}
DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
}
}
fn worktree_modified_type_change(
repo_root: &Path,
path: &str,
diff_kind: DiffKind,
) -> Option<(&'static str, DiffKind)> {
if matches!(diff_kind, DiffKind::Modified)
&& worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
{
Some(("deleted", DiffKind::Deleted))
} else {
None
}
}
fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
let target = std::fs::read_link(path)?;
return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
}
Ok(Blob::new(std::fs::read(path)?))
}
fn is_symlink_mode(mode: Option<FileMode>) -> bool {
matches!(mode, Some(FileMode::Symlink))
}
fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
match kind {
"added" => (false, is_symlink_mode(mode)),
"deleted" => (is_symlink_mode(mode), false),
_ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
}
}
fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
(old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
}
fn symlink_change_from_blobs(
kind: &str,
old_blob: Option<&Blob>,
old_mode: Option<FileMode>,
new_blob: Option<&Blob>,
mode: Option<FileMode>,
) -> Option<SymlinkChange> {
let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
let old = old_is_link
.then(|| old_blob.map(|blob| blob.content().to_vec()))
.flatten();
let new = new_is_link
.then(|| new_blob.map(|blob| blob.content().to_vec()))
.flatten();
make_symlink_change(old, new)
}
#[allow(clippy::too_many_arguments)]
fn symlink_change_for_paths(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
kind: &str,
old_path: &str,
new_path: &str,
old_mode: Option<FileMode>,
mode: Option<FileMode>,
) -> Option<SymlinkChange> {
let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
let old = old_is_link
.then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
.flatten()
.map(|blob| blob.content().to_vec());
let new = new_is_link
.then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
.flatten()
.map(|blob| blob.content().to_vec());
make_symlink_change(old, new)
}
fn detect_clear_renames_for_worktree_status(
cli: &Cli,
changes: Vec<FileChange>,
include_lines: bool,
unified: usize,
) -> Result<Vec<FileChange>> {
let cwd = std::env::current_dir()?;
let start = cli.repo.as_ref().unwrap_or(&cwd);
let Ok(repo) = Repository::open(start) else {
return Ok(changes);
};
let from_tree = if let Some(id) = repo.head()? {
repo.store()
.get_state(&id)?
.and_then(|state| repo.store().get_tree(&state.tree).transpose())
.transpose()?
} else {
None
};
detect_clear_renames(
&repo,
from_tree.as_ref(),
None,
changes,
include_lines,
unified,
)
}
fn detect_clear_renames(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
changes: Vec<FileChange>,
include_lines: bool,
unified: usize,
) -> Result<Vec<FileChange>> {
let deleted = changes
.iter()
.filter(|change| change.kind == "deleted")
.map(|change| change.path.as_str())
.collect::<Vec<_>>();
let added = changes
.iter()
.filter(|change| change.kind == "added")
.map(|change| change.path.as_str())
.collect::<Vec<_>>();
if deleted.is_empty() || added.is_empty() {
return Ok(changes);
}
let deleted_side_modes = changes
.iter()
.filter(|change| change.kind == "deleted")
.map(|change| (change.path.as_str(), change.mode))
.collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
let added_side_modes = changes
.iter()
.filter(|change| change.kind == "added")
.map(|change| (change.path.as_str(), change.mode))
.collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
let mut candidates = Vec::new();
for old_path in &deleted {
let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
continue;
};
for new_path in &added {
if old_path == new_path {
continue;
}
if !rename_mode_compatible(
deleted_side_modes.get(old_path).copied().flatten(),
added_side_modes.get(new_path).copied().flatten(),
) {
continue;
}
let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
continue;
};
let score = rename_similarity(&old_blob, &new_blob);
if score >= 0.75 {
candidates.push((score, (*old_path).to_string(), (*new_path).to_string()));
}
}
}
candidates.sort_by(|left, right| {
right
.0
.total_cmp(&left.0)
.then_with(|| left.1.cmp(&right.1))
.then_with(|| left.2.cmp(&right.2))
});
let mut used_old = BTreeSet::new();
let mut used_new = BTreeSet::new();
let mut renames: Vec<(String, String, f64)> = Vec::new();
for (score, old_path, new_path) in candidates {
if used_old.insert(old_path.clone()) && used_new.insert(new_path.clone()) {
renames.push((old_path, new_path, score));
}
}
if renames.is_empty() {
return Ok(changes);
}
let rename_by_new = renames
.iter()
.map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
.collect::<std::collections::BTreeMap<_, _>>();
let removed_old = renames
.iter()
.map(|(old_path, _, _)| old_path.as_str())
.collect::<BTreeSet<_>>();
let deleted_modes = changes
.iter()
.filter(|change| change.kind == "deleted")
.map(|change| (change.path.clone(), change.mode))
.collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
let mut output = Vec::with_capacity(changes.len() - renames.len());
for mut change in changes {
if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
continue;
}
if change.kind == "added"
&& let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
{
let (lines, eol) = if include_lines {
match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
Ok(Some((lines, eol))) => (Some(lines), eol),
Ok(None) => (None, FileEolState::default()),
Err(error) if is_binary_diff_error(&error) => {
change.binary = true;
(None, FileEolState::default())
}
Err(error) => return Err(error),
}
} else {
(None, FileEolState::default())
};
change.kind = "renamed".to_string();
change.old_path = Some(old_path.to_string());
change.similarity_score = Some(score);
change.lines = lines;
change.eol = eol;
change.old_mode = deleted_modes.get(old_path).copied().flatten();
change.symlink = symlink_change_for_paths(
repo,
from_tree,
to_tree,
"renamed",
old_path,
&change.path,
change.old_mode,
change.mode,
);
if change.symlink.is_some() {
change.binary = false;
}
change.line_counts = None;
}
output.push(change);
}
Ok(output)
}
fn rename_lines(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
old_path: &str,
new_path: &str,
unified: usize,
) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
return Ok(None);
};
let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
return Ok(None);
};
ensure_text_diffable(&old_blob)?;
ensure_text_diffable(&new_blob)?;
let eol = eol_for_modified(&old_blob, &new_blob);
let diff = diff_blobs(&old_blob, &new_blob);
let lines = diff
.iter()
.map(|line| LineDiff::new(line.prefix(), line.content()))
.collect();
Ok(Some((
unified_hunks(number_lines(lines), unified, &eol),
eol,
)))
}
fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
let Some(tree) = tree else {
return Ok(None);
};
find_blob_in_tree(repo, tree, path)
}
fn new_blob_for_rename(
repo: &Repository,
to_tree: Option<&Tree>,
path: &str,
) -> Result<Option<Blob>> {
if let Some(tree) = to_tree {
return find_blob_in_tree(repo, tree, path);
}
let worktree_path = repo.root().join(path);
match std::fs::symlink_metadata(&worktree_path) {
Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
is_symlink(old) == is_symlink(new)
}
fn rename_similarity(old_blob: &Blob, new_blob: &Blob) -> f64 {
if old_blob.content() == new_blob.content() {
return 1.0;
}
let (Some(old_text), Some(new_text)) = (old_blob.content_str(), new_blob.content_str()) else {
return 0.0;
};
if old_text.chars().any(is_terminal_hostile_control)
|| new_text.chars().any(is_terminal_hostile_control)
{
return 0.0;
}
let old_lines = old_text.lines().collect::<Vec<_>>();
let new_lines = new_text.lines().collect::<Vec<_>>();
if old_lines.is_empty() || new_lines.is_empty() {
return 0.0;
}
let shared = lcs_len(&old_lines, &new_lines);
(shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
}
fn lcs_len(left: &[&str], right: &[&str]) -> usize {
let mut previous = vec![0usize; right.len() + 1];
let mut current = vec![0usize; right.len() + 1];
for left_line in left {
for (index, right_line) in right.iter().enumerate() {
current[index + 1] = if left_line == right_line {
previous[index] + 1
} else {
previous[index + 1].max(current[index])
};
}
std::mem::swap(&mut previous, &mut current);
current.fill(0);
}
previous[right.len()]
}
fn get_state_diff(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: &Tree,
path: &str,
kind: &DiffKind,
) -> Result<(Vec<LineDiff>, FileEolState)> {
match kind {
DiffKind::Added => {
let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
return Ok((Vec::new(), FileEolState::default()));
};
let eol = eol_for_added(&new_blob);
Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
}
DiffKind::Deleted => {
let Some(tree) = from_tree else {
return Ok((Vec::new(), FileEolState::default()));
};
let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
return Ok((Vec::new(), FileEolState::default()));
};
let eol = eol_for_deleted(&old_blob);
Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
}
DiffKind::Modified => {
let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
return Ok((Vec::new(), FileEolState::default()));
};
if let Some(tree) = from_tree
&& let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
{
return modified_blob_hunks(&old_blob, &new_blob);
}
let eol = eol_for_added(&new_blob);
Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
}
DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
}
}
fn eol_for_added(new_blob: &Blob) -> FileEolState {
let (new_eol, new_count) = blob_eol_meta(new_blob);
FileEolState {
old_has_final_newline: true,
new_has_final_newline: new_eol,
old_line_count: 0,
new_line_count: new_count,
}
}
fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
let (old_eol, old_count) = blob_eol_meta(old_blob);
FileEolState {
old_has_final_newline: old_eol,
new_has_final_newline: true,
old_line_count: old_count,
new_line_count: 0,
}
}
fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
let (old_eol, old_count) = blob_eol_meta(old_blob);
let (new_eol, new_count) = blob_eol_meta(new_blob);
FileEolState {
old_has_final_newline: old_eol,
new_has_final_newline: new_eol,
old_line_count: old_count,
new_line_count: new_count,
}
}
fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
let content = blob.content();
if content.is_empty() {
return (true, 0);
}
let has_eol = content.ends_with(b"\n");
let line_count = blob
.content_str()
.map(|text| text.lines().count())
.unwrap_or(0);
(has_eol, line_count)
}
fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
let text = text_diff_content(blob)?;
Ok(text
.lines()
.map(|line| LineDiff::new(prefix, line))
.collect())
}
fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
if old.content() == new.content() {
return Ok((Vec::new(), FileEolState::default()));
}
ensure_text_diffable(old)?;
ensure_text_diffable(new)?;
let eol = eol_for_modified(old, new);
let diff = diff_blobs(old, new);
let lines = diff
.iter()
.map(|l| LineDiff::new(l.prefix(), l.content()))
.collect();
Ok((number_lines(lines), eol))
}
fn ensure_text_diffable(blob: &Blob) -> Result<()> {
text_diff_content(blob).map(|_| ())
}
fn text_diff_content(blob: &Blob) -> Result<&str> {
let Some(text) = blob.content_str() else {
return Err(anyhow!(BINARY_DIFF_ERROR));
};
if text.chars().any(is_terminal_hostile_control) {
return Err(anyhow!(BINARY_DIFF_ERROR));
}
Ok(text)
}
fn is_binary_diff_error(error: &anyhow::Error) -> bool {
error.to_string() == BINARY_DIFF_ERROR
}
fn is_terminal_hostile_control(ch: char) -> bool {
ch.is_control() && ch != '\n' && ch != '\t'
}
fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
let mut old_line = 1usize;
let mut new_line = 1usize;
lines
.into_iter()
.map(|line| {
let old = if line.prefix != "+" {
let current = Some(old_line);
old_line += 1;
current
} else {
None
};
let new = if line.prefix != "-" {
let current = Some(new_line);
new_line += 1;
current
} else {
None
};
LineDiff::with_lines(line.prefix, line.content, old, new)
})
.collect()
}
fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
match find_entry_in_tree(repo, tree, path)? {
Some(entry) => Ok(Some(repo.require_blob(&entry.hash)?)),
None => Ok(None),
}
}
fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
let parts: Vec<&str> = path.split('/').collect();
find_entry_recursive(repo, tree, &parts)
}
fn find_entry_recursive(
repo: &Repository,
tree: &Tree,
parts: &[&str],
) -> Result<Option<TreeEntry>> {
if parts.is_empty() {
return Ok(None);
}
let name = parts[0];
let entry = match tree.get(name) {
Some(e) => e,
None => return Ok(None),
};
if parts.len() == 1 {
if entry.is_blob() || entry.entry_type == EntryType::Symlink {
return Ok(Some(entry.clone()));
}
} else if entry.is_tree()
&& let Some(subtree) = repo.store().get_tree(&entry.hash)?
{
return find_entry_recursive(repo, &subtree, &parts[1..]);
}
Ok(None)
}
fn worktree_file_mode(path: &Path) -> Option<FileMode> {
let metadata = std::fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() {
return Some(FileMode::Symlink);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 != 0 {
return Some(FileMode::Executable);
}
}
Some(FileMode::Normal)
}
fn change_file_modes(
repo: &Repository,
from_tree: Option<&Tree>,
to_tree: Option<&Tree>,
path: &str,
kind: &str,
) -> (Option<FileMode>, Option<FileMode>) {
let old_side = || {
from_tree
.and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
.map(|entry| entry.mode)
};
let new_side = || match to_tree {
Some(tree) => find_entry_in_tree(repo, tree, path)
.ok()
.flatten()
.map(|entry| entry.mode),
None => worktree_file_mode(&repo.root().join(path)),
};
match kind {
"added" => (None, new_side()),
"deleted" => (None, old_side()),
"modified" => (old_side(), new_side()),
_ => (None, None),
}
}
#[cfg(test)]
mod tests {
use super::unified_hunks;
use crate::cli::commands::diff::diff_types::{
DiffStats, FileChange, FileEolState, LineCounts, LineDiff, change_line_counts,
};
fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
FileChange {
path: "notes.txt".to_string(),
kind: kind.to_string(),
line_counts: Some(counts),
..Default::default()
}
}
#[test]
fn diff_stats_reads_line_counts_when_hunks_dropped() {
let changes = vec![stat_change(
"modified",
LineCounts {
added: 1,
modified: 0,
deleted: 0,
},
)];
let stats = DiffStats::from_changes(&changes, None);
assert_eq!(stats.files_changed, 1);
assert_eq!(stats.additions, 1);
assert_eq!(stats.modifications, 0);
assert_eq!(stats.deletions, 0);
assert_eq!(stats.renames, 0);
}
#[test]
fn diff_stats_treats_zero_line_counts_as_authoritative() {
let changes = vec![stat_change(
"modified",
LineCounts {
added: 0,
modified: 0,
deleted: 0,
},
)];
let stats = DiffStats::from_changes(&changes, None);
assert_eq!(stats.modifications, 0);
assert_eq!(stats.additions, 0);
assert_eq!(stats.deletions, 0);
}
#[test]
fn change_line_counts_pairs_modified_lines() {
let lines = vec![
LineDiff::with_lines("-", "alpha", Some(1), None),
LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
LineDiff::with_lines("+", "fresh", None, Some(2)),
];
let counts = change_line_counts(Some(&lines));
assert_eq!(counts.modified, 1);
assert_eq!(counts.added, 1);
assert_eq!(counts.deleted, 0);
}
#[test]
fn unified_hunks_keeps_added_decoration_in_canonical_body() {
let lines = vec![
LineDiff::with_lines("+", "#[test]", None, Some(1)),
LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
];
let hunk = unified_hunks(lines, 3, &FileEolState::default());
let header = hunk
.iter()
.find(|line| line.prefix == "@")
.expect("hunk should carry an `@@` header");
assert_eq!(
header.content, "@ -1,2 +1,4 @@",
"header counts must match the untrimmed body: {hunk:?}"
);
assert!(
hunk.iter()
.any(|line| line.prefix == "+" && line.content == "#[test]"),
"added decoration line must survive in the canonical body: {hunk:?}"
);
assert!(
hunk.iter()
.any(|line| line.prefix == "+" && line.content == "fn added() {}"),
"added function body should remain: {hunk:?}"
);
}
#[test]
fn display_trim_drops_added_decoration_but_keeps_header() {
use super::trim_added_decorations_for_display;
let lines = vec![
LineDiff::with_lines("+", "#[test]", None, Some(1)),
LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
];
let hunk = unified_hunks(lines, 3, &FileEolState::default());
let display = trim_added_decorations_for_display(&hunk);
assert!(
display
.iter()
.filter(|line| line.content == "#[test]")
.all(|line| line.prefix == " "),
"display trim should let existing context own the decoration: {display:?}"
);
assert!(
display
.iter()
.any(|line| line.prefix == "+" && line.content == "fn added() {}"),
"added function body should remain after display trim: {display:?}"
);
assert_eq!(
display
.iter()
.find(|line| line.prefix == "@")
.map(|l| l.content.as_str()),
Some("@ -1,2 +1,4 @@"),
"display trim must not rewrite the `@@` header: {display:?}"
);
}
}