git: add function for exporting to underlying Git repo (#44)

This commit is contained in:
Martin von Zweigbergk 2021-12-04 09:49:50 -08:00
parent aa78f97d55
commit 47b3abd0f7
2 changed files with 222 additions and 5 deletions

View file

@ -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<RefName> {
}
}
// 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<ReadonlyRepo>,
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}'")]

View file

@ -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();