use std::collections::BTreeSet;
use std::path::Path;
use std::sync::{Arc, Mutex};
use crate::diff::structured::{structured_file_diff, SideBytes, StructuredFileDiff};
use crate::diff::targets::{commit_tree, diff_index_to_tree_for_paths};
use crate::diff::tree_content::{gitlink_line, load_new_side, load_old_side};
use crate::diff::{
apply_rename_detection_to_diff_entries, diff_index_to_tree_with_renames,
diff_index_to_worktree_with_renames, diff_tree_to_worktree_with_git_dir_and_rules,
index_mtime_for_diff, line_change_counts, load_worktree_side_for_diff, read_submodule_head_oid,
sort_entries_by_path, submodule_porcelain_flags, DiffEntry, DiffIndexToWorktreeOptions,
DiffOptions, DiffStatus, LineStat, RenameDetection, RENAME_DETECTION_DISABLED,
};
use crate::error::{Error, Result};
use crate::index::write_tree::cache_tree_extension_valid;
use crate::index::Index;
use crate::objects::odb::Odb;
use crate::objects::{FileMode, ObjectId};
use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
use crate::repo::Repository;
use crate::state::resolve_head;
use crate::worktree::fsmonitor::fsmonitor_enabled;
use crate::worktree::worktree_rules::WorktreeRules;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileChange {
pub path: String,
pub old_path: Option<String>,
pub status: DiffStatus,
pub old_mode: FileMode,
pub new_mode: FileMode,
pub old_oid: ObjectId,
pub new_oid: ObjectId,
pub old_bytes: SideBytes,
pub new_bytes: SideBytes,
pub binary: bool,
pub content_changed: bool,
pub similarity: Option<u32>,
}
impl FileChange {
fn from_entry(
entry: DiffEntry,
old_bytes: SideBytes,
new_bytes: SideBytes,
binary: bool,
) -> Self {
let path = entry
.new_path
.clone()
.or_else(|| entry.old_path.clone())
.unwrap_or_default();
let similarity = entry.rename_similarity();
let old_path = entry.old_path.filter(|old| *old != path);
Self {
path,
old_path,
status: entry.status,
old_mode: entry.old_mode,
new_mode: entry.new_mode,
old_oid: entry.old_oid,
new_oid: entry.new_oid,
old_bytes,
new_bytes,
binary,
content_changed: entry.old_oid != entry.new_oid,
similarity,
}
}
#[must_use]
pub fn structured(&self, options: DiffOptions) -> StructuredFileDiff {
if self.binary {
return StructuredFileDiff {
old_mode: self.old_mode.octal_if_present(),
new_mode: self.new_mode.octal_if_present(),
old_encoding_lossy: self.old_bytes.encoding_lossy,
new_encoding_lossy: self.new_bytes.encoding_lossy,
hunks: Vec::new(),
};
}
structured_file_diff(
self.old_mode,
self.new_mode,
&self.old_bytes,
&self.new_bytes,
options,
)
}
#[must_use]
pub fn line_stat(&self, options: &DiffOptions) -> Option<LineStat> {
if self.binary {
return None;
}
Some(line_change_counts(
&self.old_bytes.text(),
&self.new_bytes.text(),
options,
))
}
}
pub fn load_object_changes(repo: &Repository, entries: &[DiffEntry]) -> Result<Vec<FileChange>> {
let config = repo.config()?;
let par_opts = repo
.thread_policy()
.resolve(ParallelOp::DiffContent, config.as_ref());
let odb = repo.odb();
let est_bytes = entries.len().saturating_mul(8192);
try_par_map(entries, par_opts, est_bytes, |entry| {
load_change(odb, entry.clone(), None)
})
.map_err(|ParallelHashError::Task(err)| err)
}
pub(crate) struct WorktreeSide<'a> {
pub(crate) work_tree: &'a Path,
pub(crate) index: &'a Index,
pub(crate) rules: &'a Mutex<WorktreeRules>,
}
pub(crate) fn load_change(
odb: &Odb,
entry: DiffEntry,
worktree: Option<&WorktreeSide<'_>>,
) -> Result<FileChange> {
let (old_bytes, old_binary) = load_old_side(odb, &entry, None)?;
let (new_bytes, new_binary) = match worktree {
Some(side) => load_worktree_new_side(odb, &entry, side)?,
None => load_new_side(odb, &entry, None)?,
};
Ok(FileChange::from_entry(
entry,
old_bytes,
new_bytes,
old_binary || new_binary,
))
}
pub(crate) fn load_changes(
odb: &Odb,
entries: Vec<DiffEntry>,
worktree: Option<&WorktreeSide<'_>>,
) -> Result<Vec<FileChange>> {
entries
.into_iter()
.map(|entry| load_change(odb, entry, worktree))
.collect()
}
fn load_worktree_new_side(
odb: &Odb,
entry: &DiffEntry,
side: &WorktreeSide<'_>,
) -> Result<(SideBytes, bool)> {
if entry.new_mode == FileMode::GITLINK {
let line = worktree_gitlink_line(entry, side.work_tree);
return Ok((SideBytes::from_bytes(line.into_bytes()), false));
}
if entry.new_mode == FileMode::ABSENT {
return Ok((SideBytes::from_bytes(Vec::new()), false));
}
let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
return load_new_side(odb, entry, None);
};
let index_entry = side.index.get(&crate::repo_relpath::storage_bytes(path), 0);
let rules = side.rules.lock().unwrap_or_else(|e| e.into_inner());
load_worktree_side_for_diff(
odb,
side.work_tree,
path,
entry.new_mode.bits(),
rules.config(),
index_entry,
Some(&rules),
false,
)
}
fn worktree_gitlink_line(entry: &DiffEntry, work_tree: &Path) -> String {
let path = entry.new_path.as_deref().or(entry.old_path.as_deref());
let head = if entry.new_oid != ObjectId::zero() {
Some(entry.new_oid)
} else {
path.and_then(|p| read_submodule_head_oid(&work_tree.join(p)))
};
let Some(head) = head.filter(|oid| *oid != ObjectId::zero()) else {
return String::new();
};
let dirty = entry.old_mode == FileMode::GITLINK
&& path.is_some_and(|p| submodule_porcelain_flags(work_tree, p, entry.old_oid).modified);
if dirty {
format!("Subproject commit {}-dirty\n", head.to_hex())
} else {
gitlink_line(&head)
}
}
fn rename_threshold(renames: RenameDetection) -> u32 {
renames
.threshold_percent()
.unwrap_or(RENAME_DETECTION_DISABLED)
}
pub(crate) fn head_tree(repo: &Repository) -> Result<Option<ObjectId>> {
resolve_head(repo.git_dir())?
.oid()
.map(|oid| commit_tree(repo, oid))
.transpose()
}
pub(crate) fn staged_entries_in(
repo: &Repository,
index: &Index,
rename_threshold: u32,
restrict_to_paths: Option<&BTreeSet<String>>,
) -> Result<Vec<DiffEntry>> {
let head_tree = head_tree(repo)?;
let mut entries = match restrict_to_paths.filter(|paths| !paths.is_empty()) {
Some(paths) => diff_index_to_tree_for_paths(repo, index, head_tree.as_ref(), paths)?,
None => {
let config = repo.config()?;
diff_index_to_tree_with_renames(
repo.odb(),
index,
head_tree.as_ref(),
false,
rename_threshold,
config.as_ref(),
&repo.thread_policy(),
)?
}
};
sort_entries_by_path(&mut entries);
Ok(entries)
}
pub(crate) fn unstaged_entries_in(
repo: &Repository,
index: &mut Index,
work_tree: &Path,
rules: &Arc<Mutex<WorktreeRules>>,
rename_threshold: u32,
restrict_to_paths: Option<Arc<BTreeSet<String>>>,
) -> Result<Vec<DiffEntry>> {
let config = rules.lock().unwrap_or_else(|e| e.into_inner()).config_arc();
let refresh_fsmonitor = restrict_to_paths.is_none() && fsmonitor_enabled(config.as_ref());
let options = DiffIndexToWorktreeOptions {
repository_git_dir: Some(repo.git_dir().to_path_buf()),
index_mtime: index_mtime_for_diff(index, None),
config: Some(config),
thread_policy: Some(repo.thread_policy()),
refresh_index_stat_in_pass: false,
refresh_fsmonitor,
worktree_rules: Some(Arc::clone(rules)),
command_runner: Some(repo.command_runner()),
restrict_to_paths,
..DiffIndexToWorktreeOptions::default()
};
let (mut entries, _) =
diff_index_to_worktree_with_renames(repo, index, work_tree, options, rename_threshold)?;
sort_entries_by_path(&mut entries);
Ok(entries)
}
pub(crate) fn worktree_entries_in(
repo: &Repository,
index: &mut Index,
work_tree: &Path,
rules: &Arc<Mutex<WorktreeRules>>,
base_tree: Option<&ObjectId>,
rename_threshold: u32,
) -> Result<Vec<DiffEntry>> {
let index_matches_base = match (base_tree, index.cache_tree_root.as_ref()) {
(Some(base), Some(cached)) => base == cached,
(None, None) => true,
_ => false,
};
if index_matches_base && cache_tree_extension_valid(index.cache_tree.as_ref()) {
return unstaged_entries_in(repo, index, work_tree, rules, rename_threshold, None);
}
let config = repo.config()?;
let raw = {
let rules = rules.lock().unwrap_or_else(|e| e.into_inner());
diff_tree_to_worktree_with_git_dir_and_rules(
repo.odb(),
base_tree,
work_tree,
repo.git_dir(),
index,
Some(config.as_ref()),
Some(&rules),
)?
};
let mut entries = apply_rename_detection_to_diff_entries(
repo.odb(),
raw,
Some(work_tree),
rename_threshold,
config.as_ref(),
&repo.thread_policy(),
)?;
sort_entries_by_path(&mut entries);
Ok(entries)
}
pub struct UncommittedDiff<'repo> {
repo: &'repo Repository,
index: Index,
rules: Option<Arc<Mutex<WorktreeRules>>>,
}
impl<'repo> UncommittedDiff<'repo> {
#[must_use]
pub fn new(repo: &'repo Repository, index: Index) -> Self {
Self {
repo,
index,
rules: None,
}
}
#[must_use]
pub fn index(&self) -> &Index {
&self.index
}
pub fn staged_entries(&self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
staged_entries_in(self.repo, &self.index, rename_threshold(renames), None)
}
pub fn unstaged_entries(&mut self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
let work_tree = self.work_tree()?;
let rules = self.rules()?;
unstaged_entries_in(
self.repo,
&mut self.index,
work_tree,
&rules,
rename_threshold(renames),
None,
)
}
pub fn worktree_entries(
&mut self,
base_tree: Option<&ObjectId>,
renames: RenameDetection,
) -> Result<Vec<DiffEntry>> {
let work_tree = self.work_tree()?;
let rules = self.rules()?;
worktree_entries_in(
self.repo,
&mut self.index,
work_tree,
&rules,
base_tree,
rename_threshold(renames),
)
}
pub fn load_worktree_changes(&mut self, entries: Vec<DiffEntry>) -> Result<Vec<FileChange>> {
let work_tree = self.work_tree()?;
let rules = self.rules()?;
let side = WorktreeSide {
work_tree,
index: &self.index,
rules: &rules,
};
load_changes(self.repo.odb(), entries, Some(&side))
}
fn work_tree(&self) -> Result<&'repo Path> {
self.repo.work_tree().ok_or(Error::RequiresWorkTree)
}
fn rules(&mut self) -> Result<Arc<Mutex<WorktreeRules>>> {
if let Some(rules) = &self.rules {
return Ok(Arc::clone(rules));
}
let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(
self.repo,
&self.index,
)?));
self.rules = Some(Arc::clone(&rules));
Ok(rules)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::InitOptions;
fn entry(status: DiffStatus, old: Option<&str>, new: Option<&str>) -> DiffEntry {
DiffEntry {
status,
old_path: old.map(str::to_owned),
new_path: new.map(str::to_owned),
old_mode: FileMode::REGULAR,
new_mode: FileMode::REGULAR,
old_oid: ObjectId::zero(),
new_oid: ObjectId::zero(),
score: Some(87),
}
}
#[test]
fn from_entry_keeps_old_path_only_for_real_renames() {
let bytes = || SideBytes::from_bytes(Vec::new());
let renamed = FileChange::from_entry(
entry(DiffStatus::Renamed, Some("a"), Some("b")),
bytes(),
bytes(),
false,
);
assert_eq!(renamed.path, "b");
assert_eq!(renamed.old_path.as_deref(), Some("a"));
assert_eq!(renamed.similarity, Some(87));
let deleted = FileChange::from_entry(
entry(DiffStatus::Deleted, Some("a"), None),
bytes(),
bytes(),
false,
);
assert_eq!(deleted.path, "a");
assert_eq!(deleted.old_path, None);
assert_eq!(deleted.similarity, None);
}
#[test]
fn binary_change_has_no_hunks_or_line_stat() {
let mut change = FileChange::from_entry(
entry(DiffStatus::Modified, Some("a"), Some("a")),
SideBytes::from_bytes(b"one\n".to_vec()),
SideBytes::from_bytes(b"two\n".to_vec()),
true,
);
assert!(change.structured(DiffOptions::default()).hunks.is_empty());
assert_eq!(change.line_stat(&DiffOptions::default()), None);
change.binary = false;
assert_eq!(change.structured(DiffOptions::default()).hunks.len(), 1);
assert_eq!(
change.line_stat(&DiffOptions::default()),
Some(LineStat {
insertions: 1,
deletions: 1,
})
);
}
#[test]
fn worktree_gitlink_line_reads_submodule_head_when_unhashed() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let mut gitlink = entry(DiffStatus::Added, None, Some("missing-sub"));
gitlink.old_mode = FileMode::ABSENT;
gitlink.new_mode = FileMode::GITLINK;
assert_eq!(worktree_gitlink_line(&gitlink, dir.path()), "");
let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
.unwrap_or_else(|e| panic!("oid: {e}"));
gitlink.new_oid = oid;
assert_eq!(
worktree_gitlink_line(&gitlink, dir.path()),
format!("Subproject commit {}\n", oid.to_hex())
);
}
#[test]
fn bare_repository_worktree_methods_require_a_work_tree() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let init = InitOptions {
bare: true,
..InitOptions::default()
};
let repo = Repository::init(dir.path(), &init).unwrap_or_else(|e| panic!("init: {e}"));
let mut diff = UncommittedDiff::new(&repo, Index::default());
assert!(diff
.staged_entries(RenameDetection::Off)
.is_ok_and(|e| e.is_empty()));
assert!(matches!(
diff.unstaged_entries(RenameDetection::Off),
Err(Error::RequiresWorkTree)
));
assert!(matches!(
diff.worktree_entries(None, RenameDetection::Off),
Err(Error::RequiresWorkTree)
));
assert!(matches!(
diff.load_worktree_changes(Vec::new()),
Err(Error::RequiresWorkTree)
));
}
}