mirror of
https://github.com/martinvonz/jj.git
synced 2025-05-29 11:01:13 +00:00
git: add function for exporting to underlying Git repo (#44)
This commit is contained in:
parent
aa78f97d55
commit
47b3abd0f7
118
lib/src/git.rs
118
lib/src/git.rs
@ -12,16 +12,20 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// 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 itertools::Itertools;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::backend::CommitId;
|
use crate::backend::CommitId;
|
||||||
use crate::commit::Commit;
|
use crate::commit::Commit;
|
||||||
use crate::op_store::RefTarget;
|
use crate::op_store::{OperationId, RefTarget};
|
||||||
use crate::repo::MutableRepo;
|
use crate::operation::Operation;
|
||||||
|
use crate::repo::{MutableRepo, ReadonlyRepo, RepoRef};
|
||||||
use crate::view::RefName;
|
use crate::view::RefName;
|
||||||
|
|
||||||
#[derive(Error, Debug, PartialEq)]
|
#[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(
|
pub fn import_refs(
|
||||||
mut_repo: &mut MutableRepo,
|
mut_repo: &mut MutableRepo,
|
||||||
git_repo: &git2::Repository,
|
git_repo: &git2::Repository,
|
||||||
@ -129,6 +133,110 @@ pub fn import_refs(
|
|||||||
Ok(())
|
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)]
|
#[derive(Error, Debug, PartialEq)]
|
||||||
pub enum GitFetchError {
|
pub enum GitFetchError {
|
||||||
#[error("No git remote named '{0}'")]
|
#[error("No git remote named '{0}'")]
|
||||||
|
@ -245,6 +245,7 @@ fn delete_git_ref(git_repo: &git2::Repository, name: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct GitRepoData {
|
struct GitRepoData {
|
||||||
|
settings: UserSettings,
|
||||||
_temp_dir: TempDir,
|
_temp_dir: TempDir,
|
||||||
origin_repo: git2::Repository,
|
origin_repo: git2::Repository,
|
||||||
git_repo: git2::Repository,
|
git_repo: git2::Repository,
|
||||||
@ -264,6 +265,7 @@ impl GitRepoData {
|
|||||||
std::fs::create_dir(&jj_repo_dir).unwrap();
|
std::fs::create_dir(&jj_repo_dir).unwrap();
|
||||||
let repo = ReadonlyRepo::init_external_git(&settings, jj_repo_dir, git_repo_dir);
|
let repo = ReadonlyRepo::init_external_git(&settings, jj_repo_dir, git_repo_dir);
|
||||||
Self {
|
Self {
|
||||||
|
settings,
|
||||||
_temp_dir: temp_dir,
|
_temp_dir: temp_dir,
|
||||||
origin_repo,
|
origin_repo,
|
||||||
git_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]
|
#[test]
|
||||||
fn test_init() {
|
fn test_init() {
|
||||||
let settings = testutils::user_settings();
|
let settings = testutils::user_settings();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user