use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::pathspec::{Pathspec, Prune};
use crate::{
EntryMode, ObjectId, ObjectKind, ObjectReadError, Objects, ReadLimits, Tree, TreeError,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct TreeValue {
pub id: ObjectId,
pub mode: EntryMode,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub struct TreeChange {
pub path: Vec<u8>,
pub old: Option<TreeValue>,
pub new: Option<TreeValue>,
}
impl TreeChange {
#[must_use]
pub fn status(&self) -> ChangeStatus {
ChangeStatus::between(self.old, self.new)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ChangeStatus {
Added,
Deleted,
Modified,
TypeChanged,
}
impl ChangeStatus {
#[must_use]
pub fn between(old: Option<TreeValue>, new: Option<TreeValue>) -> Self {
match (old, new) {
(None, Some(_)) => Self::Added,
(Some(_), None) => Self::Deleted,
(Some(old), Some(new)) if file_type(old.mode) != file_type(new.mode) => {
Self::TypeChanged
}
_ => Self::Modified,
}
}
#[must_use]
pub fn letter(self) -> char {
match self {
Self::Added => 'A',
Self::Deleted => 'D',
Self::Modified => 'M',
Self::TypeChanged => 'T',
}
}
}
#[derive(Eq, PartialEq)]
enum FileType {
Regular,
Symlink,
Gitlink,
Tree,
}
fn file_type(mode: EntryMode) -> FileType {
match mode {
EntryMode::Blob | EntryMode::Executable => FileType::Regular,
EntryMode::Symlink => FileType::Symlink,
EntryMode::Gitlink => FileType::Gitlink,
EntryMode::Tree => FileType::Tree,
}
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct TreeCompareLimits {
pub max_trees: usize,
pub max_tree_bytes: usize,
pub max_entries: usize,
pub max_depth: usize,
pub max_path_bytes: usize,
pub max_changes: usize,
pub read: ReadLimits,
}
impl TreeCompareLimits {
#[must_use]
pub fn with_max_trees(mut self, max_trees: usize) -> Self {
self.max_trees = max_trees;
self
}
#[must_use]
pub fn with_max_tree_bytes(mut self, max_tree_bytes: usize) -> Self {
self.max_tree_bytes = max_tree_bytes;
self
}
#[must_use]
pub fn with_max_entries(mut self, max_entries: usize) -> Self {
self.max_entries = max_entries;
self
}
#[must_use]
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self
}
#[must_use]
pub fn with_max_path_bytes(mut self, max_path_bytes: usize) -> Self {
self.max_path_bytes = max_path_bytes;
self
}
#[must_use]
pub fn with_max_changes(mut self, max_changes: usize) -> Self {
self.max_changes = max_changes;
self
}
#[must_use]
pub fn with_read(mut self, read: ReadLimits) -> Self {
self.read = read;
self
}
}
impl Default for TreeCompareLimits {
fn default() -> Self {
Self {
max_trees: 100_000,
max_tree_bytes: 256 * 1024 * 1024,
max_entries: 1_000_000,
max_depth: 1_024,
max_path_bytes: 64 * 1024 * 1024,
max_changes: 1_000_000,
read: ReadLimits::default(),
}
}
}
impl Objects {
pub fn compare_trees_with(
&self,
old: Option<ObjectId>,
new: Option<ObjectId>,
limits: TreeCompareLimits,
cancel: &AtomicBool,
) -> Result<Vec<TreeChange>, TreeCompareError> {
self.compare_trees_matching(old, new, &Pathspec::all(), limits, cancel)
}
pub fn compare_trees_matching(
&self,
old: Option<ObjectId>,
new: Option<ObjectId>,
paths: &Pathspec,
limits: TreeCompareLimits,
cancel: &AtomicBool,
) -> Result<Vec<TreeChange>, TreeCompareError> {
self.compare_trees_among(old, new, &HashMap::new(), paths, limits, cancel)
}
pub(crate) fn compare_trees_among(
&self,
old: Option<ObjectId>,
new: Option<ObjectId>,
local: &HashMap<ObjectId, Tree>,
paths: &Pathspec,
limits: TreeCompareLimits,
cancel: &AtomicBool,
) -> Result<Vec<TreeChange>, TreeCompareError> {
if paths.uses_attributes() {
return Err(TreeCompareError::Unsupported("attr: pathspec magic"));
}
for id in old.into_iter().chain(new) {
id.require_format(self.object_format())?;
}
let mut budget = Budget {
remaining: limits,
cancel,
local,
};
budget.check()?;
let mut pending = vec![Directory {
path: vec![],
old,
new,
depth: 0,
selected: paths.is_all(),
}];
let mut changes = Vec::new();
while let Some(directory) = pending.pop() {
budget.check()?;
if directory.old == directory.new {
continue;
}
let old = budget.read(self, directory.old, &directory.path)?;
let new = budget.read(self, directory.new, &directory.path)?;
let pairs = pair_entries(&old, &new, cancel)?;
for (name, (old, new)) in pairs {
budget.check()?;
let length = directory
.path
.len()
.checked_add(name.len())
.and_then(|n| n.checked_add(usize::from(!directory.path.is_empty())))
.ok_or(TreeCompareError::Limit("path bytes"))?;
charge(&mut budget.remaining.max_path_bytes, length, "path bytes")?;
let mut path = Vec::with_capacity(length);
path.extend_from_slice(&directory.path);
if !path.is_empty() {
path.push(b'/');
}
path.extend_from_slice(name);
let old_tree = tree_id(old);
let new_tree = tree_id(new);
if old_tree != new_tree {
let prune = if directory.selected {
Prune::All
} else {
paths.directory(&path)
};
if prune != Prune::Skip {
if directory.depth == limits.max_depth {
return Err(TreeCompareError::Limit("depth"));
}
pending.push(Directory {
path: path.clone(),
old: old_tree,
new: new_tree,
depth: directory.depth + 1,
selected: prune == Prune::All,
});
}
}
let old = old.filter(|value| value.mode != EntryMode::Tree);
let new = new.filter(|value| value.mode != EntryMode::Tree);
if old != new && (directory.selected || paths.matches_unchecked(&path)) {
charge(&mut budget.remaining.max_changes, 1, "changes")?;
changes.push(TreeChange { path, old, new });
}
}
}
budget.check()?;
changes.sort_unstable_by(|a, b| a.path.cmp(&b.path));
budget.check()?;
Ok(changes)
}
pub fn compare_trees(
&self,
old: Option<ObjectId>,
new: Option<ObjectId>,
) -> Result<Vec<TreeChange>, TreeCompareError> {
self.compare_trees_with(
old,
new,
TreeCompareLimits::default(),
&AtomicBool::new(false),
)
}
}
fn tree_id(value: Option<TreeValue>) -> Option<ObjectId> {
value
.filter(|value| value.mode == EntryMode::Tree)
.map(|value| value.id)
}
type EntryPairs<'a> = BTreeMap<&'a [u8], (Option<TreeValue>, Option<TreeValue>)>;
fn pair_entries<'a>(
old: &'a Tree,
new: &'a Tree,
cancel: &AtomicBool,
) -> Result<EntryPairs<'a>, TreeCompareError> {
let mut pairs: EntryPairs<'_> = BTreeMap::new();
for entry in old.entries() {
check_cancel(cancel)?;
pairs.entry(&entry.name).or_default().0 = Some(TreeValue {
id: entry.id,
mode: entry.mode,
});
}
for entry in new.entries() {
check_cancel(cancel)?;
pairs.entry(&entry.name).or_default().1 = Some(TreeValue {
id: entry.id,
mode: entry.mode,
});
}
Ok(pairs)
}
struct Directory {
path: Vec<u8>,
old: Option<ObjectId>,
new: Option<ObjectId>,
depth: usize,
selected: bool,
}
struct Budget<'a> {
remaining: TreeCompareLimits,
cancel: &'a AtomicBool,
local: &'a HashMap<ObjectId, Tree>,
}
impl Budget<'_> {
fn check(&self) -> Result<(), TreeCompareError> {
check_cancel(self.cancel)
}
fn read(
&mut self,
objects: &Objects,
id: Option<ObjectId>,
path: &[u8],
) -> Result<Tree, TreeCompareError> {
self.check()?;
let Some(id) = id else {
return Ok(Tree::new(objects.object_format(), vec![]).expect("empty tree"));
};
if let Some(tree) = self.local.get(&id) {
return Ok(tree.clone());
}
charge(&mut self.remaining.max_trees, 1, "trees")?;
let mut read = self.remaining.read;
read.max_object_bytes = read.max_object_bytes.min(self.remaining.max_tree_bytes);
let object = objects
.read_with(id, read)
.map_err(|source| TreeCompareError::Read {
id,
path: path.to_vec(),
source: Box::new(source),
})?
.ok_or_else(|| TreeCompareError::Missing {
id,
path: path.to_vec(),
})?;
self.check()?;
if object.kind() != ObjectKind::Tree {
return Err(TreeCompareError::NotTree {
id,
path: path.to_vec(),
actual: object.kind(),
});
}
charge(
&mut self.remaining.max_tree_bytes,
object.data().len(),
"tree bytes",
)?;
let invalid = |source| TreeCompareError::Invalid {
id,
path: path.to_vec(),
source,
};
let tree = Tree::parse(object.object_format(), object.data()).map_err(invalid)?;
charge(
&mut self.remaining.max_entries,
tree.entries().len(),
"entries",
)?;
tree.validate().map_err(invalid)?;
self.check()?;
Ok(tree)
}
}
fn check_cancel(cancel: &AtomicBool) -> Result<(), TreeCompareError> {
if cancel.load(Ordering::Relaxed) {
Err(TreeCompareError::Cancelled)
} else {
Ok(())
}
}
fn charge(remaining: &mut usize, count: usize, name: &'static str) -> Result<(), TreeCompareError> {
*remaining = remaining
.checked_sub(count)
.ok_or(TreeCompareError::Limit(name))?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TreeCompareError {
#[error(transparent)]
ObjectFormat(#[from] crate::ObjectFormatError),
#[error("missing tree {id} at {path:?}")]
Missing {
id: ObjectId,
path: Vec<u8>,
},
#[error("expected tree {id} at {path:?}, found {actual:?}")]
NotTree {
id: ObjectId,
path: Vec<u8>,
actual: ObjectKind,
},
#[error("reading tree {id} at {path:?}")]
Read {
id: ObjectId,
path: Vec<u8>,
#[source]
source: Box<ObjectReadError>,
},
#[error("invalid tree {id} at {path:?}")]
Invalid {
id: ObjectId,
path: Vec<u8>,
#[source]
source: TreeError,
},
#[error("tree comparison limit exceeded: {0}")]
Limit(&'static str),
#[error("unsupported in tree comparison: {0}")]
Unsupported(&'static str),
#[error("tree comparison cancelled")]
Cancelled,
}
#[cfg(test)]
mod tests;