From 47b3abd0f7e9c6945b22e957144c4475b44b571c Mon Sep 17 00:00:00 2001 From: Martin von Zweigbergk Date: Sat, 4 Dec 2021 09:49:50 -0800 Subject: [PATCH] git: add function for exporting to underlying Git repo (#44) --- lib/src/git.rs | 118 ++++++++++++++++++++++++++++++++++++++++-- lib/tests/test_git.rs | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 5 deletions(-) diff --git a/lib/src/git.rs b/lib/src/git.rs index 6c8fd8813..0674ff152 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -12,16 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::sync::Arc; -use git2::RemoteCallbacks; +use git2::{Oid, RemoteCallbacks}; use itertools::Itertools; use thiserror::Error; use crate::backend::CommitId; use crate::commit::Commit; -use crate::op_store::RefTarget; -use crate::repo::MutableRepo; +use crate::op_store::{OperationId, RefTarget}; +use crate::operation::Operation; +use crate::repo::{MutableRepo, ReadonlyRepo, RepoRef}; use crate::view::RefName; #[derive(Error, Debug, PartialEq)] @@ -47,7 +51,7 @@ fn parse_git_ref(ref_name: &str) -> Option { } } -// Reflect changes made in the underlying Git repo in the Jujutsu repo. +/// Reflect changes made in the underlying Git repo in the Jujutsu repo. pub fn import_refs( mut_repo: &mut MutableRepo, git_repo: &git2::Repository, @@ -129,6 +133,110 @@ pub fn import_refs( Ok(()) } +#[derive(Error, Debug, PartialEq)] +pub enum GitExportError { + #[error("Cannot export conflicted branch '{0}'")] + ConflictedBranch(String), + #[error("Unexpected git error when exporting refs: {0}")] + InternalGitError(#[from] git2::Error), +} + +/// Reflect changes between two Jujutsu repo states in the underlying Git repo. +pub fn export_changes( + old_repo: RepoRef, + new_repo: RepoRef, + git_repo: &git2::Repository, +) -> Result<(), GitExportError> { + let old_view = old_repo.view(); + let new_view = new_repo.view(); + let old_branches: HashSet<_> = old_view.branches().keys().cloned().collect(); + let new_branches: HashSet<_> = new_view.branches().keys().cloned().collect(); + // TODO: Check that the ref is not pointed to by any worktree's HEAD. + let mut active_branches = HashSet::new(); + if let Ok(head_ref) = git_repo.head() { + if let Some(head_target) = head_ref.name() { + active_branches.insert(head_target.to_string()); + } + } + let mut detach_head = false; + // First find the changes we want need to make and then make them all at once to + // reduce the risk of making some changes before we fail. + let mut refs_to_update = BTreeMap::new(); + let mut refs_to_delete = BTreeSet::new(); + for branch_name in old_branches.union(&new_branches) { + let old_branch = old_view.get_local_branch(branch_name); + let new_branch = new_view.get_local_branch(branch_name); + if new_branch == old_branch { + continue; + } + let git_ref_name = format!("refs/heads/{}", branch_name); + if let Some(new_branch) = new_branch { + match new_branch { + RefTarget::Normal(id) => { + refs_to_update.insert( + git_ref_name.clone(), + Oid::from_bytes(id.as_bytes()).unwrap(), + ); + } + RefTarget::Conflict { .. } => { + return Err(GitExportError::ConflictedBranch(branch_name.to_string())); + } + } + } else { + refs_to_delete.insert(git_ref_name.clone()); + } + if active_branches.contains(&git_ref_name) { + detach_head = true; + } + } + if detach_head { + let current_git_head_ref = git_repo.head()?; + let current_git_commit = current_git_head_ref.peel_to_commit()?; + git_repo.set_head_detached(current_git_commit.id())?; + } + for (git_ref_name, new_target) in refs_to_update { + git_repo.reference(&git_ref_name, new_target, true, "export from jj")?; + } + for git_ref_name in refs_to_delete { + if let Ok(mut git_ref) = git_repo.find_reference(&git_ref_name) { + git_ref.delete()?; + } + } + Ok(()) +} + +/// Reflect changes made in the Jujutsu repo since last export in the underlying +/// Git repo. If this is the first export, nothing will be exported. The +/// exported state's operation ID is recorded in the repo (`.jj/ +/// git_export_operation_id`). +pub fn export_refs( + repo: &Arc, + git_repo: &git2::Repository, +) -> Result<(), GitExportError> { + let last_export_path = repo.repo_path().join("git_export_operation_id"); + if let Ok(mut last_export_file) = OpenOptions::new().read(true).open(&last_export_path) { + let mut buf = vec![]; + last_export_file.read_to_end(&mut buf).unwrap(); + let last_export_op_id = OperationId::from_hex(String::from_utf8(buf).unwrap().as_str()); + let loader = repo.loader(); + let op_store = loader.op_store(); + let last_export_store_op = op_store.read_operation(&last_export_op_id).unwrap(); + let last_export_op = + Operation::new(op_store.clone(), last_export_op_id, last_export_store_op); + let old_repo = repo.loader().load_at(&last_export_op); + export_changes(old_repo.as_repo_ref(), repo.as_repo_ref(), git_repo)?; + } + if let Ok(mut last_export_file) = OpenOptions::new() + .write(true) + .create(true) + .open(&last_export_path) + { + let buf = repo.op_id().hex().as_bytes().to_vec(); + last_export_file.write_all(&buf).unwrap(); + } + Ok(()) +} + #[derive(Error, Debug, PartialEq)] pub enum GitFetchError { #[error("No git remote named '{0}'")] diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index bd51e6f0a..b37fdd217 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -245,6 +245,7 @@ fn delete_git_ref(git_repo: &git2::Repository, name: &str) { } struct GitRepoData { + settings: UserSettings, _temp_dir: TempDir, origin_repo: git2::Repository, git_repo: git2::Repository, @@ -264,6 +265,7 @@ impl GitRepoData { std::fs::create_dir(&jj_repo_dir).unwrap(); let repo = ReadonlyRepo::init_external_git(&settings, jj_repo_dir, git_repo_dir); Self { + settings, _temp_dir: temp_dir, origin_repo, git_repo, @@ -316,6 +318,113 @@ fn test_import_refs_detached_head() { ); } +#[test] +fn test_export_refs_initial() { + // The first export doesn't do anything + let mut test_data = GitRepoData::create(); + let git_repo = test_data.git_repo; + let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); + git_repo.set_head("refs/heads/main").unwrap(); + let mut tx = test_data.repo.start_transaction("test"); + git::import_refs(tx.mut_repo(), &git_repo).unwrap(); + test_data.repo = tx.commit(); + + // The first export shouldn't do anything + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + assert_eq!(git_repo.head().unwrap().name(), Some("refs/heads/main")); + assert_eq!( + git_repo.find_reference("refs/heads/main").unwrap().target(), + Some(commit1.id()) + ); +} + +#[test] +fn test_export_refs_no_op() { + // Nothing changes on the git side if nothing changed on the jj side + let mut test_data = GitRepoData::create(); + let git_repo = test_data.git_repo; + let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); + git_repo.set_head("refs/heads/main").unwrap(); + + let mut tx = test_data.repo.start_transaction("test"); + git::import_refs(tx.mut_repo(), &git_repo).unwrap(); + test_data.repo = tx.commit(); + + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + // The export should be a no-op since nothing changed on the jj side since last + // export + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + assert_eq!(git_repo.head().unwrap().name(), Some("refs/heads/main")); + assert_eq!( + git_repo.find_reference("refs/heads/main").unwrap().target(), + Some(commit1.id()) + ); +} + +#[test] +fn test_export_refs_branch_changed() { + // We can export a change to a branch + let mut test_data = GitRepoData::create(); + let git_repo = test_data.git_repo; + let commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); + git_repo + .reference("refs/heads/feature", commit.id(), false, "test") + .unwrap(); + git_repo.set_head("refs/heads/feature").unwrap(); + + let mut tx = test_data.repo.start_transaction("test"); + git::import_refs(tx.mut_repo(), &git_repo).unwrap(); + test_data.repo = tx.commit(); + + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + let mut tx = test_data.repo.start_transaction("test"); + let new_commit = testutils::create_random_commit(&test_data.settings, &test_data.repo) + .set_parents(vec![CommitId::from_bytes(commit.id().as_bytes())]) + .write_to_repo(tx.mut_repo()); + tx.mut_repo().set_local_branch( + "main".to_string(), + RefTarget::Normal(new_commit.id().clone()), + ); + test_data.repo = tx.commit(); + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + assert_eq!( + git_repo + .find_reference("refs/heads/main") + .unwrap() + .peel_to_commit() + .unwrap() + .id(), + Oid::from_bytes(new_commit.id().as_bytes()).unwrap() + ); + // HEAD should be unchanged since its target branch didn't change + assert_eq!(git_repo.head().unwrap().name(), Some("refs/heads/feature")); +} + +#[test] +fn test_export_refs_current_branch_changed() { + // If we update a branch that is checked out in the git repo, HEAD gets detached + let mut test_data = GitRepoData::create(); + let git_repo = test_data.git_repo; + let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); + git_repo.set_head("refs/heads/main").unwrap(); + let mut tx = test_data.repo.start_transaction("test"); + git::import_refs(tx.mut_repo(), &git_repo).unwrap(); + test_data.repo = tx.commit(); + + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + let mut tx = test_data.repo.start_transaction("test"); + let new_commit = testutils::create_random_commit(&test_data.settings, &test_data.repo) + .set_parents(vec![CommitId::from_bytes(commit1.id().as_bytes())]) + .write_to_repo(tx.mut_repo()); + tx.mut_repo().set_local_branch( + "main".to_string(), + RefTarget::Normal(new_commit.id().clone()), + ); + test_data.repo = tx.commit(); + assert_eq!(git::export_refs(&test_data.repo, &git_repo), Ok(())); + assert!(git_repo.head_detached().unwrap()); +} + #[test] fn test_init() { let settings = testutils::user_settings();