diff --git a/crates/gitbutler-branch-actions/src/commit_ops.rs b/crates/gitbutler-branch-actions/src/commit_ops.rs new file mode 100644 index 0000000000..03336be6a0 --- /dev/null +++ b/crates/gitbutler-branch-actions/src/commit_ops.rs @@ -0,0 +1,275 @@ +use anyhow::{bail, Result}; +use gitbutler_oxidize::GixRepositoryExt; + +/// Finds the first parent of a given commit. +fn get_first_parent<'repo>(commit: &gix::Commit<'repo>) -> Result> { + let Some(first_parent) = commit.parent_ids().next() else { + bail!("Failed to find first parent of {}", commit.id()) + }; + let first_parent = first_parent.object()?.into_commit(); + Ok(first_parent) +} + +/// Gets the changes that one commit introduced compared to the base, +/// excluding anything between the commit and the base. +fn get_exclusive_tree( + repository: &gix::Repository, + commit_id: gix::ObjectId, + base_id: gix::ObjectId, +) -> Result { + let commit = repository.find_commit(commit_id)?; + let commit_parent = get_first_parent(&commit)?; + let base = repository.find_commit(base_id)?; + + let merged_tree = repository + .merge_trees( + commit_parent.tree_id()?, + commit.tree_id()?, + base.tree_id()?, + Default::default(), + repository.merge_options_force_ours()?, + )? + .tree + .write()?; + Ok(merged_tree.into()) +} + +#[derive(PartialEq, Debug)] +enum SubsetKind { + /// The subset_id is not equal to or a subset of superset_id. + /// superset_id MAY still be a strict subset of subset_id + NotSubset, + /// The subset_id is a strict subset of superset_id + Subset, + /// The subset_id and superset_id are equivalent commits + Equal, +} + +/// Takes two commits and determines if one is a subset of or equal to the other. +/// +/// ### Performance +/// +/// `repository` should have been configured [`with_object_memory()`](gix::Repository::with_object_memory()) +/// to prevent real objects to be written while probing for set inclusion. +#[allow(dead_code)] +fn is_subset( + repository: &gix::Repository, + superset_id: gix::ObjectId, + subset_id: gix::ObjectId, + common_base_id: gix::ObjectId, +) -> Result { + let exclusive_superset = get_exclusive_tree(repository, superset_id, common_base_id)?; + let exclusive_subset = get_exclusive_tree(repository, subset_id, common_base_id)?; + + if exclusive_superset == exclusive_subset { + return Ok(SubsetKind::Equal); + } + + let common_base = repository.find_commit(common_base_id)?; + + let (options, unresolved) = repository.merge_options_fail_fast()?; + let mut merged_exclusives = repository.merge_trees( + common_base.tree_id()?, + exclusive_superset, + exclusive_subset, + Default::default(), + options, + )?; + + if merged_exclusives.has_unresolved_conflicts(unresolved) + || exclusive_superset != merged_exclusives.tree.write()? + { + Ok(SubsetKind::NotSubset) + } else { + Ok(SubsetKind::Subset) + } +} + +#[cfg(test)] +mod test { + use gitbutler_testsupport::testing_repository::TestingRepository; + mod get_exclusive_tree { + use gitbutler_oxidize::OidExt; + + use super::super::get_exclusive_tree; + use super::*; + + #[test] + fn when_already_exclusive_returns_self() { + let test_repository = TestingRepository::open(); + let base_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo"), ("bar.txt", "bar")]); + let second_commit: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz")], + ); + + let exclusive_tree = get_exclusive_tree( + &test_repository.gix_repository(), + second_commit.id().to_gix(), + base_commit.id().to_gix(), + ) + .unwrap(); + + assert_eq!( + second_commit.tree_id().to_gix(), + exclusive_tree, + "The tree returned should match the second commit" + ) + } + + #[test] + fn when_on_top_of_other_commit_its_changes_are_dropped() { + let test_repository = TestingRepository::open(); + let base_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo")]); + let second_commit: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz")], + ); + let third_commit: git2::Commit = test_repository.commit_tree( + Some(&second_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz"), ("qux.txt", "bax")], + ); + + // The second commit changed foo.txt, and added bar.txt. We expect + // foo.txt to be reverted back to foo, and bar.txt should be dropped + let expected_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo"), ("qux.txt", "bax")]); + + let exclusive_tree = get_exclusive_tree( + &test_repository.gix_repository(), + third_commit.id().to_gix(), + base_commit.id().to_gix(), + ) + .unwrap(); + + assert_eq!(expected_commit.tree_id().to_gix(), exclusive_tree,) + } + } + + mod is_subset { + use gitbutler_oxidize::OidExt; + + use crate::commit_ops::SubsetKind; + + use super::super::is_subset; + use super::*; + + #[test] + fn a_commit_is_a_subset_of_itself() { + let test_repository = TestingRepository::open(); + let base_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo")]); + let second_commit: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz")], + ); + + assert_eq!( + is_subset( + &test_repository.gix_repository(), + second_commit.id().to_gix(), + second_commit.id().to_gix(), + base_commit.id().to_gix() + ) + .unwrap(), + SubsetKind::Equal + ) + } + + #[test] + fn basic_subset() { + let test_repository = TestingRepository::open(); + let base_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo")]); + let superset: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz"), ("baz.txt", "asdf")], + ); + let subset: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "bar"), ("bar.txt", "baz")], + ); + + assert_eq!( + is_subset( + &test_repository.gix_repository(), + superset.id().to_gix(), + subset.id().to_gix(), + base_commit.id().to_gix() + ) + .unwrap(), + SubsetKind::Subset + ); + + assert_eq!( + is_subset( + &test_repository.gix_repository(), + subset.id().to_gix(), + superset.id().to_gix(), + base_commit.id().to_gix() + ) + .unwrap(), + SubsetKind::NotSubset + ); + } + + #[test] + fn complex_subset() { + let test_repository = TestingRepository::open(); + let base_commit: git2::Commit = + test_repository.commit_tree(None, &[("foo.txt", "foo")]); + let i1: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "baz"), ("amp.txt", "asfd")], + ); + let superset: git2::Commit = test_repository.commit_tree( + Some(&i1), + &[ + ("foo.txt", "baz"), + ("amp.txt", "asfd"), + ("bar.txt", "baz"), + ("baz.txt", "asdf"), + ], + ); + let i2: git2::Commit = test_repository.commit_tree( + Some(&base_commit), + &[("foo.txt", "xxx"), ("fuzz.txt", "asdf")], + ); + let subset: git2::Commit = test_repository.commit_tree( + Some(&i2), + &[("foo.txt", "xxx"), ("fuzz.txt", "asdf"), ("bar.txt", "baz")], + ); + + // This creates two commits "superset" and "subset" which when + // compared directly don't have a superset-subset relationship, + // but because we take the changes that the subset/superset commits + // exclusivly added compared to a common base, we are able to + // identify that the changes each commit introduced are infact + // a superset/subset of each other + + assert_eq!( + is_subset( + &test_repository.gix_repository(), + superset.id().to_gix(), + subset.id().to_gix(), + base_commit.id().to_gix() + ) + .unwrap(), + SubsetKind::Subset + ); + + assert_eq!( + is_subset( + &test_repository.gix_repository(), + subset.id().to_gix(), + superset.id().to_gix(), + base_commit.id().to_gix() + ) + .unwrap(), + SubsetKind::NotSubset + ); + } + } +} diff --git a/crates/gitbutler-branch-actions/src/lib.rs b/crates/gitbutler-branch-actions/src/lib.rs index 624b30df04..0fb3c1dbc3 100644 --- a/crates/gitbutler-branch-actions/src/lib.rs +++ b/crates/gitbutler-branch-actions/src/lib.rs @@ -79,4 +79,5 @@ pub use branch::{ pub use integration::GITBUTLER_WORKSPACE_COMMIT_TITLE; +mod commit_ops; pub mod stack; diff --git a/crates/gitbutler-oxidize/src/lib.rs b/crates/gitbutler-oxidize/src/lib.rs index 0aa7ad7b49..9c859fdc97 100644 --- a/crates/gitbutler-oxidize/src/lib.rs +++ b/crates/gitbutler-oxidize/src/lib.rs @@ -15,10 +15,30 @@ pub fn git2_to_gix_object_id(id: git2::Oid) -> gix::ObjectId { gix::ObjectId::try_from(id.as_bytes()).expect("git2 oid is always valid") } +pub trait OidExt { + fn to_gix(self) -> gix::ObjectId; +} + +impl OidExt for git2::Oid { + fn to_gix(self) -> gix::ObjectId { + git2_to_gix_object_id(self) + } +} + pub fn gix_to_git2_oid(id: impl Into) -> git2::Oid { git2::Oid::from_bytes(id.into().as_bytes()).expect("always valid") } +pub trait ObjectIdExt { + fn to_git2(self) -> git2::Oid; +} + +impl ObjectIdExt for gix::ObjectId { + fn to_git2(self) -> git2::Oid { + gix_to_git2_oid(self) + } +} + pub fn git2_signature_to_gix_signature<'a>( input: impl Borrow>, ) -> gix::actor::Signature { @@ -101,3 +121,16 @@ pub fn gix_to_git2_index(index: &gix::index::State) -> anyhow::Result) { + let mut recorder = gix::traverse::tree::Recorder::default(); + tree.traverse().breadthfirst(&mut recorder).unwrap(); + let repo = tree.repo; + for record in recorder.records { + println!( + "{}: {}", + record.filepath, + repo.find_blob(record.oid).unwrap().data.as_bstr() + ); + } +} diff --git a/crates/gitbutler-testsupport/src/testing_repository.rs b/crates/gitbutler-testsupport/src/testing_repository.rs index 86f8c9b040..5c9bdb24e8 100644 --- a/crates/gitbutler-testsupport/src/testing_repository.rs +++ b/crates/gitbutler-testsupport/src/testing_repository.rs @@ -52,6 +52,10 @@ impl TestingRepository { } } + pub fn gix_repository(&self) -> gix::Repository { + gix::open(self.repository.path()).unwrap() + } + pub fn open_with_initial_commit(files: &[(&str, &str)]) -> Self { let tempdir = tempdir().unwrap(); let repository = git2::Repository::init_opts(tempdir.path(), &init_opts()).unwrap(); @@ -90,6 +94,7 @@ impl TestingRepository { ) -> git2::Commit<'a> { self.commit_tree_inner(parent, &Uuid::new_v4().to_string(), files, Some(change_id)) } + pub fn commit_tree<'a>( &'a self, parent: Option<&git2::Commit<'_>>,