use crate::error::{Error, Result};
use crate::util::trim_newline;
use derivative::Derivative;
use log::trace;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fmt::{self, Display, Formatter};
use std::path::{Path, PathBuf};
use std::process::Command;
pub type Oid = String;
#[derive(Derivative)]
#[derivative(PartialEq, Hash, Eq, Debug)]
pub struct Repo {
pub path: PathBuf,
#[derivative(PartialEq = "ignore", Hash = "ignore", Debug = "ignore")]
ancestry_cache: RefCell<HashMap<(Oid, Oid), bool>>,
}
#[derive(PartialEq, Hash, Eq, Debug, Clone)]
pub struct Object<'a> {
pub oid: Oid,
pub repo: &'a Repo,
}
fn path_str(path: &Path) -> &str {
path.to_str()
.expect(&format!("could not convert path {:?} to string", path))
}
impl Repo {
pub fn new(path: PathBuf) -> Repo {
Repo {
path,
ancestry_cache: RefCell::new(HashMap::new()),
}
}
pub fn cmd(&self, cmd: &str) -> Command {
let mut tmp = Command::new("git");
tmp.current_dir(&self.path);
tmp.arg(cmd);
tmp
}
pub fn cmd_output(&self, params: &[&str]) -> Option<String> {
if params.len() == 0 {
unreachable!("`cmd_output' invoked without parameters!");
}
let mut cmd = self.cmd(params[0]);
for p in ¶ms[1..] {
cmd.arg(p);
}
let cmd_str = format!("git {}", params.join(" "));
trace!("{}", cmd_str);
let output = cmd
.output()
.expect(&format!("could not get output of `{}'!", cmd_str));
trace!("{:?}", output);
if output.status.success() {
Some(trim_newline(String::from_utf8(output.stdout).expect(
&format!("output of `{}' contains non-UTF8 characters!", cmd_str),
)))
} else {
None
}
}
pub fn last_commit_on_path(&self, path: &Path) -> Option<Object> {
self.cmd_output(&["log", "-n", "1", "--pretty=format:%H", "--", path_str(path)])
.and_then(|s| {
if s.is_empty() {
None
} else {
Some(Object::new(s, self))
}
})
}
fn first_ordered_object<'a>(
&'a self,
objects: &'a HashSet<Object<'a>>,
ord: Ordering,
) -> Result<&'a Object> {
if objects.len() == 0 {
return Error::result("no objects given");
}
if objects.len() == 1 {
return Ok(objects.iter().next().unwrap());
}
let mut iter = objects.iter();
objects
.iter()
.try_fold(iter.next().unwrap(), |youngest, obj| {
match obj.partial_cmp(&youngest) {
Some(o) => {
if o == ord {
Ok(obj)
} else {
Ok(youngest)
}
}
None => Error::result(format!("{:?} and {:?} are incomparable", youngest, obj)),
}
})
}
pub fn youngest_object<'a>(&'a self, objects: &'a HashSet<Object<'a>>) -> Result<&'a Object> {
self.first_ordered_object(objects, Ordering::Less)
}
pub fn oldest_object<'a>(&'a self, objects: &'a HashSet<Object<'a>>) -> Result<&'a Object> {
self.first_ordered_object(objects, Ordering::Greater)
}
pub fn oldest_common_descendant_on_current_branch<'a>(
&'a self,
objects: &'a HashSet<Object<'a>>,
) -> Result<Object<'a>> {
if objects.len() == 0 {
return Error::result("no objects given");
}
let youngest_object = self.youngest_object(&objects);
if youngest_object.is_ok() {
return youngest_object.map(|obj| obj.clone());
}
let mut descendants = objects.iter().map(|obj| {
obj.descendants_on_current_branch()
.iter()
.map(|obj| Object::new(obj.oid.clone(), &self))
.collect::<HashSet<_>>()
});
let intersection: HashSet<Object> = descendants
.next()
.map(|set| descendants.fold(set, |set1, set2| &set1 & &set2))
.unwrap_or_default();
let oldest_descendant = self.oldest_object(&intersection);
oldest_descendant.map(|obj| Object::new(obj.oid.clone(), &self))
}
fn object_is_ancestor_of(&self, ancestor: &Object, other: &Object) -> bool {
let key = (ancestor.oid.clone(), other.oid.clone());
if let Some(entry) = self.ancestry_cache.borrow().get(&key) {
return *entry;
}
let output = self.cmd_output(&[
"rev-list",
"--ancestry-path",
&format!("{}..{}", ancestor.oid, other.oid),
]);
let entry = match output {
None => false,
Some(s) => s.len() > 0,
};
self.ancestry_cache.borrow_mut().insert(key, entry);
entry
}
}
impl<'a> Display for Object<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.oid)
}
}
impl<'a> PartialOrd for Object<'a> {
fn partial_cmp(&self, other: &Object) -> Option<Ordering> {
if self == other {
return Some(Ordering::Equal);
} else if self.is_descendant_of(other) {
return Some(Ordering::Less);
} else if self.is_ancestor_of(other) {
return Some(Ordering::Greater);
} else {
return None;
}
}
}
impl<'a> Object<'a> {
pub fn new(oid: Oid, repo: &'a Repo) -> Object<'a> {
Object {
oid: oid,
repo: repo,
}
}
pub fn is_ancestor_of(&self, obj: &Object) -> bool {
if self.repo != obj.repo {
return false;
}
self.repo.object_is_ancestor_of(&self, obj)
}
pub fn is_descendant_of(&self, obj: &Object) -> bool {
obj.is_ancestor_of(self)
}
pub fn path_is_same_as(&self, ancestor: &Object, path: &Path) -> bool {
if self.repo != ancestor.repo {
return false;
}
let output = self.repo.cmd_output(&[
"diff",
"--quiet",
&format!("{}..{}", ancestor.oid, self.oid),
"--",
path_str(path),
]);
output.is_some()
}
fn descendants_on_current_branch(&self) -> Vec<Object<'a>> {
match self.repo.cmd_output(&[
"rev-list",
"--ancestry-path",
"--reverse",
&format!("{}..", self.oid),
]) {
Some(s) => s
.lines()
.map(|line| Object::new(line.to_string(), self.repo))
.collect(),
None => vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{Error, Result};
use crate::test_util::{create_file, write_file};
use maplit::hashset;
use tempdir::TempDir;
impl Repo {
fn cmd_assert(&self, params: &[&str]) {
assert!(
self.cmd_output(params).is_some(),
format!("git {}", params.join(" "))
);
}
fn last_commit(&self) -> Option<Object> {
self.past_commit(0)
}
fn past_commit(&self, n_commits_ago: usize) -> Option<Object> {
self.cmd_output(&["rev-parse", &format!("HEAD~{}", n_commits_ago)])
.and_then(|oup| oup.lines().next().map(|l| l.to_string()))
.map(|head_commit| Object::new(head_commit.to_string(), &self))
}
}
fn setup() -> Result<(Repo, TempDir)> {
let tmp = TempDir::new("memora-test-git")
.map_err(|cause| Error::chain("Could not create temporary directory:", cause))?;
let repo = Repo::new(tmp.path().to_path_buf());
repo.cmd_assert(&["init"]);
repo.cmd_assert(&["config", "--local", "user.name", "Test"]);
repo.cmd_assert(&["config", "--local", "user.email", "test@localhost"]);
Ok((repo, tmp))
}
fn setup_with_file(rel_path: &str) -> Result<(Repo, TempDir, std::fs::File)> {
let (repo, tmp_dir) = setup()?;
let fp = tmp_dir.path().join(rel_path);
let file = create_file(fp)?;
Ok((repo, tmp_dir, file))
}
fn rand_string(rng: &mut dyn rand::RngCore, n_chars: usize) -> String {
use rand::distributions::Alphanumeric;
use rand::Rng;
rng.sample_iter(Alphanumeric).take(n_chars).collect()
}
fn rand_commits_on_file(repo: &Repo, rel_path: &str, n_commits: usize) -> Result<()> {
let mut rng = rand::thread_rng();
let mut file = create_file(repo.path.join(rel_path))?;
for _i in 0..n_commits {
write_file(&mut file, &rand_string(&mut rng, 10))?;
repo.cmd_assert(&["add", rel_path]);
repo.cmd_assert(&["commit", "-m", &rand_string(&mut rng, 10)]);
}
Ok(())
}
fn setup_with_commits_on_file(rel_path: &str, n_commits: usize) -> Result<(Repo, TempDir)> {
let (repo, tmp_dir, _file) = setup_with_file(rel_path)?;
rand_commits_on_file(&repo, rel_path, n_commits)?;
Ok((repo, tmp_dir))
}
fn create_two_incomparable_commits<'a>(
repo: &'a Repo,
path: &str,
) -> Result<(Object<'a>, Object<'a>)> {
repo.cmd_assert(&["checkout", "-b", "some_branch"]);
rand_commits_on_file(&repo, path, 1)?;
let some_commit = repo.last_commit().unwrap();
repo.cmd_assert(&["checkout", "master"]);
repo.cmd_assert(&["checkout", "-b", "another_branch"]);
rand_commits_on_file(&repo, path, 1)?;
let another_commit = repo.last_commit().unwrap();
Ok((some_commit, another_commit))
}
#[test]
fn last_commit_on_existing_path_with_single_commit() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 1)?;
let act = repo.last_commit_on_path(Path::new("some_file"));
assert_eq!(act, repo.last_commit());
Ok(())
}
#[test]
fn last_commit_on_existing_path_with_no_commit() -> Result<()> {
let (repo, _tmp_dir, _file) = setup_with_file("some_file")?;
let act = repo.last_commit_on_path(Path::new("some_file"));
assert_eq!(act, None);
Ok(())
}
#[test]
fn last_commit_on_existing_path_with_two_commits() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 2)?;
let act = repo.last_commit_on_path(Path::new("some_file"));
assert_eq!(act, repo.last_commit());
Ok(())
}
#[test]
fn last_commit_on_nonexistent_path() -> Result<()> {
let (repo, _tmp_dir, _file) = setup_with_file("some_file")?;
let act = repo.last_commit_on_path(Path::new("some_other_file"));
assert_eq!(act, None);
Ok(())
}
#[test]
fn youngest_object_no_commit() -> Result<()> {
let (repo, _tmp_dir, _file) = setup_with_file("some_file")?;
assert!(repo.youngest_object(&hashset! {}).is_err());
Ok(())
}
#[test]
fn youngest_object_single_commit() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 5)?;
let obj = repo.last_commit().unwrap();
assert_eq!(repo.youngest_object(&hashset! {obj.clone()}).unwrap(), &obj);
Ok(())
}
#[test]
fn youngest_object_two_identical_commits() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 7)?;
let obj = repo.last_commit().unwrap();
assert_eq!(
repo.youngest_object(&hashset! {obj.clone(), obj.clone()})
.unwrap(),
&obj
);
Ok(())
}
#[test]
fn youngest_object_two_different_commits() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 7)?;
let younger = repo.last_commit().unwrap();
let older = repo.past_commit(4).unwrap();
assert_eq!(
repo.youngest_object(&hashset! {older.clone(), younger.clone()})
.unwrap(),
&younger
);
assert_eq!(
repo.youngest_object(&hashset! {younger.clone(), older.clone()})
.unwrap(),
&younger
);
Ok(())
}
#[test]
fn youngest_object_two_incomparable_commits() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 7)?;
let (some_commit, another_commit) = create_two_incomparable_commits(&repo, "some_file")?;
assert!(repo
.youngest_object(&hashset! {some_commit.clone(), another_commit.clone()})
.is_err());
Ok(())
}
#[test]
fn partial_cmp_different_objects() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 5)?;
let younger = repo.past_commit(1).unwrap();
let older = repo.past_commit(4).unwrap();
assert_eq!(younger.partial_cmp(&older), Some(Ordering::Less));
assert_eq!(older.partial_cmp(&younger), Some(Ordering::Greater));
Ok(())
}
#[test]
fn partial_cmp_identical_objects() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 5)?;
let younger = repo.past_commit(1).unwrap();
assert_eq!(younger.partial_cmp(&younger), Some(Ordering::Equal));
Ok(())
}
#[test]
fn partial_cmp_incomparable_objects() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 1)?;
let (some_commit, another_commit) = create_two_incomparable_commits(&repo, "some_file")?;
assert_eq!(some_commit.partial_cmp(&another_commit), None);
Ok(())
}
#[test]
fn descendants_on_current_branch() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 5)?;
let ancestor = repo.past_commit(3).unwrap();
let descendants = {
let mut vec = Vec::new();
for i in (0..3).rev() {
vec.push(repo.past_commit(i).unwrap());
}
vec
};
assert_eq!(ancestor.descendants_on_current_branch(), descendants);
Ok(())
}
#[test]
fn oldest_common_descendant_on_current_branch_with_merge() -> Result<()> {
let (repo, _tmp_dir) = setup_with_commits_on_file("some_file", 1)?;
repo.cmd_assert(&["checkout", "-b", "some_branch"]);
rand_commits_on_file(&repo, "some_file", 2)?;
let branch_commit = repo.past_commit(1).unwrap();
repo.cmd_assert(&["checkout", "master"]);
rand_commits_on_file(&repo, "another_file", 20)?;
let master_commit = repo.past_commit(10).unwrap();
repo.cmd_assert(&["merge", "--no-edit", "some_branch"]);
let merge_commit = repo.last_commit().unwrap();
rand_commits_on_file(&repo, "some_file", 1)?;
assert_eq!(
repo.oldest_common_descendant_on_current_branch(&hashset! {branch_commit.clone(),
master_commit.clone()})
.unwrap(),
merge_commit
);
Ok(())
}
}