// Copyright 2020 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::io; use std::io::Write as _; use std::iter; use std::path::Path; use std::path::PathBuf; use std::slice; use std::sync::Arc; use std::sync::Barrier; use std::sync::mpsc; use std::thread; use assert_matches::assert_matches; use gix::remote::Direction; use itertools::Itertools as _; use jj_lib::backend::BackendError; use jj_lib::backend::ChangeId; use jj_lib::backend::CommitId; use jj_lib::backend::MillisSinceEpoch; use jj_lib::backend::Signature; use jj_lib::backend::Timestamp; use jj_lib::commit::Commit; use jj_lib::commit_builder::CommitBuilder; use jj_lib::config::ConfigLayer; use jj_lib::config::ConfigSource; use jj_lib::git; use jj_lib::git::FailedRefExportReason; use jj_lib::git::GitFetch; use jj_lib::git::GitFetchError; use jj_lib::git::GitFetchRefExpression; use jj_lib::git::GitImportError; use jj_lib::git::GitImportOptions; use jj_lib::git::GitImportStats; use jj_lib::git::GitPushError; use jj_lib::git::GitPushOptions; use jj_lib::git::GitPushRefTargets; use jj_lib::git::GitPushStats; use jj_lib::git::GitRefKind; use jj_lib::git::GitRefUpdate; use jj_lib::git::GitResetHeadError; use jj_lib::git::GitSettings; use jj_lib::git::GitSidebandLineTerminator; use jj_lib::git::GitSubprocessCallback; use jj_lib::git::GitSubprocessOptions; use jj_lib::git::IgnoredRefspec; use jj_lib::git::IgnoredRefspecs; use jj_lib::git::expand_fetch_refspecs; use jj_lib::git::load_default_fetch_bookmarks; use jj_lib::git_backend::GitBackend; use jj_lib::hex_util; use jj_lib::index::ResolvedChangeTargets; use jj_lib::merge::Diff; use jj_lib::merge::Merge; use jj_lib::object_id::ObjectId as _; use jj_lib::op_store::LocalRemoteRefTarget; use jj_lib::op_store::RefTarget; use jj_lib::op_store::RemoteRef; use jj_lib::op_store::RemoteRefState; use jj_lib::ref_name::GitRefNameBuf; use jj_lib::ref_name::RefName; use jj_lib::ref_name::RemoteName; use jj_lib::ref_name::RemoteRefSymbol; use jj_lib::repo::MutableRepo; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo as _; use jj_lib::settings::UserSettings; use jj_lib::signing::Signer; use jj_lib::str_util::StringExpression; use jj_lib::str_util::StringMatcher; use jj_lib::workspace::Workspace; use maplit::btreemap; use maplit::hashset; use pollster::FutureExt as _; use tempfile::TempDir; use test_case::test_case; use testutils::CommitBuilderExt as _; use testutils::TestRepo; use testutils::TestRepoBackend; use testutils::TestResult; use testutils::base_user_config; use testutils::commit_transactions; use testutils::create_random_commit; use testutils::repo_path; use testutils::write_random_commit; use testutils::write_random_commit_with_parents; #[derive(Debug)] struct NullCallback; impl GitSubprocessCallback for NullCallback { fn needs_progress(&self) -> bool { false } fn progress(&mut self, _progress: &git::GitProgress) -> io::Result<()> { Ok(()) } fn local_sideband( &mut self, _message: &[u8], _term: Option, ) -> io::Result<()> { Ok(()) } fn remote_sideband( &mut self, _message: &[u8], _term: Option, ) -> io::Result<()> { Ok(()) } } fn empty_git_commit( git_repo: &gix::Repository, ref_name: &str, parents: &[gix::ObjectId], ) -> gix::ObjectId { let empty_tree_id = git_repo.empty_tree().id().detach(); testutils::git::write_commit( git_repo, ref_name, empty_tree_id, &format!("random commit {}", rand::random::()), parents, ) } fn jj_id(id: gix::ObjectId) -> CommitId { CommitId::from_bytes(id.as_bytes()) } fn git_id(commit: &Commit) -> gix::ObjectId { gix::ObjectId::from_bytes_or_panic(commit.id().as_bytes()) } fn remote_symbol<'a, N, M>(name: &'a N, remote: &'a M) -> RemoteRefSymbol<'a> where N: AsRef + ?Sized, M: AsRef + ?Sized, { RemoteRefSymbol { name: name.as_ref(), remote: remote.as_ref(), } } fn get_git_backend(repo: &Arc) -> &GitBackend { repo.store().backend_impl().unwrap() } fn get_git_repo(repo: &Arc) -> gix::Repository { get_git_backend(repo).git_repo() } fn init_external_git_repo(test_repo: &TestRepo, name: &Path) -> TestResult> { let settings = test_repo.repo.settings(); let git_repo_path = get_git_backend(&test_repo.repo).git_repo_path(); let repo_dir = test_repo.env.root().join(name); fs::create_dir(&repo_dir)?; let repo = ReadonlyRepo::init( settings, &repo_dir, &|settings, store_path| { let backend = GitBackend::init_external(settings, store_path, git_repo_path)?; Ok(Box::new(backend)) }, Signer::from_settings(settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), ReadonlyRepo::default_submodule_store_initializer(), ) .block_on()?; Ok(repo) } fn find_unique_successor(repo: &ReadonlyRepo, old_id: &CommitId) -> Option { let op = repo.operation(); let predecessors = op.store_operation().commit_predecessors.as_ref()?; let new_id = predecessors .iter() .filter(|(_, old_ids)| old_ids.contains(old_id)) .map(|(new_id, _)| new_id) .exactly_one() .ok()?; Some(repo.store().get_commit(new_id).unwrap()) } #[track_caller] fn rewrite_commit(repo: &mut MutableRepo, predecessor: &Commit, description: &str) -> Commit { repo.rewrite_commit(predecessor) .set_description(description) .write() .block_on() .unwrap() } /// Fetches and imports all refs with the default configuration. fn fetch_import_all(mut_repo: &mut MutableRepo, remote: &RemoteName) -> GitImportStats { let git_settings = GitSettings::from_settings(mut_repo.base_repo().settings()).unwrap(); let import_options = default_import_options(); let mut fetcher = GitFetch::new( mut_repo, git_settings.to_subprocess_options(), &import_options, ) .unwrap(); fetch_all_with(&mut fetcher, remote).unwrap(); fetcher.import_refs().block_on().unwrap() } /// Fetches all refs without importing. fn fetch_all_with(fetcher: &mut GitFetch, remote: &RemoteName) -> Result<(), GitFetchError> { let ref_expr = GitFetchRefExpression { bookmark: StringExpression::all(), tag: StringExpression::all(), }; fetch_with(fetcher, remote, ref_expr) } /// Fetches the specified refs without importing. fn fetch_with( fetcher: &mut GitFetch, remote: &RemoteName, ref_expr: GitFetchRefExpression, ) -> Result<(), GitFetchError> { let refspecs = expand_fetch_refspecs(remote, ref_expr).expect("ref patterns should be valid"); let depth = None; fetcher.fetch(remote, refspecs, &mut NullCallback, depth) } fn push_status_rejected_references(push_stats: GitPushStats) -> Vec { assert!(push_stats.pushed.is_empty()); assert!(push_stats.remote_rejected.is_empty()); push_stats .rejected .into_iter() .map(|(reference, _)| reference) .collect() } #[test] fn test_import_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); git_ref(&git_repo, "refs/remotes/origin/main", commit1); let commit2 = empty_git_commit(&git_repo, "refs/heads/main", &[commit1]); let commit3 = empty_git_commit(&git_repo, "refs/heads/feature1", &[commit2]); let commit4 = empty_git_commit(&git_repo, "refs/heads/feature2", &[commit2]); let commit5 = empty_git_commit(&git_repo, "refs/tags/v1.0", &[commit1]); let commit6 = empty_git_commit(&git_repo, "refs/remotes/origin/feature3", &[commit1]); // Should not be imported empty_git_commit(&git_repo, "refs/notes/x", &[commit2]); empty_git_commit(&git_repo, "refs/remotes/origin/HEAD", &[commit2]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let mut tx = repo.start_transaction(); git::import_head(tx.repo_mut()).block_on()?; let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); let expected_heads = hashset! { jj_id(commit3), jj_id(commit4), jj_id(commit5), jj_id(commit6), }; assert_eq!(*view.heads(), expected_heads); assert_eq!(view.bookmarks().count(), 4); assert_eq!( view.get_local_bookmark("main".as_ref()), &RefTarget::normal(jj_id(commit2)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit2)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit1)), state: RemoteRefState::New, }, ); assert_eq!( view.get_local_bookmark("feature1".as_ref()), &RefTarget::normal(jj_id(commit3)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature1", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit3)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature1", "origin")), RemoteRef::absent_ref() ); assert_eq!( view.get_local_bookmark("feature2".as_ref()), &RefTarget::normal(jj_id(commit4)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature2", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit4)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature2", "origin")), RemoteRef::absent_ref() ); assert_eq!( view.get_local_bookmark("feature3".as_ref()), RefTarget::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature3", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature3", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit6)), state: RemoteRefState::New, }, ); assert_eq!( view.get_local_tag("v1.0".as_ref()), &RefTarget::normal(jj_id(commit5)) ); assert_eq!( view.get_remote_tag(remote_symbol("v1.0", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit5)), state: RemoteRefState::Tracked, }, ); assert_eq!(view.git_refs().len(), 6); assert_eq!( view.get_git_ref("refs/heads/main".as_ref()), &RefTarget::normal(jj_id(commit2)) ); assert_eq!( view.get_git_ref("refs/heads/feature1".as_ref()), &RefTarget::normal(jj_id(commit3)) ); assert_eq!( view.get_git_ref("refs/heads/feature2".as_ref()), &RefTarget::normal(jj_id(commit4)) ); assert_eq!( view.get_git_ref("refs/remotes/origin/main".as_ref()), &RefTarget::normal(jj_id(commit1)) ); assert_eq!( view.get_git_ref("refs/remotes/origin/feature3".as_ref()), &RefTarget::normal(jj_id(commit6)) ); assert_eq!( view.get_git_ref("refs/tags/v1.0".as_ref()), &RefTarget::normal(jj_id(commit5)) ); assert_eq!(view.git_head(), &RefTarget::normal(jj_id(commit2))); Ok(()) } #[test] fn test_import_refs_reimport() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); git_ref(&git_repo, "refs/remotes/origin/main", commit1); let commit2 = empty_git_commit(&git_repo, "refs/heads/main", &[commit1]); let commit3 = empty_git_commit(&git_repo, "refs/heads/feature1", &[commit2]); let commit4 = empty_git_commit(&git_repo, "refs/heads/feature2", &[commit2]); let pgp_key_oid = git_repo.write_blob(b"my PGP key")?.detach(); git_repo.reference( "refs/tags/my-gpg-key", pgp_key_oid, gix::refs::transaction::PreviousValue::MustNotExist, "", )?; let mut tx = repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); let expected_heads = hashset! { jj_id(commit3), jj_id(commit4), }; let view = repo.view(); assert_eq!(*view.heads(), expected_heads); // Delete feature1 and rewrite feature2 delete_git_ref(&git_repo, "refs/heads/feature1"); delete_git_ref(&git_repo, "refs/heads/feature2"); let commit5 = empty_git_commit(&git_repo, "refs/heads/feature2", &[commit2]); // Also modify feature2 on the jj side let mut tx = repo.start_transaction(); let commit6 = create_random_commit(tx.repo_mut()) .set_parents(vec![jj_id(commit2)]) .write_unwrap(); tx.repo_mut() .set_local_bookmark_target("feature2".as_ref(), RefTarget::normal(commit6.id().clone())); let repo = tx.commit("test").block_on()?; let mut tx = repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; assert_eq!( // The order is unstable just because we import heads from Git repo. HashSet::from_iter(stats.abandoned_commits.iter().map(Commit::id)), HashSet::from([&jj_id(commit4), &jj_id(commit3)]), ); assert!(stats.rewritten_commit_ids.is_empty()); let view = repo.view(); let expected_heads = hashset! { jj_id(commit5), commit6.id().clone(), }; assert_eq!(*view.heads(), expected_heads); assert_eq!(view.bookmarks().count(), 2); let commit1_target = RefTarget::normal(jj_id(commit1)); let commit2_target = RefTarget::normal(jj_id(commit2)); assert_eq!( view.get_local_bookmark("main".as_ref()), &RefTarget::normal(jj_id(commit2)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit2)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: commit1_target.clone(), state: RemoteRefState::New, }, ); assert_eq!( view.get_local_bookmark("feature2".as_ref()), &RefTarget::from_legacy_form([jj_id(commit4)], [commit6.id().clone(), jj_id(commit5)]) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature2", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit5)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature2", "origin")), RemoteRef::absent_ref() ); assert_eq!(view.local_tags().count(), 0); assert_eq!(view.git_refs().len(), 3); assert_eq!( view.get_git_ref("refs/heads/main".as_ref()), &commit2_target ); assert_eq!( view.get_git_ref("refs/remotes/origin/main".as_ref()), &commit1_target ); let commit5_target = RefTarget::normal(jj_id(commit5)); assert_eq!( view.get_git_ref("refs/heads/feature2".as_ref()), &commit5_target ); Ok(()) } #[test] fn test_import_refs_reimport_head_removed() -> TestResult { // Test that re-importing refs doesn't cause a deleted head to come back let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let commit_id = jj_id(commit); // Test the setup assert!(tx.repo().view().heads().contains(&commit_id)); // Remove the head and re-import tx.repo_mut().remove_head(&commit_id); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(!tx.repo().view().heads().contains(&commit_id)); Ok(()) } #[test] fn test_import_refs_reimport_git_head_does_not_count() -> TestResult { // Test that if a bookmark is removed, the corresponding commit is abandoned // no matter if the Git HEAD points to the commit (or a descendant of it.) let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); testutils::git::set_head_to_id(&git_repo, commit); let mut tx = repo.start_transaction(); git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; // Delete the bookmark and re-import. The commit should still be there since // HEAD points to it git_repo.find_reference("refs/heads/main")?.delete()?; git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(!tx.repo().view().heads().contains(&jj_id(commit))); Ok(()) } #[test] fn test_import_refs_reimport_git_head_without_ref() -> TestResult { // Simulate external `git checkout` in colocated workspace, from anonymous // bookmark. let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // First, HEAD points to commit1. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit(tx.repo_mut()); testutils::git::set_head_to_id(&git_repo, git_id(&commit1)); // Import HEAD. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); // Move HEAD to commit2 (by e.g. `git checkout` command) testutils::git::set_head_to_id(&git_repo, git_id(&commit2)); // Reimport HEAD, which doesn't abandon the old HEAD branch because jj thinks it // would be moved by `git checkout` command. This isn't always true because the // detached HEAD commit could be rewritten by e.g. `git commit --amend` command, // but it should be safer than abandoning old checkout branch. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); Ok(()) } #[test] fn test_import_refs_reimport_git_head_with_moved_ref() -> TestResult { // Simulate external history rewriting in colocated workspace. let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // First, both HEAD and main point to commit1. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit(tx.repo_mut()); git_repo.reference( "refs/heads/main", git_id(&commit1), gix::refs::transaction::PreviousValue::Any, "test", )?; testutils::git::set_head_to_id(&git_repo, git_id(&commit1)); // Import HEAD and main. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); // Move both HEAD and main to commit2 (by e.g. `git commit --amend` command) git_repo.reference( "refs/heads/main", git_id(&commit2), gix::refs::transaction::PreviousValue::Any, "test", )?; testutils::git::set_head_to_id(&git_repo, git_id(&commit2)); // Reimport HEAD and main, which abandons the old main branch. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(!tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); // Reimport HEAD and main, which abandons the old main bookmark. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(!tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); Ok(()) } #[test] fn test_import_refs_reimport_with_deleted_remote_ref() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = auto_track_import_options(); let commit_base = empty_git_commit(&git_repo, "refs/heads/main", &[]); let commit_main = empty_git_commit(&git_repo, "refs/heads/main", &[commit_base]); let commit_remote_only = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-only", &[commit_base], ); let commit_remote_and_local = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-and-local", &[commit_base], ); git_ref( &git_repo, "refs/heads/feature-remote-and-local", commit_remote_and_local, ); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let expected_heads = hashset! { jj_id(commit_main), jj_id(commit_remote_only), jj_id(commit_remote_and_local), }; let view = repo.view(); assert_eq!(*view.heads(), expected_heads); assert_eq!(view.bookmarks().count(), 3); // Even though the git repo does not have a local bookmark for // `feature-remote-only`, jj creates one. This follows the model explained // in docs/bookmarks.md. assert_eq!( view.get_local_bookmark("feature-remote-only".as_ref()), &RefTarget::normal(jj_id(commit_remote_only)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_only)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_local_bookmark("feature-remote-and-local".as_ref()), &RefTarget::normal(jj_id(commit_remote_and_local)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert!(view.get_local_bookmark("main".as_ref()).is_present()); // bookmark #3 of 3 // Simulate fetching from a remote where feature-remote-only and // feature-remote-and-local bookmarks were deleted. This leads to the // following import deleting the corresponding local bookmarks. delete_git_ref(&git_repo, "refs/remotes/origin/feature-remote-only"); delete_git_ref(&git_repo, "refs/remotes/origin/feature-remote-and-local"); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); // The local bookmarks were indeed deleted assert_eq!(view.bookmarks().count(), 2); assert!(view.get_local_bookmark("main".as_ref()).is_present()); assert!( view.get_local_bookmark("feature-remote-only".as_ref()) .is_absent() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "origin")), RemoteRef::absent_ref() ); assert!( view.get_local_bookmark("feature-remote-and-local".as_ref()) .is_absent() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "origin")), RemoteRef::absent_ref() ); let expected_heads = hashset! { jj_id(commit_main), // Neither commit_remote_only nor commit_remote_and_local should be // listed as a head. commit_remote_only was never affected by #864, // but commit_remote_and_local was. }; assert_eq!(*view.heads(), expected_heads); Ok(()) } /// This test is nearly identical to the previous one, except the bookmarks are /// moved sideways instead of being deleted. #[test] fn test_import_refs_reimport_with_moved_remote_ref() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = auto_track_import_options(); let commit_base = empty_git_commit(&git_repo, "refs/heads/main", &[]); let commit_main = empty_git_commit(&git_repo, "refs/heads/main", &[commit_base]); let commit_remote_only = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-only", &[commit_base], ); let commit_remote_and_local = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-and-local", &[commit_base], ); git_ref( &git_repo, "refs/heads/feature-remote-and-local", commit_remote_and_local, ); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let expected_heads = hashset! { jj_id(commit_main), jj_id(dbg!(commit_remote_only)), jj_id(dbg!(commit_remote_and_local)), }; let view = repo.view(); assert_eq!(*view.heads(), expected_heads); assert_eq!(view.bookmarks().count(), 3); // Even though the git repo does not have a local bookmark for // `feature-remote-only`, jj creates one. This follows the model explained // in docs/bookmarks.md. assert_eq!( view.get_local_bookmark("feature-remote-only".as_ref()), &RefTarget::normal(jj_id(commit_remote_only)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_only)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_local_bookmark("feature-remote-and-local".as_ref()), &RefTarget::normal(jj_id(commit_remote_and_local)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert!(view.get_local_bookmark("main".as_ref()).is_present()); // bookmark #3 of 3 // Simulate fetching from a remote where feature-remote-only and // feature-remote-and-local bookmarks were moved. This leads to the // following import moving the corresponding local bookmarks. delete_git_ref(&git_repo, "refs/remotes/origin/feature-remote-only"); delete_git_ref(&git_repo, "refs/remotes/origin/feature-remote-and-local"); let new_commit_remote_only = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-only", &[commit_base], ); let new_commit_remote_and_local = empty_git_commit( &git_repo, "refs/remotes/origin/feature-remote-and-local", &[commit_base], ); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert_eq!(view.bookmarks().count(), 3); // The local bookmarks are moved assert_eq!( view.get_local_bookmark("feature-remote-only".as_ref()), &RefTarget::normal(jj_id(new_commit_remote_only)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-only", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(new_commit_remote_only)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_local_bookmark("feature-remote-and-local".as_ref()), &RefTarget::normal(jj_id(new_commit_remote_and_local)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "git")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-remote-and-local", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(new_commit_remote_and_local)), state: RemoteRefState::Tracked, }, ); assert!(view.get_local_bookmark("main".as_ref()).is_present()); // bookmark #3 of 3 let expected_heads = hashset! { jj_id(commit_main), jj_id(new_commit_remote_and_local), jj_id(new_commit_remote_only), // Neither commit_remote_only nor commit_remote_and_local should be // listed as a head. commit_remote_only was never affected by #864, // but commit_remote_and_local was. }; assert_eq!(*view.heads(), expected_heads); Ok(()) } #[test] fn test_import_refs_reimport_with_moved_untracked_remote_ref() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // The base commit doesn't have a reference. let remote_ref_name = "refs/remotes/origin/feature"; let commit_base = empty_git_commit(&git_repo, remote_ref_name, &[]); let commit_remote_t0 = empty_git_commit(&git_repo, remote_ref_name, &[commit_base]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_t0) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 1); assert_eq!( view.get_remote_bookmark(remote_symbol("feature", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_t0)), state: RemoteRefState::New, }, ); // Move the reference remotely and fetch the changes. delete_git_ref(&git_repo, remote_ref_name); let commit_remote_t1 = empty_git_commit(&git_repo, remote_ref_name, &[commit_base]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); // commit_remote_t0 should be abandoned, but commit_base shouldn't because // it's the ancestor of commit_remote_t1. assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_t1) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 1); assert_eq!( view.get_remote_bookmark(remote_symbol("feature", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_t1)), state: RemoteRefState::New, }, ); Ok(()) } #[test] fn test_import_refs_reimport_with_deleted_untracked_intermediate_remote_ref() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // Set up linear graph: // o feature-b@origin // o feature-a@origin let remote_ref_name_a = "refs/remotes/origin/feature-a"; let remote_ref_name_b = "refs/remotes/origin/feature-b"; let commit_remote_a = empty_git_commit(&git_repo, remote_ref_name_a, &[]); let commit_remote_b = empty_git_commit(&git_repo, remote_ref_name_b, &[commit_remote_a]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_b) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 2); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-a", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_a)), state: RemoteRefState::New, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-b", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_b)), state: RemoteRefState::New, }, ); // Delete feature-a remotely and fetch the changes. delete_git_ref(&git_repo, remote_ref_name_a); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); // No commits should be abandoned because feature-a is pinned by feature-b. // Otherwise, feature-b would have to be rebased locally even though the // user haven't made any modifications to these commits yet. assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_b) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 1); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-b", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_b)), state: RemoteRefState::New, }, ); Ok(()) } #[test] fn test_import_refs_reimport_with_deleted_abandoned_untracked_remote_ref() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // Set up linear graph: // o feature-b@origin // o feature-a@origin let remote_ref_name_a = "refs/remotes/origin/feature-a"; let remote_ref_name_b = "refs/remotes/origin/feature-b"; let commit_remote_a = empty_git_commit(&git_repo, remote_ref_name_a, &[]); let commit_remote_b = empty_git_commit(&git_repo, remote_ref_name_b, &[commit_remote_a]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_b) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 2); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-a", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_a)), state: RemoteRefState::New, }, ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-b", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_b)), state: RemoteRefState::New, }, ); // Abandon feature-b locally: // x feature-b@origin (hidden) // o feature-a@origin let mut tx = repo.start_transaction(); let jj_commit_remote_b = tx.repo().store().get_commit(&jj_id(commit_remote_b))?; tx.repo_mut().record_abandoned_commit(&jj_commit_remote_b); tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); assert_eq!(*view.heads(), hashset! { jj_id(commit_remote_a) }); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 2); // Delete feature-a remotely and fetch the changes. delete_git_ref(&git_repo, remote_ref_name_a); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let view = repo.view(); // The feature-a commit should be abandoned. Since feature-b has already // been abandoned, there are no descendant commits to be rebased. assert_eq!( *view.heads(), hashset! { repo.store().root_commit_id().clone() } ); assert_eq!(view.local_bookmarks().count(), 0); assert_eq!(view.all_remote_bookmarks().count(), 1); assert_eq!( view.get_remote_bookmark(remote_symbol("feature-b", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_remote_b)), state: RemoteRefState::New, }, ); Ok(()) } #[test] fn test_import_refs_reimport_absent_tracked_remote_bookmarks() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let absent_tracked_ref = RemoteRef { target: RefTarget::absent(), state: RemoteRefState::Tracked, }; // Set up absent tracked refs. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit_with_parents(tx.repo_mut(), &[&commit1]); tx.repo_mut() .set_local_bookmark_target("foo".as_ref(), RefTarget::normal(commit1.id().clone())); tx.repo_mut() .set_remote_bookmark(remote_symbol("foo", "origin"), absent_tracked_ref.clone()); tx.repo_mut() .set_remote_bookmark(remote_symbol("foo", "upstream"), absent_tracked_ref.clone()); let repo = tx.commit("test").block_on()?; // Import with no change. let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("test").block_on()?; // Absent tracked remote refs shouldn't be deleted. assert_eq!( repo.view().all_remote_bookmarks().collect_vec(), vec![ (remote_symbol("foo", "origin"), &absent_tracked_ref), (remote_symbol("foo", "upstream"), &absent_tracked_ref), ] ); // foo: commit1 // foo@origin: absent -> commit2 (= descendant of commit1) git_repo.reference( "refs/remotes/origin/foo", git_id(&commit2), gix::refs::transaction::PreviousValue::Any, "test", )?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("test").block_on()?; // Tracked refs should be merged and their state should be preserved. assert_eq!( repo.view().get_local_bookmark("foo".as_ref()), &RefTarget::normal(commit2.id().clone()) ); assert_eq!( repo.view() .get_remote_bookmark(remote_symbol("foo", "origin")), &RemoteRef { target: RefTarget::normal(commit2.id().clone()), state: RemoteRefState::Tracked, } ); assert_eq!( repo.view() .get_remote_bookmark(remote_symbol("foo", "upstream")), &absent_tracked_ref ); Ok(()) } #[test] fn test_import_refs_reimport_absent_tracked_remote_tags() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let absent_tracked_ref = RemoteRef { target: RefTarget::absent(), state: RemoteRefState::Tracked, }; // Set up absent tracked refs. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit(tx.repo_mut()); let commit3 = write_random_commit(tx.repo_mut()); tx.repo_mut() .set_local_tag_target("bar".as_ref(), RefTarget::normal(commit1.id().clone())); tx.repo_mut() .set_local_tag_target("foo".as_ref(), RefTarget::normal(commit2.id().clone())); tx.repo_mut() .set_remote_tag(remote_symbol("bar", "git"), absent_tracked_ref.clone()); tx.repo_mut() .set_remote_tag(remote_symbol("foo", "git"), absent_tracked_ref.clone()); let repo = tx.commit("test").block_on()?; // Import with no change. let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("test").block_on()?; // Absent tracked remote refs shouldn't be deleted. assert_eq!( repo.view().all_remote_tags().collect_vec(), vec![ (remote_symbol("bar", "git"), &absent_tracked_ref), (remote_symbol("foo", "git"), &absent_tracked_ref), ] ); // foo: commit2 // foo@git: absent -> commit3 (= sibling of commit4) git_repo.reference( "refs/tags/foo", git_id(&commit3), gix::refs::transaction::PreviousValue::Any, "test", )?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("test").block_on()?; // Tracked refs should be merged and their state should be preserved. assert_eq!( repo.view().get_local_tag("foo".as_ref()), &RefTarget::from_merge(Merge::from_vec(vec![ Some(commit2.id().clone()), None, Some(commit3.id().clone()), ])), ); assert_eq!( repo.view().get_remote_tag(remote_symbol("bar", "git")), &absent_tracked_ref ); assert_eq!( repo.view().get_remote_tag(remote_symbol("foo", "git")), &RemoteRef { target: RefTarget::normal(commit3.id().clone()), state: RemoteRefState::Tracked, } ); Ok(()) } #[test] fn test_import_refs_reimport_remote_tags_deleted() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let import_options = default_import_options(); // Set up tags that don't exist in Git repo. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let target1 = RefTarget::normal(commit1.id().clone()); let remote_ref1 = RemoteRef { target: target1.clone(), state: RemoteRefState::Tracked, }; tx.repo_mut() .set_local_tag_target("tag1".as_ref(), target1.clone()); tx.repo_mut() .set_remote_tag(remote_symbol("tag1", "git"), remote_ref1.clone()); tx.repo_mut() .set_remote_tag(remote_symbol("tag1", "origin"), remote_ref1.clone()); let repo = tx.commit("test").block_on()?; // Import "deleted" tags from Git repo. let mut tx = repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; assert_eq!(stats.changed_remote_tags.len(), 1); assert_eq!( stats.changed_remote_tags[0].symbol, remote_symbol("tag1", "git") ); // Deleted local and @git tags should be imported. assert!(repo.view().get_local_tag("tag1".as_ref()).is_absent()); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "git")), RemoteRef::absent_ref() ); // Since Git doesn't have real remote tags, other remote tags shouldn't be // updated. assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "origin")), &remote_ref1 ); Ok(()) } #[test] fn test_import_refs_reimport_git_head_with_fixed_ref() -> TestResult { // Simulate external `git checkout` in colocated workspace, from named bookmark. let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // First, both HEAD and main point to commit1. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit(tx.repo_mut()); git_repo.reference( "refs/heads/main", git_id(&commit1), gix::refs::transaction::PreviousValue::Any, "test", )?; testutils::git::set_head_to_id(&git_repo, git_id(&commit1)); // Import HEAD and main. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); // Move only HEAD to commit2 (by e.g. `git checkout` command) testutils::git::set_head_to_id(&git_repo, git_id(&commit2)); // Reimport HEAD, which shouldn't abandon the old HEAD branch. git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(commit1.id())); assert!(tx.repo().view().heads().contains(commit2.id())); Ok(()) } #[test] fn test_import_refs_reimport_all_from_root_removed() -> TestResult { // Test that if a chain of commits all the way from the root gets unreferenced, // we abandon the whole stack, but not including the root commit. let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; // Test the setup assert!(tx.repo().view().heads().contains(&jj_id(commit))); // Remove all git refs and re-import git_repo.find_reference("refs/heads/main")?.delete()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(!tx.repo().view().heads().contains(&jj_id(commit))); Ok(()) } #[test] fn test_import_refs_reimport_abandoning_disabled() -> TestResult { // Test that we don't abandoned unreachable commits if configured not to let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = GitImportOptions { abandon_unreachable_commits: false, ..default_import_options() }; let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); let commit2 = empty_git_commit(&git_repo, "refs/heads/delete-me", &[commit1]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; // Test the setup assert!(tx.repo().view().heads().contains(&jj_id(commit2))); // Remove the `delete-me` bookmark and re-import git_repo.find_reference("refs/heads/delete-me")?.delete()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; assert!(tx.repo().view().heads().contains(&jj_id(commit2))); Ok(()) } #[test] fn test_import_refs_reimport_conflicted_remote_bookmark() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit1 = empty_git_commit(&git_repo, "refs/heads/commit1", &[]); git_ref(&git_repo, "refs/remotes/origin/main", commit1); let mut tx1 = repo.start_transaction(); git::import_refs(tx1.repo_mut(), &import_options).block_on()?; let commit2 = empty_git_commit(&git_repo, "refs/heads/commit2", &[]); git_ref(&git_repo, "refs/remotes/origin/main", commit2); let mut tx2 = repo.start_transaction(); git::import_refs(tx2.repo_mut(), &import_options).block_on()?; // Remote bookmark can diverge by divergent operations (like `jj git fetch`) let repo = commit_transactions(vec![tx1, tx2]); assert_eq!( repo.view().get_git_ref("refs/remotes/origin/main".as_ref()), &RefTarget::from_legacy_form([], [jj_id(commit1), jj_id(commit2)]), ); assert_eq!( repo.view() .get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::from_legacy_form([], [jj_id(commit1), jj_id(commit2)]), state: RemoteRefState::New, }, ); // The conflict can be resolved by importing the current Git state let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("test").block_on()?; assert_eq!( repo.view().get_git_ref("refs/remotes/origin/main".as_ref()), &RefTarget::normal(jj_id(commit2)), ); assert_eq!( repo.view() .get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit2)), state: RemoteRefState::New, }, ); Ok(()) } #[test_case(false; "without synthetic predecessors")] #[test_case(true; "with synthetic predecessors")] fn test_import_refs_synthetic_predecessors_simple( record_synthetic_predecessors: bool, ) -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = GitImportOptions { record_synthetic_predecessors, ..default_import_options() }; // Main: // 2A // | // 1A* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); tx.repo_mut() .set_local_bookmark_target("1A".as_ref(), RefTarget::normal(commit1a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 1A* -> 1B*, Add 3B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let commit3b = write_random_commit_with_parents(tx.repo_mut(), &[&commit1b]); tx.repo_mut() .set_local_bookmark_target("3B".as_ref(), RefTarget::normal(commit3b.id().clone())); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); git::export_refs(tx.repo_mut())?; // Main: Import changes // // (record_synthetic_predecessors = true) // 2C 3B* // | / // 1B* // // (record_synthetic_predecessors = false) // 3B* // | // 2C 1B* let mut tx = main_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!( stats.abandoned_commits.len(), if record_synthetic_predecessors { 0 } else { 1 } ); assert_eq!( stats.rewritten_commit_ids.len(), if record_synthetic_predecessors { 1 } else { 0 } ); assert_eq!(stats.changed_remote_bookmarks.len(), 2); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); let main_repo = tx.commit("test").block_on()?; let commit2c = find_unique_successor(&main_repo, commit2a.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2c, &commit3b].map(|c| c.id().clone())) ); if record_synthetic_predecessors { // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); assert_eq!( main_repo.operation().predecessors_for_commit(commit3b.id()), Some([].as_slice()) ); // Descendants should be rebased onto 1B assert_eq!(commit2c.parent_ids(), slice::from_ref(commit1b.id())); } else { // Synthetic predecessors shouldn't be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), None ); assert_eq!( main_repo.operation().predecessors_for_commit(commit3b.id()), None ); // Descendants should be rebased onto root assert_eq!( commit2c.parent_ids(), slice::from_ref(main_repo.store().root_commit_id()) ); } Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_multiple_descendants() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 3A 5A // | | // 2A* 4A // | / // 1A let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit3a = write_random_commit_with_parents(tx.repo_mut(), &[&commit2a]); let commit4a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit5a = write_random_commit_with_parents(tx.repo_mut(), &[&commit4a]); tx.repo_mut() .set_local_bookmark_target("2A".as_ref(), RefTarget::normal(commit2a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 1A -> 1B, 2A* -> 2B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); git::export_refs(tx.repo_mut())?; let ext_repo = tx.commit("test").block_on()?; let commit2b = find_unique_successor(&ext_repo, commit2a.id()).unwrap(); // Main: Import changes // 3C 5C // | | // 2B* 4C // | / // 1B let mut tx = main_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 2); assert_eq!(stats.changed_remote_bookmarks.len(), 1); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 3); let main_repo = tx.commit("test").block_on()?; let commit3c = find_unique_successor(&main_repo, commit3a.id()).unwrap(); let commit4c = find_unique_successor(&main_repo, commit4a.id()).unwrap(); let commit5c = find_unique_successor(&main_repo, commit5a.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit3c, &commit5c].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); assert_eq!( main_repo.operation().predecessors_for_commit(commit2b.id()), Some(slice::from_ref(commit2a.id())) ); // Descendants of 1A should be rebased onto 1B assert_eq!(commit4c.parent_ids(), slice::from_ref(commit1b.id())); assert_eq!(commit5c.parent_ids(), slice::from_ref(commit4c.id())); // Descendants of 2A should be rebased onto 2B assert_eq!(commit3c.parent_ids(), slice::from_ref(commit2b.id())); Ok(()) } #[test_case(true, false; "without synthetic predecessors")] #[test_case(false, true; "with synthetic predecessors but no rewriting")] #[test_case(true, true; "with synthetic predecessors")] fn test_import_refs_synthetic_predecessors_bookmarked_simple( abandon_unreachable_commits: bool, record_synthetic_predecessors: bool, ) -> TestResult { let synthetic_rewrite_commits = abandon_unreachable_commits && record_synthetic_predecessors; let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = GitImportOptions { abandon_unreachable_commits, record_synthetic_predecessors, ..default_import_options() }; // Main: // 2A // | // 1A* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); tx.repo_mut() .set_local_bookmark_target("1A".as_ref(), RefTarget::normal(commit1a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 1A* -> 1B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); git::export_refs(tx.repo_mut())?; // Main: Set bookmark 2A* (without importing) let mut tx = main_repo.start_transaction(); tx.repo_mut() .set_local_bookmark_target("2A".as_ref(), RefTarget::normal(commit2a.id().clone())); git::export_refs(tx.repo_mut())?; // Main: Import changes // // (synthetic_rewrite_commits = true) // 2C* // | // 1B* // // (synthetic_rewrite_commits = false) // 2A // | // 1A* 1B* let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!( stats.rewritten_commit_ids.len(), if synthetic_rewrite_commits { 1 } else { 0 } ); assert_eq!(stats.changed_remote_bookmarks.len(), 1); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, if synthetic_rewrite_commits { 1 } else { 0 }); let main_repo = tx.commit("test").block_on()?; if synthetic_rewrite_commits { let commit2c = find_unique_successor(&main_repo, commit2a.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2c].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); // Descendants should be rebased onto 1B assert_eq!(commit2c.parent_ids(), slice::from_ref(commit1b.id())); } else if record_synthetic_predecessors { assert!(!abandon_unreachable_commits); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2a, &commit1b].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); } else { // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2a, &commit1b].map(|c| c.id().clone())) ); // Synthetic predecessors shouldn't be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), None ); } Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_some_bookmarked_descendants() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 3A 5A // | / // 2A* 4A // | / // 1A let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit3a = write_random_commit_with_parents(tx.repo_mut(), &[&commit2a]); let commit4a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit5a = write_random_commit_with_parents(tx.repo_mut(), &[&commit2a]); tx.repo_mut() .set_local_bookmark_target("2A".as_ref(), RefTarget::normal(commit2a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 1A -> 1B, 2A* -> 2B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); git::export_refs(tx.repo_mut())?; let ext_repo = tx.commit("test").block_on()?; let commit2b = find_unique_successor(&ext_repo, commit2a.id()).unwrap(); // Main: Set bookmark 3A* (without importing) let mut tx = main_repo.start_transaction(); tx.repo_mut() .set_local_bookmark_target("3A".as_ref(), RefTarget::normal(commit3a.id().clone())); git::export_refs(tx.repo_mut())?; // Main: Import changes // 3C* 5C // | / // 2B* 4C // | / // 1B let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 2); assert_eq!(stats.changed_remote_bookmarks.len(), 1); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 3); let main_repo = tx.commit("test").block_on()?; let commit3c = find_unique_successor(&main_repo, commit3a.id()).unwrap(); let commit4c = find_unique_successor(&main_repo, commit4a.id()).unwrap(); let commit5c = find_unique_successor(&main_repo, commit5a.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit3c, &commit4c, &commit5c].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); assert_eq!( main_repo.operation().predecessors_for_commit(commit2b.id()), Some(slice::from_ref(commit2a.id())) ); // Descendants should be rebased onto 2B assert_eq!(commit3c.parent_ids(), slice::from_ref(commit2b.id())); assert_eq!(commit4c.parent_ids(), slice::from_ref(commit1b.id())); assert_eq!(commit5c.parent_ids(), slice::from_ref(commit2b.id())); Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_rewritten_bookmarked_descendants() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 2A* // | // 1A* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); tx.repo_mut() .set_local_bookmark_target("1A".as_ref(), RefTarget::normal(commit1a.id().clone())); tx.repo_mut() .set_local_bookmark_target("2A".as_ref(), RefTarget::normal(commit2a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 1A* -> 1B*, 2A* -> 2B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 2); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); git::export_refs(tx.repo_mut())?; let ext_repo = tx.commit("test").block_on()?; let commit2b = find_unique_successor(&ext_repo, commit2a.id()).unwrap(); // Main: Rewrite 2A* -> 2C*, Add 3C* (without importing) // 2C* 3C* // | / // 1A* let mut tx = main_repo.start_transaction(); let commit2c = rewrite_commit(tx.repo_mut(), &commit2a, "2C"); let commit3c = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); tx.repo_mut() .set_local_bookmark_target("2C".as_ref(), RefTarget::normal(commit2c.id().clone())); tx.repo_mut() .set_local_bookmark_target("3C".as_ref(), RefTarget::normal(commit3c.id().clone())); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); // Main: Import changes // 2D* 3D* 2B* // \ | / // 1B* let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!( stats.rewritten_commit_ids.len(), 1 // 2B/2C isn't included because it has already been rewritten locally ); assert_eq!(stats.changed_remote_bookmarks.len(), 2); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 2); let main_repo = tx.commit("test").block_on()?; let commit2d = find_unique_successor(&main_repo, commit2c.id()).unwrap(); let commit3d = find_unique_successor(&main_repo, commit3c.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2d, &commit3d, &commit2b].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); assert_eq!( main_repo.operation().predecessors_for_commit(commit2b.id()), Some(slice::from_ref(commit2a.id())) ); // Descendants should be rebased onto 1B assert_eq!(commit2d.parent_ids(), slice::from_ref(commit1b.id())); assert_eq!(commit3d.parent_ids(), slice::from_ref(commit1b.id())); Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_old_divergent() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 2A 3B 4C 4D // | | | | // 1A 1B* 1C 1D* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit1b = rewrite_commit(tx.repo_mut(), &commit1a, "1B"); let commit1c = rewrite_commit(tx.repo_mut(), &commit1a, "1C"); let commit1d = rewrite_commit(tx.repo_mut(), &commit1a, "1D"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit3b = write_random_commit_with_parents(tx.repo_mut(), &[&commit1b]); let commit4c = write_random_commit_with_parents(tx.repo_mut(), &[&commit1c]); let commit4d = write_random_commit_with_parents(tx.repo_mut(), &[&commit1d]); tx.repo_mut() .set_local_bookmark_target("1B".as_ref(), RefTarget::normal(commit1b.id().clone())); tx.repo_mut() .set_local_bookmark_target("1D".as_ref(), RefTarget::normal(commit1d.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Abandon 1B*, Rewrite 1D* -> 1E* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 2); tx.repo_mut() .record_abandoned_commit(&ext_store.get_commit(commit1b.id())?); tx.repo_mut() .set_local_bookmark_target("1B".as_ref(), RefTarget::absent()); let commit1e = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1d.id())?, "1E"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); git::export_refs(tx.repo_mut())?; // Main: Import changes // 2A 4C 3E 4F // | | | / // 1A 1C 1E* let mut tx = main_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 2); assert_eq!(stats.changed_remote_bookmarks.len(), 2); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 2); let main_repo = tx.commit("test").block_on()?; let commit3e = find_unique_successor(&main_repo, commit3b.id()).unwrap(); let commit4f = find_unique_successor(&main_repo, commit4d.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2a, &commit4c, &commit3e, &commit4f].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded as [1D], not [1B, 1D] assert_eq!( main_repo.operation().predecessors_for_commit(commit1e.id()), Some(slice::from_ref(commit1d.id())) ); // Both descendants should be rebased onto 1E assert_eq!(commit3e.parent_ids(), slice::from_ref(commit1e.id())); assert_eq!(commit4f.parent_ids(), slice::from_ref(commit1e.id())); Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_new_divergent() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 2A 4A // | | // 1A 3A* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); let commit3a = write_random_commit(tx.repo_mut()); let commit4a = write_random_commit_with_parents(tx.repo_mut(), &[&commit3a]); tx.repo_mut() .set_local_bookmark_target("3A".as_ref(), RefTarget::normal(commit3a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; // Ext: Rewrite 3A* -> 3B*, 3C* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit3b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit3a.id())?, "3B"); let commit3c = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit3a.id())?, "3C"); tx.repo_mut() .set_local_bookmark_target("3B".as_ref(), RefTarget::normal(commit3b.id().clone())); tx.repo_mut() .set_local_bookmark_target("3C".as_ref(), RefTarget::normal(commit3c.id().clone())); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); git::export_refs(tx.repo_mut())?; // Main: Import changes // 2A 4A // | | // 1A 3A 3B* 3C* let mut tx = main_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 1); assert_eq!(stats.changed_remote_bookmarks.len(), 3); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); let main_repo = tx.commit("test").block_on()?; // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2a, &commit4a, &commit3b, &commit3c].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit3b.id()), Some(slice::from_ref(commit3a.id())) ); assert_eq!( main_repo.operation().predecessors_for_commit(commit3c.id()), Some(slice::from_ref(commit3a.id())) ); Ok(()) } #[test] fn test_import_refs_synthetic_predecessors_reimport_same_commits() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let main_repo = &test_repo.repo; let ext_repo = init_external_git_repo(&test_repo, "ext-repo".as_ref())?; let ext_store = ext_repo.store().clone(); let import_options = default_import_options(); // Main: // 2A // | // 1A* let mut tx = main_repo.start_transaction(); let commit1a = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit_with_parents(tx.repo_mut(), &[&commit1a]); tx.repo_mut() .set_local_bookmark_target("1A".as_ref(), RefTarget::normal(commit1a.id().clone())); git::export_refs(tx.repo_mut())?; let main_repo = tx.commit("test").block_on()?; let setup_view = main_repo.view().store_view().clone(); // Ext: Rewrite 1A* -> 1B* let mut tx = ext_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); let commit1b = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit1a.id())?, "1B"); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 0); git::export_refs(tx.repo_mut())?; // Main: Import changes // 2C // | // 1B* let mut tx = main_repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 1); assert_eq!(stats.changed_remote_bookmarks.len(), 1); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); let main_repo = tx.commit("test").block_on()?; let commit2c = find_unique_successor(&main_repo, commit2a.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2c].map(|c| c.id().clone())) ); // Synthetic predecessors should be recorded assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), Some(slice::from_ref(commit1a.id())) ); // Descendants should be rebased onto 1B assert_eq!(commit2c.parent_ids(), slice::from_ref(commit1b.id())); // Main: Revert the previous import let mut tx = main_repo.start_transaction(); tx.repo_mut().set_view(setup_view); let main_repo = tx.commit("test").block_on()?; // Main: Import changes again // 2E // | // 1B* let mut tx = main_repo.start_transaction(); // Update commit description of 2A so the rebased commit will have different // hash than 2C. We can't rely on low-resolution committer timestamp here. let commit2d = rewrite_commit(tx.repo_mut(), &ext_store.get_commit(commit2a.id())?, "2D"); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.abandoned_commits.len(), 0); assert_eq!(stats.rewritten_commit_ids.len(), 1); assert_eq!(stats.changed_remote_bookmarks.len(), 1); let num_rebased = tx.repo_mut().rebase_descendants().block_on()?; assert_eq!(num_rebased, 1); let main_repo = tx.commit("test").block_on()?; let commit2e = find_unique_successor(&main_repo, commit2d.id()).unwrap(); // Sanity check for the new graph assert_eq!( *main_repo.view().heads(), HashSet::from([&commit2e].map(|c| c.id().clone())) ); // Synthetic predecessors should not be duplicated assert_eq!( main_repo.operation().predecessors_for_commit(commit1b.id()), None ); // Descendants should be rebased onto 1B again assert_eq!(commit2e.parent_ids(), slice::from_ref(commit1b.id())); Ok(()) } #[test] fn test_import_refs_reserved_remote_name() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); empty_git_commit(&git_repo, "refs/remotes/git/main", &[]); empty_git_commit(&git_repo, "refs/remotes/gita/main", &[]); let mut tx = repo.start_transaction(); let stats = git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!(stats.failed_ref_names, ["refs/remotes/git/main"]); let view = tx.repo().view(); assert_eq!( view.git_refs().keys().collect_vec(), ["refs/remotes/gita/main"] ); assert_eq!( view.all_remote_bookmarks() .map(|(symbol, _)| symbol) .collect_vec(), [remote_symbol("main", "gita")] ); Ok(()) } #[test] fn test_import_some_refs() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = auto_track_import_options(); let commit_main = empty_git_commit(&git_repo, "refs/remotes/origin/main", &[]); let commit_feat1 = empty_git_commit(&git_repo, "refs/remotes/origin/feature1", &[commit_main]); let commit_feat2 = empty_git_commit(&git_repo, "refs/remotes/origin/feature2", &[commit_feat1]); let commit_feat3 = empty_git_commit(&git_repo, "refs/remotes/origin/feature3", &[commit_feat1]); let commit_feat4 = empty_git_commit(&git_repo, "refs/remotes/origin/feature4", &[commit_feat3]); let commit_ign = empty_git_commit(&git_repo, "refs/remotes/origin/ignored", &[]); // Import bookmarks feature1, feature2, and feature3. let mut tx = repo.start_transaction(); git::import_some_refs(tx.repo_mut(), &import_options, |kind, symbol| { kind == GitRefKind::Bookmark && symbol.remote == "origin" && symbol.name.as_str().starts_with("feature") }) .block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; // There are two heads, feature2 and feature4. let view = repo.view(); let expected_heads = hashset! { jj_id(commit_feat2), jj_id(commit_feat4), }; assert_eq!(*view.heads(), expected_heads); // Check that bookmarks feature[1-4] have been locally imported and are known to // be present on origin as well. assert_eq!(view.bookmarks().count(), 4); let commit_feat1_remote_ref = RemoteRef { target: RefTarget::normal(jj_id(commit_feat1)), state: RemoteRefState::Tracked, }; let commit_feat2_remote_ref = RemoteRef { target: RefTarget::normal(jj_id(commit_feat2)), state: RemoteRefState::Tracked, }; let commit_feat3_remote_ref = RemoteRef { target: RefTarget::normal(jj_id(commit_feat3)), state: RemoteRefState::Tracked, }; let commit_feat4_remote_ref = RemoteRef { target: RefTarget::normal(jj_id(commit_feat4)), state: RemoteRefState::Tracked, }; assert_eq!( view.get_local_bookmark("feature1".as_ref()), &RefTarget::normal(jj_id(commit_feat1)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature1", "origin")), &commit_feat1_remote_ref ); assert_eq!( view.get_local_bookmark("feature2".as_ref()), &RefTarget::normal(jj_id(commit_feat2)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature2", "origin")), &commit_feat2_remote_ref ); assert_eq!( view.get_local_bookmark("feature3".as_ref()), &RefTarget::normal(jj_id(commit_feat3)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature3", "origin")), &commit_feat3_remote_ref ); assert_eq!( view.get_local_bookmark("feature4".as_ref()), &RefTarget::normal(jj_id(commit_feat4)) ); assert_eq!( view.get_remote_bookmark(remote_symbol("feature4", "origin")), &commit_feat4_remote_ref ); assert!(view.get_local_bookmark("main".as_ref()).is_absent()); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent_ref() ); assert!(!view.heads().contains(&jj_id(commit_main))); assert!(view.get_local_bookmark("ignored".as_ref()).is_absent()); assert_eq!( view.get_remote_bookmark(remote_symbol("ignored", "git")), RemoteRef::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("ignored", "origin")), RemoteRef::absent_ref() ); assert!(!view.heads().contains(&jj_id(commit_ign))); // Delete bookmark feature1, feature3 and feature4 in git repository and import // bookmark feature2 only. That should have no impact on the jj repository. delete_git_ref(&git_repo, "refs/remotes/origin/feature1"); delete_git_ref(&git_repo, "refs/remotes/origin/feature3"); delete_git_ref(&git_repo, "refs/remotes/origin/feature4"); let mut tx = repo.start_transaction(); git::import_some_refs(tx.repo_mut(), &import_options, |kind, symbol| { kind == GitRefKind::Bookmark && symbol.remote == "origin" && symbol.name == "feature2" }) .block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; // feature2 and feature4 will still be heads, and all four bookmarks should be // present. let view = repo.view(); assert_eq!(view.bookmarks().count(), 4); assert_eq!(*view.heads(), expected_heads); // Import feature1: this should cause the bookmark to be deleted, but the // corresponding commit should stay because it is reachable from feature2. let mut tx = repo.start_transaction(); git::import_some_refs(tx.repo_mut(), &import_options, |kind, symbol| { kind == GitRefKind::Bookmark && symbol.remote == "origin" && symbol.name == "feature1" }) .block_on()?; // No descendant should be rewritten. assert_eq!(tx.repo_mut().rebase_descendants().block_on()?, 0); let repo = tx.commit("test").block_on()?; // feature2 and feature4 should still be the heads, and all three bookmarks // feature2, feature3, and feature3 should exist. let view = repo.view(); assert_eq!(view.bookmarks().count(), 3); assert_eq!(*view.heads(), expected_heads); // Import feature3: this should cause the bookmark to be deleted, but // feature4 should be left alone even though it is no longer in git. let mut tx = repo.start_transaction(); git::import_some_refs(tx.repo_mut(), &import_options, |kind, symbol| { kind == GitRefKind::Bookmark && symbol.remote == "origin" && symbol.name == "feature3" }) .block_on()?; // No descendant should be rewritten assert_eq!(tx.repo_mut().rebase_descendants().block_on()?, 0); let repo = tx.commit("test").block_on()?; // feature2 and feature4 should still be the heads, and both bookmarks // should exist. let view = repo.view(); assert_eq!(view.bookmarks().count(), 2); assert_eq!(*view.heads(), expected_heads); // Import feature4: both the head and the bookmark will disappear. let mut tx = repo.start_transaction(); git::import_some_refs(tx.repo_mut(), &import_options, |kind, symbol| { kind == GitRefKind::Bookmark && symbol.remote == "origin" && symbol.name == "feature4" }) .block_on()?; // No descendant should be rewritten assert_eq!(tx.repo_mut().rebase_descendants().block_on()?, 0); let repo = tx.commit("test").block_on()?; // feature2 should now be the only head and only bookmark. let view = repo.view(); assert_eq!(view.bookmarks().count(), 1); let expected_heads = hashset! { jj_id(commit_feat2), }; assert_eq!(*view.heads(), expected_heads); Ok(()) } fn git_ref(git_repo: &gix::Repository, name: &str, target: gix::ObjectId) { git_repo .reference(name, target, gix::refs::transaction::PreviousValue::Any, "") .unwrap(); } fn delete_git_ref(git_repo: &gix::Repository, name: &str) { git_repo.find_reference(name).unwrap().delete().unwrap(); } struct GitRepoData { _temp_dir: TempDir, origin_repo: gix::Repository, git_repo: gix::Repository, repo: Arc, } impl GitRepoData { fn create() -> Self { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let origin_repo_dir = temp_dir.path().join("source"); let origin_repo = testutils::git::init_bare(&origin_repo_dir); let git_repo_dir = temp_dir.path().join("git"); let git_repo = testutils::git::clone(&git_repo_dir, origin_repo_dir.to_str().unwrap(), None); let jj_repo_dir = temp_dir.path().join("jj"); std::fs::create_dir(&jj_repo_dir).unwrap(); let repo = ReadonlyRepo::init( &settings, &jj_repo_dir, &|settings, store_path| { Ok(Box::new(GitBackend::init_external( settings, store_path, git_repo.path(), )?)) }, Signer::from_settings(&settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), ReadonlyRepo::default_submodule_store_initializer(), ) .block_on() .unwrap(); Self { _temp_dir: temp_dir, origin_repo, git_repo, repo, } } } #[test] fn test_import_refs_empty_git_repo() -> TestResult { let test_data = GitRepoData::create(); let import_options = default_import_options(); let heads_before = test_data.repo.view().heads().clone(); let mut tx = test_data.repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; assert_eq!(*repo.view().heads(), heads_before); assert_eq!(repo.view().bookmarks().count(), 0); assert_eq!(repo.view().local_tags().count(), 0); assert_eq!(repo.view().git_refs().len(), 0); assert_eq!(repo.view().git_head(), RefTarget::absent_ref()); Ok(()) } #[test] fn test_import_refs_missing_git_commit() -> TestResult { let test_workspace = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_workspace.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); let commit2 = empty_git_commit(&git_repo, "refs/heads/main", &[commit1]); let shard = hex_util::encode_hex(&commit1.as_bytes()[..1]); let object_basename = hex_util::encode_hex(&commit1.as_bytes()[1..]); let object_store_path = git_repo.path().join("objects"); let object_file = object_store_path.join(&shard).join(object_basename); let backup_object_file = object_store_path.join(&shard).join("backup"); assert!(object_file.exists()); // Missing commit is ancestor of ref testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/unborn"); fs::rename(&object_file, &backup_object_file)?; let mut tx = repo.start_transaction(); let result = git::import_refs(tx.repo_mut(), &import_options).block_on(); assert_matches!( result, Err(GitImportError::MissingRefAncestor { symbol, err: BackendError::ObjectNotFound { .. } }) if symbol == remote_symbol("main", "git") ); // Missing commit is ancestor of HEAD git_repo.find_reference("refs/heads/main")?.delete()?; testutils::git::set_head_to_id(&git_repo, commit2); let mut tx = repo.start_transaction(); let result = git::import_head(tx.repo_mut()).block_on(); assert_matches!( result, Err(GitImportError::MissingHeadTarget { id, err: BackendError::ObjectNotFound { .. } }) if id == jj_id(commit2) ); // Missing commit is pointed to by ref: the ref is ignored as we don't know // if the missing object is a commit or not. fs::rename(&backup_object_file, &object_file)?; git_repo.reference( "refs/heads/main", commit1, gix::refs::transaction::PreviousValue::Any, "test", )?; testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/unborn"); fs::rename(&object_file, &backup_object_file)?; let mut tx = repo.start_transaction(); let result = git::import_refs(tx.repo_mut(), &import_options).block_on(); assert!(result.is_ok()); // Missing commit is pointed to by HEAD: the ref is ignored as we don't know // if the missing object is a commit or not. fs::rename(&backup_object_file, &object_file)?; git_repo.find_reference("refs/heads/main")?.delete()?; testutils::git::set_head_to_id(&git_repo, commit1); fs::rename(&object_file, &backup_object_file)?; let mut tx = repo.start_transaction(); let result = git::import_head(tx.repo_mut()).block_on(); assert!(result.is_ok()); Ok(()) } #[test] fn test_import_refs_detached_head() -> TestResult { let test_data = GitRepoData::create(); let import_options = default_import_options(); let commit1 = empty_git_commit(&test_data.git_repo, "refs/heads/main", &[]); // Delete the reference. Check that the detached HEAD commit still gets added to // the set of heads test_data .git_repo .find_reference("refs/heads/main")? .delete()?; testutils::git::set_head_to_id(&test_data.git_repo, commit1); let mut tx = test_data.repo.start_transaction(); git::import_head(tx.repo_mut()).block_on()?; git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let expected_heads = hashset! { jj_id(commit1) }; assert_eq!(*repo.view().heads(), expected_heads); assert_eq!(repo.view().git_refs().len(), 0); assert_eq!(repo.view().git_head(), &RefTarget::normal(jj_id(commit1))); Ok(()) } #[test] fn test_export_refs_no_detach() -> TestResult { // When exporting the bookmark that's current checked out, don't detach HEAD if // the target already matches let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; // Do an initial export to make sure `main` is considered let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(jj_id(commit1)) ); assert_eq!(git_repo.head_name()?.unwrap().as_bstr(), b"refs/heads/main"); assert_eq!( git_repo.find_reference("refs/heads/main")?.target().id(), commit1 ); Ok(()) } #[test] fn test_export_refs_bookmark_changed() -> TestResult { // We can export a change to a bookmark let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); git_repo.reference( "refs/heads/feature", commit, gix::refs::transaction::PreviousValue::MustNotExist, "test", )?; testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/feature"); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit)]) .write_unwrap(); mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(new_commit.id().clone())); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(new_commit.id().clone()) ); assert_eq!( git_repo .find_reference("refs/heads/main")? .peel_to_commit()? .id(), git_id(&new_commit) ); // HEAD should be unchanged since its target bookmark didn't change assert_eq!( git_repo.head_name()?.unwrap().as_bstr(), b"refs/heads/feature" ); Ok(()) } #[test] fn test_export_refs_tag_changed() -> TestResult { // We can export changes to lightweight and annotated tags. Since jj doesn't // have a native support for tag objects, updated tags won't retain the // original tag metadata. let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit = empty_git_commit(&git_repo, "refs/tags/lightweight-change", &[]); let constraint = gix::refs::transaction::PreviousValue::MustNotExist; git_repo.tag_reference("lightweight-delete", commit, constraint)?; for name in ["annotated-change", "annotated-delete"] { let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; git_repo.tag(name, commit, kind, None, "", constraint)?; } let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; let stats = git::import_refs(mut_repo, &import_options).block_on()?; assert_eq!(stats.changed_remote_tags.len(), 4); mut_repo.rebase_descendants().block_on()?; let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit)]) .write_unwrap(); let new_target = RefTarget::normal(new_commit.id().clone()); mut_repo.set_local_tag_target("lightweight-change".as_ref(), new_target.clone()); mut_repo.set_local_tag_target("lightweight-delete".as_ref(), RefTarget::absent()); mut_repo.set_local_tag_target("annotated-change".as_ref(), new_target.clone()); mut_repo.set_local_tag_target("annotated-delete".as_ref(), RefTarget::absent()); mut_repo.set_local_tag_target("new".as_ref(), new_target.clone()); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/tags/lightweight-change".as_ref()), new_target ); assert_eq!( mut_repo.get_git_ref("refs/tags/lightweight-delete".as_ref()), RefTarget::absent() ); assert_eq!( mut_repo.get_git_ref("refs/tags/annotated-change".as_ref()), new_target ); assert_eq!( mut_repo.get_git_ref("refs/tags/annotated-delete".as_ref()), RefTarget::absent() ); assert_eq!(mut_repo.get_git_ref("refs/tags/new".as_ref()), new_target); assert_eq!( git_repo .find_reference("refs/tags/lightweight-change")? .peel_to_commit()? .id(), git_id(&new_commit) ); assert!( git_repo .try_find_reference("refs/tags/lightweight-delete")? .is_none() ); assert_eq!( git_repo .find_reference("refs/tags/annotated-change")? .peel_to_commit()? .id(), git_id(&new_commit) ); assert!( git_repo .try_find_reference("refs/tags/annotated-delete")? .is_none() ); assert_eq!( git_repo .find_reference("refs/tags/new")? .peel_to_commit()? .id(), git_id(&new_commit) ); Ok(()) } #[test] fn test_export_refs_current_bookmark_changed() -> TestResult { // If we update a bookmark that is checked out in the git repo, HEAD gets // detached let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit1)]) .write_unwrap(); mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(new_commit.id().clone())); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(new_commit.id().clone()) ); assert_eq!( git_repo .find_reference("refs/heads/main")? .peel_to_commit()? .id() .detach(), git_id(&new_commit) ); assert!(git_repo.head()?.is_detached(), "HEAD is detached"); Ok(()) } #[test] fn test_export_refs_worktree_head_changed() -> TestResult { let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let worktree_dir = test_data._temp_dir.path().join("git-wt"); let git_workdir = git_repo.workdir().expect("git repo must have workdir"); let output = std::process::Command::new("git") .args(["worktree", "add", "-b", "wt-branch"]) .arg(&worktree_dir) .current_dir(git_workdir) .output()?; assert!( output.status.success(), "Failed to create worktree: {}", String::from_utf8_lossy(&output.stderr) ); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit1)]) .write_unwrap(); mut_repo.set_local_bookmark_target( "wt-branch".as_ref(), RefTarget::normal(new_commit.id().clone()), ); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let git_repo_wt = gix::open(&worktree_dir)?; assert!(git_repo_wt.head()?.is_detached()); Ok(()) } #[test] fn test_export_refs_worktree_no_detach() -> TestResult { let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit1 = empty_git_commit(&git_repo, "refs/heads/main", &[]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let worktree_dir = test_data._temp_dir.path().join("git-wt"); let git_workdir = git_repo.workdir().expect("git repo must have workdir"); let output = std::process::Command::new("git") .args(["worktree", "add", "-b", "wt-branch"]) .arg(&worktree_dir) .current_dir(git_workdir) .output()?; assert!( output.status.success(), "Failed to create worktree: {}", String::from_utf8_lossy(&output.stderr) ); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit1)]) .write_unwrap(); mut_repo.set_local_bookmark_target( "other-branch".as_ref(), RefTarget::normal(new_commit.id().clone()), ); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let git_repo_wt = gix::open(&worktree_dir)?; assert!(!git_repo_wt.head()?.is_detached()); assert_eq!( git_repo_wt.head_name()?.unwrap().as_bstr(), b"refs/heads/wt-branch" ); Ok(()) } #[test] fn test_export_refs_current_tag_changed() -> TestResult { // If we update a tag that is checked out in the git repo, HEAD gets // detached let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let commit1 = empty_git_commit(&git_repo, "refs/tags/v1.0", &[]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/tags/v1.0"); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); let new_commit = create_random_commit(mut_repo) .set_parents(vec![jj_id(commit1)]) .write_unwrap(); mut_repo.set_local_tag_target("v1.0".as_ref(), RefTarget::normal(new_commit.id().clone())); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/tags/v1.0".as_ref()), RefTarget::normal(new_commit.id().clone()) ); assert_eq!( git_repo .find_reference("refs/tags/v1.0")? .peel_to_commit()? .id() .detach(), git_id(&new_commit) ); assert!(git_repo.head()?.is_detached()); Ok(()) } #[test_case(false; "without moved placeholder ref")] #[test_case(true; "with moved placeholder ref")] fn test_export_refs_unborn_git_bookmark(move_placeholder_ref: bool) -> TestResult { // Can export to an empty Git repo (we can handle Git's "unborn bookmark" state) let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_head(mut_repo).block_on()?; git::import_refs(mut_repo, &import_options).block_on()?; mut_repo.rebase_descendants().block_on()?; let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert!(git_repo.head()?.is_unborn(), "HEAD is unborn"); let new_commit = write_random_commit(mut_repo); mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(new_commit.id().clone())); if move_placeholder_ref { git_repo.reference( "refs/jj/root", git_id(&new_commit), gix::refs::transaction::PreviousValue::MustNotExist, "", )?; } let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(new_commit.id().clone()) ); assert_eq!( git_repo .find_reference("refs/heads/main")? .peel_to_commit()? .id(), git_id(&new_commit) ); // HEAD should no longer point to refs/heads/main assert!(git_repo.head()?.is_unborn(), "HEAD is unborn"); // The placeholder ref should be deleted if any assert!(git_repo.find_reference("refs/jj/root").is_err()); Ok(()) } #[test] fn test_export_import_sequence() -> TestResult { // Import a bookmark pointing to A, modify it in jj to point to B, export it, // modify it in git to point to C, then import it again. There should be no // conflict. let test_data = GitRepoData::create(); let import_options = default_import_options(); let git_repo = test_data.git_repo; let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); let commit_a = write_random_commit(mut_repo); let commit_b = write_random_commit(mut_repo); let commit_c = write_random_commit(mut_repo); // Import the bookmark pointing to A git_repo.reference( "refs/heads/main", git_id(&commit_a), gix::refs::transaction::PreviousValue::Any, "test", )?; git::import_refs(mut_repo, &import_options).block_on()?; assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(commit_a.id().clone()) ); // Modify the bookmark in jj to point to B mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(commit_b.id().clone())); // Export the bookmark to git let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(commit_b.id().clone()) ); // Modify the bookmark in git to point to C git_repo.reference( "refs/heads/main", git_id(&commit_c), gix::refs::transaction::PreviousValue::Any, "test", )?; // Import from git git::import_refs(mut_repo, &import_options).block_on()?; assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::normal(commit_c.id().clone()) ); assert_eq!( mut_repo.view().get_local_bookmark("main".as_ref()), &RefTarget::normal(commit_c.id().clone()) ); Ok(()) } #[test] fn test_import_export_non_tracking_bookmark() -> TestResult { // Import a remote tracking bookmark and export it. We should not create a git // bookmark. let test_data = GitRepoData::create(); let git_repo = test_data.git_repo; let commit_main_t0 = empty_git_commit(&git_repo, "refs/remotes/origin/main", &[]); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); git::import_refs(mut_repo, &default_import_options()).block_on()?; assert!( mut_repo .view() .get_local_bookmark("main".as_ref()) .is_absent() ); assert_eq!( mut_repo .view() .get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_main_t0)), state: RemoteRefState::New, }, ); assert_eq!( mut_repo.get_git_ref("refs/remotes/origin/main".as_ref()), RefTarget::normal(jj_id(commit_main_t0)) ); // Export the bookmark to git let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( mut_repo.get_git_ref("refs/heads/main".as_ref()), RefTarget::absent() ); // Reimport with auto-track-bookmarks on. Local bookmark shouldn't be created // for the known bookmark "main". let commit_main_t1 = empty_git_commit(&git_repo, "refs/remotes/origin/main", &[commit_main_t0]); let commit_feat_t1 = empty_git_commit(&git_repo, "refs/remotes/origin/feat", &[]); git::import_refs(mut_repo, &auto_track_import_options()).block_on()?; assert!( mut_repo .view() .get_local_bookmark("main".as_ref()) .is_absent() ); assert_eq!( mut_repo.view().get_local_bookmark("feat".as_ref()), &RefTarget::normal(jj_id(commit_feat_t1)) ); assert_eq!( mut_repo .view() .get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_main_t1)), state: RemoteRefState::New, }, ); assert_eq!( mut_repo .view() .get_remote_bookmark(remote_symbol("feat", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_feat_t1)), state: RemoteRefState::Tracked, }, ); // Reimport with auto-track-bookmarks off. Tracking bookmark should be imported. let commit_main_t2 = empty_git_commit(&git_repo, "refs/remotes/origin/main", &[commit_main_t1]); let commit_feat_t2 = empty_git_commit(&git_repo, "refs/remotes/origin/feat", &[commit_feat_t1]); git::import_refs(mut_repo, &default_import_options()).block_on()?; assert!( mut_repo .view() .get_local_bookmark("main".as_ref()) .is_absent() ); assert_eq!( mut_repo.view().get_local_bookmark("feat".as_ref()), &RefTarget::normal(jj_id(commit_feat_t2)) ); assert_eq!( mut_repo .view() .get_remote_bookmark(remote_symbol("main", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_main_t2)), state: RemoteRefState::New, }, ); assert_eq!( mut_repo .view() .get_remote_bookmark(remote_symbol("feat", "origin")), &RemoteRef { target: RefTarget::normal(jj_id(commit_feat_t2)), state: RemoteRefState::Tracked, }, ); Ok(()) } #[test] fn test_export_conflicts() -> TestResult { // We skip export of conflicted bookmarks let test_data = GitRepoData::create(); let git_repo = test_data.git_repo; let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); let commit_a = write_random_commit(mut_repo); let commit_b = write_random_commit(mut_repo); let commit_c = write_random_commit(mut_repo); mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(commit_a.id().clone())); mut_repo .set_local_bookmark_target("feature".as_ref(), RefTarget::normal(commit_a.id().clone())); mut_repo.set_local_tag_target("v1.0".as_ref(), RefTarget::normal(commit_a.id().clone())); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); // Create a conflict and export. It should not be exported, but other changes // should be. mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::normal(commit_b.id().clone())); let conflict_target = RefTarget::from_legacy_form( [commit_a.id().clone()], [commit_b.id().clone(), commit_c.id().clone()], ); mut_repo.set_local_bookmark_target("feature".as_ref(), conflict_target.clone()); mut_repo.set_local_tag_target("v1.0".as_ref(), conflict_target.clone()); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( git_repo.find_reference("refs/heads/feature")?.target().id(), git_id(&commit_a) ); assert_eq!( git_repo.find_reference("refs/heads/main")?.target().id(), git_id(&commit_b) ); assert_eq!( git_repo.find_reference("refs/tags/v1.0")?.target().id(), git_id(&commit_a) ); // Conflicted bookmarks shouldn't be copied to the "git" remote assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("feature", "git")), RemoteRef { target: RefTarget::normal(commit_a.id().clone()), state: RemoteRefState::Tracked, }, ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main", "git")), RemoteRef { target: RefTarget::normal(commit_b.id().clone()), state: RemoteRefState::Tracked, }, ); assert_eq!( mut_repo.get_remote_tag(remote_symbol("v1.0", "git")), RemoteRef { target: RefTarget::normal(commit_a.id().clone()), state: RemoteRefState::Tracked, }, ); Ok(()) } #[test] fn test_export_bookmark_on_root_commit() -> TestResult { // We skip export of bookmarks pointing to the root commit let test_data = GitRepoData::create(); let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); mut_repo.set_local_bookmark_target( "on_root".as_ref(), RefTarget::normal(mut_repo.store().root_commit_id().clone()), ); let stats = git::export_refs(mut_repo)?; assert_eq!(stats.failed_bookmarks.len(), 1); assert_eq!( stats.failed_bookmarks[0].0.as_ref(), remote_symbol("on_root", "git") ); assert_matches!( stats.failed_bookmarks[0].1, FailedRefExportReason::OnRootCommit ); assert!(stats.failed_tags.is_empty()); Ok(()) } #[test] fn test_export_partial_failure() -> TestResult { // Check that we skip bookmarks that fail to export let test_data = GitRepoData::create(); let git_repo = test_data.git_repo; let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); let commit_a = write_random_commit(mut_repo); let target = RefTarget::normal(commit_a.id().clone()); // Empty string is disallowed by Git mut_repo.set_local_bookmark_target("".as_ref(), target.clone()); mut_repo.set_local_tag_target("".as_ref(), target.clone()); // Branch named HEAD is disallowed by Git CLI mut_repo.set_local_bookmark_target("HEAD".as_ref(), target.clone()); mut_repo.set_local_bookmark_target("main".as_ref(), target.clone()); // `main/sub` will conflict with `main` in Git, at least when using loose ref // storage mut_repo.set_local_bookmark_target("main/sub".as_ref(), target.clone()); // Non-git remote tags are ignored since there are no remote tags in Git mut_repo.set_remote_tag( remote_symbol("v1.0", "origin"), RemoteRef { target: target.clone(), state: RemoteRefState::Tracked, }, ); let stats = git::export_refs(mut_repo)?; assert_eq!(stats.failed_bookmarks.len(), 3); assert_eq!( stats.failed_bookmarks[0].0.as_ref(), remote_symbol("", "git") ); assert_matches!( stats.failed_bookmarks[0].1, FailedRefExportReason::InvalidGitName ); assert_eq!( stats.failed_bookmarks[1].0.as_ref(), remote_symbol("HEAD", "git") ); assert_matches!( stats.failed_bookmarks[1].1, FailedRefExportReason::InvalidGitName ); assert_eq!( stats.failed_bookmarks[2].0.as_ref(), remote_symbol("main/sub", "git") ); assert_matches!( stats.failed_bookmarks[2].1, FailedRefExportReason::FailedToSet(_) ); assert_eq!(stats.failed_tags.len(), 1); assert_eq!(stats.failed_tags[0].0.as_ref(), remote_symbol("", "git")); assert_matches!( stats.failed_tags[0].1, FailedRefExportReason::InvalidGitName ); // The `main` bookmark should have succeeded but the other should have failed assert!(git_repo.find_reference("refs/heads/").is_err()); assert!(git_repo.find_reference("refs/heads/HEAD").is_err()); assert_eq!( git_repo.find_reference("refs/heads/main")?.target().id(), git_id(&commit_a) ); assert!(git_repo.find_reference("refs/heads/main/sub").is_err()); assert!(git_repo.find_reference("refs/tags/").is_err()); // Failed bookmarks/tags shouldn't be copied to the "git" remote assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("HEAD", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main", "git")), RemoteRef { target: target.clone(), state: RemoteRefState::Tracked, }, ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main/sub", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_tag(remote_symbol("", "git")), RemoteRef::absent() ); // Now remove the `main` bookmark and make sure that the `main/sub` gets // exported even though it didn't change mut_repo.set_local_bookmark_target("main".as_ref(), RefTarget::absent()); let stats = git::export_refs(mut_repo)?; assert_eq!(stats.failed_bookmarks.len(), 2); assert_eq!( stats.failed_bookmarks[0].0.as_ref(), remote_symbol("", "git") ); assert_matches!( stats.failed_bookmarks[0].1, FailedRefExportReason::InvalidGitName ); assert_eq!( stats.failed_bookmarks[1].0.as_ref(), remote_symbol("HEAD", "git") ); assert_matches!( stats.failed_bookmarks[1].1, FailedRefExportReason::InvalidGitName ); assert_eq!(stats.failed_tags.len(), 1); assert_eq!(stats.failed_tags[0].0.as_ref(), remote_symbol("", "git")); assert_matches!( stats.failed_tags[0].1, FailedRefExportReason::InvalidGitName ); assert!(git_repo.find_reference("refs/heads/").is_err()); assert!(git_repo.find_reference("refs/heads/HEAD").is_err()); assert!(git_repo.find_reference("refs/heads/main").is_err()); assert_eq!( git_repo .find_reference("refs/heads/main/sub")? .target() .id(), git_id(&commit_a) ); assert!(git_repo.find_reference("refs/tags/").is_err()); // Failed bookmarks/tags shouldn't be copied to the "git" remote assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("HEAD", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main", "git")), RemoteRef::absent() ); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main/sub", "git")), RemoteRef { target: target.clone(), state: RemoteRefState::Tracked, }, ); assert_eq!( mut_repo.get_remote_tag(remote_symbol("", "git")), RemoteRef::absent() ); Ok(()) } #[test] fn test_export_reexport_transitions() -> TestResult { // Test exporting after making changes on the jj side, or the git side, or both let test_data = GitRepoData::create(); let git_repo = test_data.git_repo; let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); let commit_a = write_random_commit(mut_repo); let commit_b = write_random_commit(mut_repo); let commit_c = write_random_commit(mut_repo); // Create a few bookmarks whose names indicate how they change in jj in git. The // first letter represents the bookmark's target in the last export. The second // letter represents the bookmark's target in jj. The third letter represents // the bookmark's target in git. "X" means that the bookmark doesn't exist. // "A", "B", or "C" means that the bookmark points to that commit. // // AAB: Branch modified in git // AAX: Branch deleted in git // ABA: Branch modified in jj // ABB: Branch modified in both jj and git, pointing to same target // ABC: Branch modified in both jj and git, pointing to different targets // ABX: Branch modified in jj, deleted in git // AXA: Branch deleted in jj // AXB: Branch deleted in jj, modified in git // AXX: Branch deleted in both jj and git // XAA: Branch added in both jj and git, pointing to same target // XAB: Branch added in both jj and git, pointing to different targets // XAX: Branch added in jj // XXA: Branch added in git // Create initial state and export it for bookmark in [ "AAB", "AAX", "ABA", "ABB", "ABC", "ABX", "AXA", "AXB", "AXX", ] { mut_repo .set_local_bookmark_target(bookmark.as_ref(), RefTarget::normal(commit_a.id().clone())); } let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); // Make changes on the jj side for bookmark in ["AXA", "AXB", "AXX"] { mut_repo.set_local_bookmark_target(bookmark.as_ref(), RefTarget::absent()); } for bookmark in ["XAA", "XAB", "XAX"] { mut_repo .set_local_bookmark_target(bookmark.as_ref(), RefTarget::normal(commit_a.id().clone())); } for bookmark in ["ABA", "ABB", "ABC", "ABX"] { mut_repo .set_local_bookmark_target(bookmark.as_ref(), RefTarget::normal(commit_b.id().clone())); } // Make changes on the git side for bookmark in ["AAX", "ABX", "AXX"] { git_repo .find_reference(&format!("refs/heads/{bookmark}"))? .delete()?; } for bookmark in ["XAA", "XXA"] { git_repo.reference( format!("refs/heads/{bookmark}"), git_id(&commit_a), gix::refs::transaction::PreviousValue::Any, "", )?; } for bookmark in ["AAB", "ABB", "AXB", "XAB"] { git_repo.reference( format!("refs/heads/{bookmark}"), git_id(&commit_b), gix::refs::transaction::PreviousValue::Any, "", )?; } let bookmark = "ABC"; git_repo.reference( format!("refs/heads/{bookmark}"), git_id(&commit_c), gix::refs::transaction::PreviousValue::Any, "", )?; // TODO: The bookmarks that we made conflicting changes to should have failed to // export. They should have been unchanged in git and in // mut_repo.view().git_refs(). let stats = git::export_refs(mut_repo)?; assert_eq!( stats .failed_bookmarks .into_iter() .map(|(symbol, _)| symbol) .collect_vec(), vec!["ABC", "ABX", "AXB", "XAB"] .into_iter() .map(|s| remote_symbol(s, "git").to_owned()) .collect_vec() ); for bookmark in ["AAX", "ABX", "AXA", "AXX"] { assert!( git_repo .find_reference(&format!("refs/heads/{bookmark}")) .is_err(), "{bookmark} should not exist" ); } for bookmark in ["XAA", "XAX", "XXA"] { assert_eq!( git_repo .find_reference(&format!("refs/heads/{bookmark}"))? .target() .id(), git_id(&commit_a), "{bookmark} should point to commit A" ); } for bookmark in ["AAB", "ABA", "AAB", "ABB", "AXB", "XAB"] { assert_eq!( git_repo .find_reference(&format!("refs/heads/{bookmark}"))? .target() .id(), git_id(&commit_b), "{bookmark} should point to commit B" ); } let bookmark = "ABC"; assert_eq!( git_repo .find_reference(&format!("refs/heads/{bookmark}"))? .target() .id(), git_id(&commit_c), "{bookmark} should point to commit C" ); assert_eq!( *mut_repo.view().git_refs(), btreemap! { "refs/heads/AAX".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/AAB".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/ABA".into() => RefTarget::normal(commit_b.id().clone()), "refs/heads/ABB".into() => RefTarget::normal(commit_b.id().clone()), "refs/heads/ABC".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/ABX".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/AXB".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/XAA".into() => RefTarget::normal(commit_a.id().clone()), "refs/heads/XAX".into() => RefTarget::normal(commit_a.id().clone()), } ); Ok(()) } #[test] fn test_export_undo_reexport() -> TestResult { let test_data = GitRepoData::create(); let git_repo = test_data.git_repo; let mut tx = test_data.repo.start_transaction(); let mut_repo = tx.repo_mut(); // Initial export let commit_a = write_random_commit(mut_repo); let target_a = RefTarget::normal(commit_a.id().clone()); let remote_ref_a = RemoteRef { target: target_a.clone(), state: RemoteRefState::Tracked, }; mut_repo.set_local_bookmark_target("main".as_ref(), target_a.clone()); mut_repo.set_local_tag_target("v1.0".as_ref(), target_a.clone()); let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( git_repo.find_reference("refs/heads/main")?.target().id(), git_id(&commit_a) ); assert_eq!( git_repo.find_reference("refs/tags/v1.0")?.target().id(), git_id(&commit_a) ); assert_eq!(mut_repo.get_git_ref("refs/heads/main".as_ref()), target_a); assert_eq!(mut_repo.get_git_ref("refs/tags/v1.0".as_ref()), target_a); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main", "git")), remote_ref_a ); assert_eq!( mut_repo.get_remote_tag(remote_symbol("v1.0", "git")), remote_ref_a ); // Undo remote changes only mut_repo.set_remote_bookmark(remote_symbol("main", "git"), RemoteRef::absent()); mut_repo.set_remote_tag(remote_symbol("v1.0", "git"), RemoteRef::absent()); // Reexport should update the Git-tracking bookmark/tag let stats = git::export_refs(mut_repo)?; assert!(stats.failed_bookmarks.is_empty()); assert!(stats.failed_tags.is_empty()); assert_eq!( git_repo.find_reference("refs/heads/main")?.target().id(), git_id(&commit_a) ); assert_eq!( git_repo.find_reference("refs/tags/v1.0")?.target().id(), git_id(&commit_a) ); assert_eq!(mut_repo.get_git_ref("refs/heads/main".as_ref()), target_a); assert_eq!(mut_repo.get_git_ref("refs/tags/v1.0".as_ref()), target_a); assert_eq!( mut_repo.get_remote_bookmark(remote_symbol("main", "git")), remote_ref_a ); assert_eq!( mut_repo.get_remote_tag(remote_symbol("v1.0", "git")), remote_ref_a ); Ok(()) } #[test] fn test_reset_head_to_root() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); let git_repo = testutils::git::init(&workspace_root); let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); let mut_repo = tx.repo_mut(); let root_commit_id = repo.store().root_commit_id(); let tree = repo.store().empty_merged_tree(); let commit1 = mut_repo .new_commit(vec![root_commit_id.clone()], tree.clone()) .write_unwrap(); let commit2 = mut_repo .new_commit(vec![commit1.id().clone()], tree.clone()) .write_unwrap(); // Set Git HEAD to commit2's parent (i.e. commit1) git::reset_head(tx.repo_mut(), &commit2).block_on()?; assert!(git_repo.head()?.is_detached(), "HEAD is detached"); assert_eq!( tx.repo().git_head(), RefTarget::normal(commit1.id().clone()) ); // Set Git HEAD back to root git::reset_head(tx.repo_mut(), &commit1).block_on()?; assert!(git_repo.head()?.is_unborn(), "HEAD is unborn"); assert!(tx.repo().git_head().is_absent()); // Move placeholder ref as if new commit were created by git git_repo.reference( "refs/jj/root", git_id(&commit1), gix::refs::transaction::PreviousValue::MustNotExist, "", )?; git::reset_head(tx.repo_mut(), &commit2).block_on()?; assert!(git_repo.head_id().is_ok()); assert_eq!( tx.repo().git_head(), RefTarget::normal(commit1.id().clone()) ); assert!(git_repo.find_reference("refs/jj/root").is_ok()); // Set Git HEAD back to root git::reset_head(tx.repo_mut(), &commit1).block_on()?; assert!(git_repo.head()?.is_unborn(), "HEAD is unborn"); assert!(tx.repo().git_head().is_absent()); // The placeholder ref should be deleted assert!(git_repo.find_reference("refs/jj/root").is_err()); Ok(()) } #[test] fn test_reset_head_detached_out_of_sync() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); let git_repo = testutils::git::init(&workspace_root); let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); // 4 // | // 2 3 // |/ // 1 5 // |/ // root let commit1 = write_random_commit(tx.repo_mut()); let commit2 = write_random_commit_with_parents(tx.repo_mut(), &[&commit1]); let commit3 = write_random_commit_with_parents(tx.repo_mut(), &[&commit1]); let commit4 = write_random_commit_with_parents(tx.repo_mut(), &[&commit3]); let commit5 = write_random_commit(tx.repo_mut()); // unborn -> commit1 (= commit2's parent) git::reset_head(tx.repo_mut(), &commit2).block_on()?; assert_eq!( tx.repo().git_head(), RefTarget::normal(commit1.id().clone()) ); // External process updates HEAD to point to commit5 testutils::git::set_head_to_id(&git_repo, git_id(&commit5)); // {expected: commit1, actual: commit5} -> commit1 (= commit3's parent): // works because the expected HEAD is unchanged. git::reset_head(tx.repo_mut(), &commit3).block_on()?; assert_eq!( tx.repo().git_head(), RefTarget::normal(commit1.id().clone()) ); // {expected: commit1, actual: commit5} -> commit3 (= commit4's parent) assert_matches!( git::reset_head(tx.repo_mut(), &commit4).block_on(), Err(GitResetHeadError::UpdateHeadRef(_)) ); assert_eq!( tx.repo().git_head(), RefTarget::normal(commit1.id().clone()), "view shouldn't be updated on failed export" ); // Import the HEAD moved by external process git::import_head(tx.repo_mut()).block_on()?; assert_eq!( tx.repo().git_head(), RefTarget::normal(commit5.id().clone()) ); // commit5 -> commit3 (= commit4's parent) git::reset_head(tx.repo_mut(), &commit4).block_on()?; assert_eq!( tx.repo().git_head(), RefTarget::normal(commit3.id().clone()) ); Ok(()) } fn get_index_state(workspace_root: &Path) -> String { let git_repo = gix::open(workspace_root).unwrap(); let index = git_repo.index().unwrap(); index .entries() .iter() .map(|entry| { format!( "{:?} {} {:?}\n", entry.flags.stage(), entry.path_in(index.path_backing()), entry.mode ) }) .join("") } #[test] fn test_reset_head_with_index() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); let git_repo = testutils::git::init(&workspace_root); let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); let mut_repo = tx.repo_mut(); let root_commit_id = repo.store().root_commit_id(); let tree = repo.store().empty_merged_tree(); let commit1 = mut_repo .new_commit(vec![root_commit_id.clone()], tree.clone()) .write_unwrap(); let commit2 = mut_repo .new_commit(vec![commit1.id().clone()], tree.clone()) .write_unwrap(); // Set Git HEAD to commit2's parent (i.e. commit1) git::reset_head(tx.repo_mut(), &commit2).block_on()?; insta::assert_snapshot!(get_index_state(&workspace_root), @""); // Add "staged changes" to the Git index { let mut index_manager = testutils::git::IndexManager::new(&git_repo); index_manager.add_file("file.txt", b"i am a file\n"); index_manager.sync_index(); } insta::assert_snapshot!(get_index_state(&workspace_root), @"Unconflicted file.txt Mode(FILE)"); // Reset head and the Git index git::reset_head(tx.repo_mut(), &commit2).block_on()?; insta::assert_snapshot!(get_index_state(&workspace_root), @""); Ok(()) } #[test] fn test_reset_head_with_index_no_conflict() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); gix::init(&workspace_root)?; let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); let mut_repo = tx.repo_mut(); // Build tree containing every mode of file let tree = testutils::create_tree_with(&repo, |builder| { builder .file(repo_path("some/dir/normal-file"), "file\n") .executable(false); builder .file(repo_path("some/dir/executable-file"), "file\n") .executable(true); builder.symlink(repo_path("some/dir/symlink"), "./normal-file"); builder.submodule( repo_path("some/dir/commit"), testutils::write_random_commit(mut_repo).id().clone(), ); }); let parent_commit = mut_repo .new_commit(vec![repo.store().root_commit_id().clone()], tree.clone()) .write_unwrap(); let wc_commit = mut_repo .new_commit(vec![parent_commit.id().clone()], tree.clone()) .write_unwrap(); // Reset head to working copy commit git::reset_head(mut_repo, &wc_commit).block_on()?; // Git index should contain all files from the tree. // `Mode(DIR | SYMLINK)` actually means `MODE(COMMIT)`, as in a git submodule. insta::assert_snapshot!(get_index_state(&workspace_root), @" Unconflicted some/dir/commit Mode(DIR | SYMLINK) Unconflicted some/dir/executable-file Mode(FILE | FILE_EXECUTABLE) Unconflicted some/dir/normal-file Mode(FILE) Unconflicted some/dir/symlink Mode(SYMLINK) "); Ok(()) } #[test] fn test_reset_head_with_index_merge_conflict() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); gix::init(&workspace_root)?; let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); let mut_repo = tx.repo_mut(); // Build conflict trees containing every mode of file let base_tree = testutils::create_tree_with(&repo, |builder| { builder .file(repo_path("some/dir/normal-file"), "base\n") .executable(false); builder .file(repo_path("some/dir/executable-file"), "base\n") .executable(true); builder.symlink(repo_path("some/dir/symlink"), "./normal-file"); builder.submodule( repo_path("some/dir/commit"), testutils::write_random_commit(mut_repo).id().clone(), ); }); let left_tree = testutils::create_tree_with(&repo, |builder| { builder .file(repo_path("some/dir/normal-file"), "left\n") .executable(false); builder .file(repo_path("some/dir/executable-file"), "left\n") .executable(true); builder.symlink(repo_path("some/dir/symlink"), "./executable-file"); builder.submodule( repo_path("some/dir/commit"), testutils::write_random_commit(mut_repo).id().clone(), ); }); let right_tree = testutils::create_tree_with(&repo, |builder| { builder .file(repo_path("some/dir/normal-file"), "right\n") .executable(false); builder .file(repo_path("some/dir/executable-file"), "right\n") .executable(true); builder.symlink(repo_path("some/dir/symlink"), "./commit"); builder.submodule( repo_path("some/dir/commit"), testutils::write_random_commit(mut_repo).id().clone(), ); }); let base_commit = mut_repo .new_commit( vec![repo.store().root_commit_id().clone()], base_tree.clone(), ) .write_unwrap(); let left_commit = mut_repo .new_commit(vec![base_commit.id().clone()], left_tree.clone()) .write_unwrap(); let right_commit = mut_repo .new_commit(vec![base_commit.id().clone()], right_tree.clone()) .write_unwrap(); // Create working copy commit with resolution of conflict by taking the right // tree. This shouldn't affect the index, since the index is based on the parent // commit. let wc_commit = mut_repo .new_commit( vec![left_commit.id().clone(), right_commit.id().clone()], right_tree.clone(), ) .write_unwrap(); // Reset head to working copy commit with merge conflict git::reset_head(mut_repo, &wc_commit).block_on()?; // Index should contain conflicted files from merge of parent commits. // `Mode(DIR | SYMLINK)` actually means `MODE(COMMIT)`, as in a git submodule. insta::assert_snapshot!(get_index_state(&workspace_root), @" Base some/dir/commit Mode(DIR | SYMLINK) Ours some/dir/commit Mode(DIR | SYMLINK) Theirs some/dir/commit Mode(DIR | SYMLINK) Base some/dir/executable-file Mode(FILE | FILE_EXECUTABLE) Ours some/dir/executable-file Mode(FILE | FILE_EXECUTABLE) Theirs some/dir/executable-file Mode(FILE | FILE_EXECUTABLE) Base some/dir/normal-file Mode(FILE) Ours some/dir/normal-file Mode(FILE) Theirs some/dir/normal-file Mode(FILE) Base some/dir/symlink Mode(SYMLINK) Ours some/dir/symlink Mode(SYMLINK) Theirs some/dir/symlink Mode(SYMLINK) "); Ok(()) } #[test] fn test_reset_head_with_index_file_directory_conflict() -> TestResult { // Create colocated workspace let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); gix::init(&workspace_root)?; let (_workspace, repo) = Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git")) .block_on()?; let mut tx = repo.start_transaction(); let mut_repo = tx.repo_mut(); // Build conflict trees containing file-directory conflict let left_tree = testutils::create_tree_with(&repo, |builder| { builder.file(repo_path("test/dir/file"), "dir\n"); }); let right_tree = testutils::create_tree_with(&repo, |builder| { builder.file(repo_path("test"), "file\n"); }); let left_commit = mut_repo .new_commit( vec![repo.store().root_commit_id().clone()], left_tree.clone(), ) .write_unwrap(); let right_commit = mut_repo .new_commit( vec![repo.store().root_commit_id().clone()], right_tree.clone(), ) .write_unwrap(); let wc_commit = mut_repo .new_commit( vec![left_commit.id().clone(), right_commit.id().clone()], repo.store().empty_merged_tree().clone(), ) .write_unwrap(); // Reset head to working copy commit with file-directory conflict git::reset_head(mut_repo, &wc_commit).block_on()?; // Only the file should be added to the index (the tree should be skipped). insta::assert_snapshot!(get_index_state(&workspace_root), @"Theirs test Mode(FILE)"); Ok(()) } #[test] fn test_init() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let git_repo_dir = temp_dir.path().join("git"); let jj_repo_dir = temp_dir.path().join("jj"); let git_repo = testutils::git::init_bare(git_repo_dir); let initial_git_commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); std::fs::create_dir(&jj_repo_dir)?; let repo = &ReadonlyRepo::init( &settings, &jj_repo_dir, &|settings, store_path| { Ok(Box::new(GitBackend::init_external( settings, store_path, git_repo.path(), )?)) }, Signer::from_settings(&settings)?, ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), ReadonlyRepo::default_submodule_store_initializer(), ) .block_on()?; // The refs were *not* imported -- it's the caller's responsibility to import // any refs they care about. assert!(!repo.view().heads().contains(&jj_id(initial_git_commit))); Ok(()) } #[test] fn test_fetch_empty_repo() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; let default_branch = fetcher.get_default_branch("origin".as_ref())?; let stats = fetcher.import_refs().block_on()?; // No default bookmark and no refs assert_eq!(default_branch, None); assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); assert_eq!(*tx.repo().view().git_refs(), btreemap! {}); assert_eq!(tx.repo().view().bookmarks().count(), 0); Ok(()) } #[test] fn test_fetch_initial_commit_head_is_not_set() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; let default_branch = fetcher.get_default_branch("origin".as_ref())?; let stats = fetcher.import_refs().block_on()?; // No default bookmark because the origin repo's HEAD wasn't set assert_eq!(default_branch, None); assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); let repo = tx.commit("test").block_on()?; // The initial commit is visible after git_fetch(). let view = repo.view(); assert!(view.heads().contains(&jj_id(initial_git_commit))); let initial_commit_target = RefTarget::normal(jj_id(initial_git_commit)); let initial_commit_remote_ref = RemoteRef { target: initial_commit_target.clone(), state: RemoteRefState::New, }; assert_eq!( *view.git_refs(), btreemap! { "refs/remotes/origin/main".into() => initial_commit_target.clone(), } ); assert_eq!( view.bookmarks().collect::>(), btreemap! { "main".as_ref() => LocalRemoteRefTarget { local_target: RefTarget::absent_ref(), remote_refs: vec![ ("origin".as_ref(), &initial_commit_remote_ref), ], }, } ); Ok(()) } #[test] fn test_fetch_initial_commit_head_is_set() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); testutils::git::set_symbolic_reference(&test_data.origin_repo, "HEAD", "refs/heads/main"); let new_git_commit = empty_git_commit( &test_data.origin_repo, "refs/heads/main", &[initial_git_commit], ); test_data.origin_repo.reference( "refs/tags/v1.0", new_git_commit, gix::refs::transaction::PreviousValue::MustNotExist, "", )?; let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; let default_branch = fetcher.get_default_branch("origin".as_ref())?; let stats = fetcher.import_refs().block_on()?; assert_eq!(default_branch, Some("main".into())); assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); Ok(()) } #[test] fn test_fetch_success() -> TestResult { let mut test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = auto_track_import_options(); let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options.clone(), &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; fetcher.import_refs().block_on()?; test_data.repo = tx.commit("test").block_on()?; testutils::git::set_symbolic_reference(&test_data.origin_repo, "HEAD", "refs/heads/main"); let new_git_commit = empty_git_commit( &test_data.origin_repo, "refs/heads/main", &[initial_git_commit], ); test_data.origin_repo.reference( "refs/tags/v1.0", new_git_commit, gix::refs::transaction::PreviousValue::MustNotExist, "", )?; let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; let default_branch = fetcher.get_default_branch("origin".as_ref())?; let stats = fetcher.import_refs().block_on()?; // The default bookmark is "main" assert_eq!(default_branch, Some("main".into())); assert!(stats.abandoned_commits.is_empty()); assert!(stats.rewritten_commit_ids.is_empty()); let repo = tx.commit("test").block_on()?; // The new commit is visible after we fetch again let view = repo.view(); assert!(view.heads().contains(&jj_id(new_git_commit))); let new_commit_target = RefTarget::normal(jj_id(new_git_commit)); let new_commit_remote_ref = RemoteRef { target: new_commit_target.clone(), state: RemoteRefState::Tracked, }; assert_eq!( *view.git_refs(), btreemap! { "refs/remotes/origin/main".into() => new_commit_target.clone(), // "refs/tags/v1.0" isn't exported yet } ); assert_eq!( view.bookmarks().collect::>(), btreemap! { "main".as_ref() => LocalRemoteRefTarget { local_target: &new_commit_target, remote_refs: vec![ ("origin".as_ref(), &new_commit_remote_ref), ], }, } ); assert_eq!( view.local_tags().collect_vec(), vec![("v1.0".as_ref(), &new_commit_target)], ); assert_eq!( view.all_remote_tags().collect_vec(), vec![(remote_symbol("v1.0", "origin"), &new_commit_remote_ref)] ); Ok(()) } #[test] fn test_fetch_prune_deleted_ref() -> TestResult { let test_data = GitRepoData::create(); let commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(); fetch_import_all(tx.repo_mut(), "origin".as_ref()); tx.repo_mut() .track_remote_bookmark(remote_symbol("main", "origin"))?; // Test the setup assert!(tx.repo().get_local_bookmark("main".as_ref()).is_present()); assert!( tx.repo() .get_remote_bookmark(remote_symbol("main", "origin")) .is_present() ); test_data .origin_repo .find_reference("refs/heads/main")? .delete()?; // After re-fetching, the bookmark should be deleted let stats = fetch_import_all(tx.repo_mut(), "origin".as_ref()); assert_eq!( stats.abandoned_commits.iter().map(Commit::id).collect_vec(), vec![&jj_id(commit)] ); assert!(stats.rewritten_commit_ids.is_empty()); assert!(tx.repo().get_local_bookmark("main".as_ref()).is_absent()); assert_eq!( tx.repo_mut() .get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent() ); Ok(()) } #[test] fn test_fetch_no_default_branch() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options.clone(), &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; fetcher.import_refs().block_on()?; empty_git_commit( &test_data.origin_repo, "refs/heads/main", &[initial_git_commit], ); // It's actually not enough to have a detached HEAD, it also needs to point to a // commit without a bookmark (that's possibly a bug in Git *and* libgit2), so // we point it to initial_git_commit. testutils::git::set_head_to_id(&test_data.origin_repo, initial_git_commit); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; let default_branch = fetcher.get_default_branch("origin".as_ref())?; fetcher.import_refs().block_on()?; // There is no default bookmark assert_eq!(default_branch, None); Ok(()) } #[test] fn test_fetch_empty_refspecs() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); // Base refspecs shouldn't be respected let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; let ref_expr = GitFetchRefExpression { bookmark: StringExpression::none(), tag: StringExpression::none(), }; fetch_with(&mut fetcher, "origin".as_ref(), ref_expr)?; fetcher.import_refs().block_on()?; assert_eq!( tx.repo_mut() .get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent() ); // No remote refs should have been fetched git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert_eq!( tx.repo_mut() .get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent() ); Ok(()) } #[test] fn test_fetch_environment_options() -> TestResult { let temp_dir = testutils::new_temp_dir(); let test_data = GitRepoData::create(); let import_options = default_import_options(); let mut subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let trace_path = temp_dir.path().join("git-trace.log"); subprocess_options .environment .insert("GIT_TRACE".into(), trace_path.clone().into()); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; fetch_all_with(&mut fetcher, "origin".as_ref())?; assert!(trace_path.exists()); Ok(()) } #[test] fn test_load_default_fetch_bookmarks() -> TestResult { let mut test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let git_repo = get_git_repo(&test_repo.repo); let config = git_repo.config_snapshot().clone(); // NB: gix doesn't seem to round-trip some of these refspecs even though // they parse fine, so we'll update the config file here directly std::fs::OpenOptions::new() .append(true) .open( config .meta() .path .as_ref() .expect("failed to find config file"), ) .expect("failed to open config file") .write_all( br#" [remote "origin"] url = /dev/null # Valid fetch = +refs/heads/main:refs/remotes/origin/main fetch = +refs/heads/foo*:refs/remotes/origin/foo* fetch = ^refs/heads/excluded fetch = ^refs/heads/fooqux # Invalid fetch = +refs/heads/src-only fetch = refs/heads/non-forced fetch = refs/heads/non-forced:refs/remotes/origin/non-forced fetch = +refs/heads/wrong-dst:refs/remotes/tags/wrong-dst fetch = +refs/heads/wrong-remote:refs/remotes/origin2/wrong-remote fetch = +refs/tags/wrong-src:refs/remotes/origin/wrong-src fetch = ^refs/tags/unsupported [remote "positive-only"] url = /dev/null fetch = +refs/heads/*:refs/remotes/positive-only/* "#, ) .expect("failed to update config file"); // Reload after Git configuration change. test_repo.repo = test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); let git_repo = get_git_repo(&test_repo.repo); let (IgnoredRefspecs(ignored_refspecs), bookmark_expr) = load_default_fetch_bookmarks("origin".as_ref(), &git_repo) .expect("failed to load refspecs"); let mut warnings = String::new(); for IgnoredRefspec { refspec, reason } in ignored_refspecs { warnings.push_str(reason); warnings.push_str(": "); warnings.push_str(&String::from_utf8_lossy(&refspec)); warnings.push('\n'); } insta::assert_snapshot!(warnings, @" fetch-only refspecs are not supported: refs/heads/non-forced fetch-only refspecs are not supported: refs/heads/src-only only refs/heads/ is supported for refspec sources: ^refs/tags/unsupported non-forced refspecs are not supported: refs/heads/non-forced:refs/remotes/origin/non-forced remote renaming not supported: +refs/heads/wrong-dst:refs/remotes/tags/wrong-dst remote renaming not supported: +refs/heads/wrong-remote:refs/remotes/origin2/wrong-remote only refs/heads/ is supported for refspec sources: +refs/tags/wrong-src:refs/remotes/origin/wrong-src "); insta::assert_debug_snapshot!(bookmark_expr, @r#" Intersection( Union( Pattern( Glob( GlobPattern( "foo*", ), ), ), Pattern( Exact( "main", ), ), ), NotIn( Union( Pattern( Exact( "excluded", ), ), Pattern( Exact( "fooqux", ), ), ), ), ) "#); let (IgnoredRefspecs(ignored_refspecs), bookmark_expr) = load_default_fetch_bookmarks("positive-only".as_ref(), &git_repo) .expect("failed to load refspecs"); assert!(ignored_refspecs.is_empty()); insta::assert_debug_snapshot!(bookmark_expr, @r#" Pattern( Glob( GlobPattern( "*", ), ), ) "#); Ok(()) } #[test] fn test_load_default_fetch_bookmarks_invalid_configuration() -> TestResult { let mut test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let git_repo = get_git_repo(&test_repo.repo); let config = git_repo.config_snapshot().clone(); // These refspecs are already rejected by gix, but we want to assert the error // reporting here std::fs::OpenOptions::new() .append(true) .open( config .meta() .path .as_ref() .expect("failed to find config file"), ) .expect("failed to open config file") .write_all( br#" [remote "first"] url = /dev/null fetch = +refs/heads/bad*pattern*:refs/remotes/heads/bad*pattern* [remote "second"] url = /dev/null fetch = +refs/heads/badpattern?:refs/remotes/heads/badpattern? [remote "third"] url = /dev/null fetch = +refs/heads/bad[pat]:refs/remotes/heads/bad[pat] "#, ) .expect("failed to update config file"); // Reload after Git configuration change. test_repo.repo = test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); let git_repo = get_git_repo(&test_repo.repo); let first_err = load_default_fetch_bookmarks("first".as_ref(), &git_repo).unwrap_err(); let second_err = load_default_fetch_bookmarks("second".as_ref(), &git_repo).unwrap_err(); let third_err = load_default_fetch_bookmarks("third".as_ref(), &git_repo).unwrap_err(); insta::assert_snapshot!(format!("{first_err:#?}\n{second_err:#?}\n{third_err:#?}"), @r#" InvalidRemoteConfiguration( RemoteNameBuf( "first", ), RefSpec { kind: "fetch", remote_name: "first", source: Error { key: "remote..fetch", value: Some( "+refs/heads/bad*pattern*:refs/remotes/heads/bad*pattern*", ), environment_override: None, source: Some( PatternUnsupported { pattern: "refs/heads/bad*pattern*", }, ), }, }, ) InvalidRemoteConfiguration( RemoteNameBuf( "second", ), RefSpec { kind: "fetch", remote_name: "second", source: Error { key: "remote..fetch", value: Some( "+refs/heads/badpattern?:refs/remotes/heads/badpattern?", ), environment_override: None, source: Some( ReferenceName( InvalidByte { byte: "?", }, ), ), }, }, ) InvalidRemoteConfiguration( RemoteNameBuf( "third", ), RefSpec { kind: "fetch", remote_name: "third", source: Error { key: "remote..fetch", value: Some( "+refs/heads/bad[pat]:refs/remotes/heads/bad[pat]", ), environment_override: None, source: Some( ReferenceName( InvalidByte { byte: "[", }, ), ), }, }, ) "#); Ok(()) } #[test] fn test_fetch_no_such_remote() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; let result = fetch_all_with(&mut fetcher, "invalid-remote".as_ref()); assert!(matches!(result, Err(GitFetchError::NoSuchRemote(_)))); Ok(()) } #[test] fn test_fetch_multiple_branches() -> TestResult { let test_data = GitRepoData::create(); let _initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let mut tx = test_data.repo.start_transaction(); let mut fetcher = GitFetch::new(tx.repo_mut(), subprocess_options, &import_options)?; let ref_expr = GitFetchRefExpression { bookmark: StringExpression::union_all(vec![ StringExpression::exact("main"), StringExpression::exact("noexist1"), StringExpression::exact("noexist2"), ]), tag: StringExpression::none(), }; fetch_with(&mut fetcher, "origin".as_ref(), ref_expr)?; let stats = fetcher.import_refs().block_on()?; assert_eq!( stats .changed_remote_bookmarks .iter() .map(|update| &update.symbol) .collect_vec(), [remote_symbol("main", "origin")] ); Ok(()) } #[test] fn test_fetch_local_remote_conflicts() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = auto_track_import_options(); let fetch_import = |mut_repo: &mut MutableRepo| { let mut fetcher = GitFetch::new(mut_repo, subprocess_options.clone(), &import_options).unwrap(); fetch_all_with(&mut fetcher, "origin".as_ref()).unwrap(); fetcher.import_refs().block_on().unwrap() }; // Create bookmark and tag at remote. let commit1 = empty_git_commit(&test_data.origin_repo, "refs/heads/bookmark", &[]); git_ref(&test_data.origin_repo, "refs/tags/tag", commit1); // Create bookmark and tag of the same name locally. let mut tx = test_data.repo.start_transaction(); let commit2 = write_random_commit(tx.repo_mut()); let target2 = RefTarget::normal(commit2.id().clone()); let commit3 = write_random_commit(tx.repo_mut()); let target3 = RefTarget::normal(commit3.id().clone()); tx.repo_mut() .set_local_bookmark_target("bookmark".as_ref(), target2.clone()); tx.repo_mut() .set_local_tag_target("tag".as_ref(), target3.clone()); // Fetch and track bookmark and tag. let stats = fetch_import(tx.repo_mut()); let repo = tx.commit("test").block_on()?; assert_eq!(stats.changed_remote_bookmarks.len(), 1); assert_eq!(stats.changed_remote_tags.len(), 1); let conflicted_target2 = RefTarget::from_merge(Merge::from_vec(vec![ Some(commit2.id().clone()), None, Some(jj_id(commit1)), ])); let conflicted_target3 = RefTarget::from_merge(Merge::from_vec(vec![ Some(commit3.id().clone()), None, Some(jj_id(commit1)), ])); assert_eq!( repo.view().get_local_bookmark("bookmark".as_ref()), &conflicted_target2 ); assert_eq!( repo.view().get_local_tag("tag".as_ref()), &conflicted_target3 ); Ok(()) } #[test] fn test_fetch_with_tag_changes() -> TestResult { let test_data = GitRepoData::create(); // Create tagged commit at remote. let commit1 = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); git_ref(&test_data.origin_repo, "refs/tags/tag1", commit1); let target1 = RefTarget::normal(jj_id(commit1)); let remote_ref1 = RemoteRef { target: target1.clone(), state: RemoteRefState::Tracked, }; // Set up tags that don't exist in Git repo. let mut tx = test_data.repo.start_transaction(); let commit2 = write_random_commit(tx.repo_mut()); let target2 = RefTarget::normal(commit2.id().clone()); let remote_ref2 = RemoteRef { target: target2.clone(), state: RemoteRefState::Tracked, }; tx.repo_mut() .set_local_tag_target("tag2".as_ref(), target2.clone()); tx.repo_mut() .set_remote_tag(remote_symbol("tag2", "git"), remote_ref2.clone()); tx.repo_mut() .set_remote_tag(remote_symbol("tag2", "origin"), remote_ref2.clone()); let repo = tx.commit("test").block_on()?; // Fetch and import refs. let mut tx = repo.start_transaction(); let stats = fetch_import_all(tx.repo_mut(), "origin".as_ref()); tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; assert_eq!(stats.changed_remote_tags.len(), 2); assert_eq!( stats.changed_remote_tags[0].symbol, remote_symbol("tag1", "origin") ); assert_eq!( stats.changed_remote_tags[1].symbol, remote_symbol("tag2", "origin") ); // Fetched tags should be mapped to remote tags, then merged to local tags. assert_eq!(repo.view().get_local_tag("tag1".as_ref()), &target1); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "origin")), &remote_ref1 ); assert!(repo.view().get_local_tag("tag2".as_ref()).is_absent()); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag2", "origin")), RemoteRef::absent_ref() ); Ok(()) } #[test] fn test_fetch_with_explicit_tag_patterns() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let fetch_import = |mut_repo: &mut MutableRepo, tag: StringExpression| { let mut fetcher = GitFetch::new(mut_repo, subprocess_options.clone(), &import_options).unwrap(); let ref_expr = GitFetchRefExpression { // Include all bookmarks to ensure that tags should never be fetched // implicitly. bookmark: StringExpression::all(), tag, }; fetch_with(&mut fetcher, "origin".as_ref(), ref_expr).unwrap(); fetcher.import_refs().block_on().unwrap() }; // Create tagged commit at remote: tag1 could be fetched implicitly by // following the main branch. tag1 isn't. let commit1 = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); git_ref(&test_data.origin_repo, "refs/tags/tag1", commit1); let commit2 = empty_git_commit(&test_data.origin_repo, "refs/tags/tag2", &[]); let target1 = RefTarget::normal(jj_id(commit1)); let target2 = RefTarget::normal(jj_id(commit2)); let remote_ref1 = RemoteRef { target: target1.clone(), state: RemoteRefState::Tracked, }; let remote_ref2 = RemoteRef { target: target2.clone(), state: RemoteRefState::Tracked, }; // Fetch "tag2". "tag1" shouldn't be fetched implicitly. let mut tx = test_data.repo.start_transaction(); let stats = fetch_import(tx.repo_mut(), StringExpression::exact("tag2")); let repo = tx.commit("test").block_on()?; assert_eq!(stats.changed_remote_tags.len(), 1); assert_eq!( stats.changed_remote_tags[0].symbol, remote_symbol("tag2", "origin") ); assert!(repo.view().get_local_tag("tag1".as_ref()).is_absent()); assert_eq!(repo.view().get_local_tag("tag2".as_ref()), &target2); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag2", "origin")), &remote_ref2 ); // commit1 is fetched by "main", commit2 is by "tag2" assert_eq!( *repo.view().heads(), hashset! { jj_id(commit1), jj_id(commit2) } ); // Fetch "tag1". "tag2" should be unchanged. let mut tx = repo.start_transaction(); let stats = fetch_import(tx.repo_mut(), StringExpression::exact("tag1")); let repo = tx.commit("test").block_on()?; assert_eq!(stats.changed_remote_tags.len(), 1); assert_eq!( stats.changed_remote_tags[0].symbol, remote_symbol("tag1", "origin") ); assert_eq!(repo.view().get_local_tag("tag1".as_ref()), &target1); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "origin")), &remote_ref1 ); assert_eq!(repo.view().get_local_tag("tag2".as_ref()), &target2); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag2", "origin")), &remote_ref2 ); assert_eq!( *repo.view().heads(), hashset! { jj_id(commit1), jj_id(commit2) } ); Ok(()) } #[test] fn test_fetch_export_annotated_tags() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let fetch_import = |mut_repo: &mut MutableRepo| { let mut fetcher = GitFetch::new(mut_repo, subprocess_options.clone(), &import_options).unwrap(); let ref_expr = GitFetchRefExpression { bookmark: StringExpression::none(), tag: StringExpression::all(), }; fetch_with(&mut fetcher, "origin".as_ref(), ref_expr).unwrap(); fetcher.import_refs().block_on().unwrap() }; // Create tags at remote let commit1 = empty_git_commit(&test_data.origin_repo, "refs/tags/tag1", &[]); let commit2 = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let commit3 = empty_git_commit(&test_data.origin_repo, "refs/tags/tag3.4", &[]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; let tag2_oid = test_data .origin_repo .tag("tag2", commit2, kind, None, "", constraint)? .id(); let target1 = RefTarget::normal(jj_id(commit1)); let target2 = RefTarget::normal(jj_id(commit2)); let target3 = RefTarget::normal(jj_id(commit3)); let remote_ref1 = RemoteRef { target: target1.clone(), state: RemoteRefState::Tracked, }; let remote_ref2 = RemoteRef { target: target2.clone(), state: RemoteRefState::Tracked, }; let remote_ref3 = RemoteRef { target: target3.clone(), state: RemoteRefState::Tracked, }; // Fetch tags, merge remote tags, update one of merged local tags, and // export local tags to Git let mut tx = test_data.repo.start_transaction(); fetch_import(tx.repo_mut()); let commit4 = write_random_commit(tx.repo_mut()); let target4 = RefTarget::normal(commit4.id().clone()); let remote_ref4 = RemoteRef { target: target4.clone(), state: RemoteRefState::Tracked, }; tx.repo_mut() .set_local_tag_target("tag3.4".as_ref(), target4.clone()); git::export_refs(tx.repo_mut())?; let repo = tx.commit("test").block_on()?; assert_eq!(repo.view().get_local_tag("tag1".as_ref()), &target1); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "git")), &remote_ref1 ); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag1", "origin")), &remote_ref1 ); assert_eq!(repo.view().get_local_tag("tag2".as_ref()), &target2); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag2", "git")), &remote_ref2 ); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag2", "origin")), &remote_ref2 ); assert_eq!(repo.view().get_local_tag("tag3.4".as_ref()), &target4); assert_eq!( repo.view().get_remote_tag(remote_symbol("tag3.4", "git")), &remote_ref4 ); assert_eq!( repo.view() .get_remote_tag(remote_symbol("tag3.4", "origin")), &remote_ref3 ); assert_eq!( test_data.git_repo.find_reference("refs/tags/tag1")?.id(), commit1 ); // Exported local tag should point to the original annotated tag assert_eq!( test_data.git_repo.find_reference("refs/tags/tag2")?.id(), tag2_oid ); // Locally-moved tag shouldn't point to the original remote tag target assert_eq!( test_data.git_repo.find_reference("refs/tags/tag3.4")?.id(), git_id(&commit4) ); Ok(()) } struct PushTestSetup { source_repo_dir: PathBuf, jj_repo: Arc, main_commit: Commit, child_of_main_commit: Commit, parent_of_main_commit: Commit, sideways_commit: Commit, } /// Set up a situation where `main` is at `main_commit`, the child of /// `parent_of_main_commit`, both in the source repo and in jj's clone of the /// repo. In jj's clone, there are also two more commits, `child_of_main_commit` /// and `sideways_commit`, arranged as follows: /// /// o child_of_main_commit /// o main_commit /// o parent_of_main_commit /// | o sideways_commit /// |/ /// ~ root fn set_up_push_repos(settings: &UserSettings, temp_dir: &TempDir) -> PushTestSetup { let source_repo_dir = temp_dir.path().join("source"); let clone_repo_dir = temp_dir.path().join("clone"); let jj_repo_dir = temp_dir.path().join("jj"); let source_repo = testutils::git::init_bare(&source_repo_dir); let parent_of_initial_git_commit = empty_git_commit(&source_repo, "refs/heads/main", &[]); let initial_git_commit = empty_git_commit( &source_repo, "refs/heads/main", &[parent_of_initial_git_commit], ); let clone_repo = testutils::git::clone(&clone_repo_dir, source_repo_dir.to_str().unwrap(), None); std::fs::create_dir(&jj_repo_dir).unwrap(); let jj_repo = ReadonlyRepo::init( settings, &jj_repo_dir, &|settings, store_path| { Ok(Box::new(GitBackend::init_external( settings, store_path, clone_repo.path(), )?)) }, Signer::from_settings(settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), ReadonlyRepo::default_submodule_store_initializer(), ) .block_on() .unwrap(); get_git_backend(&jj_repo) .import_head_commits(&[jj_id(initial_git_commit)]) .unwrap(); let main_commit = jj_repo .store() .get_commit(&jj_id(initial_git_commit)) .unwrap(); let parent_of_main_commit = jj_repo .store() .get_commit(&jj_id(parent_of_initial_git_commit)) .unwrap(); let mut tx = jj_repo.start_transaction(); let sideways_commit = write_random_commit(tx.repo_mut()); let child_of_main_commit = write_random_commit_with_parents(tx.repo_mut(), &[&main_commit]); tx.repo_mut().set_git_ref_target( "refs/remotes/origin/main".as_ref(), RefTarget::normal(main_commit.id().clone()), ); tx.repo_mut().set_remote_bookmark( remote_symbol("main", "origin"), RemoteRef { target: RefTarget::normal(main_commit.id().clone()), // Caller expects the main bookmark is tracked. The corresponding local bookmark will // be created (or left as deleted) by caller. state: RemoteRefState::Tracked, }, ); let jj_repo = tx.commit("test").block_on().unwrap(); PushTestSetup { source_repo_dir, jj_repo, main_commit, child_of_main_commit, parent_of_main_commit, sideways_commit, } } #[test] fn test_push_bookmarks_success() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let mut setup = set_up_push_repos(&settings, &temp_dir); let clone_repo = get_git_repo(&setup.jj_repo); let mut tx = setup.jj_repo.start_transaction(); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let import_options = default_import_options(); let targets = GitPushRefTargets { bookmarks: vec![( "main".into(), Diff::new( Some(setup.main_commit.id().clone()), Some(setup.child_of_main_commit.id().clone()), ), )], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the ref got updated in the source repo let source_repo = testutils::git::open(&setup.source_repo_dir); let new_target = source_repo.find_reference("refs/heads/main")?; let new_oid = git_id(&setup.child_of_main_commit); assert_eq!(new_target.target().id(), new_oid); // Check that the ref got updated in the cloned repo. This just tests our // assumptions about libgit2 because we want the refs/remotes/origin/main // bookmark to be updated. let new_target = clone_repo.find_reference("refs/remotes/origin/main")?; assert_eq!(new_target.target().id(), new_oid); // Check that the repo view got updated let view = tx.repo().view(); assert_eq!( *view.get_git_ref("refs/remotes/origin/main".as_ref()), RefTarget::normal(setup.child_of_main_commit.id().clone()), ); assert_eq!( *view.get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef { target: RefTarget::normal(setup.child_of_main_commit.id().clone()), state: RemoteRefState::Tracked, }, ); // Check that the repo view reflects the changes in the Git repo setup.jj_repo = tx.commit("test").block_on()?; let mut tx = setup.jj_repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_bookmarks_deletion() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let mut setup = set_up_push_repos(&settings, &temp_dir); let clone_repo = get_git_repo(&setup.jj_repo); let mut tx = setup.jj_repo.start_transaction(); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let import_options = default_import_options(); let source_repo = testutils::git::open(&setup.source_repo_dir); // Test the setup assert!(source_repo.find_reference("refs/heads/main").is_ok()); let targets = GitPushRefTargets { bookmarks: vec![( "main".into(), Diff::new(Some(setup.main_commit.id().clone()), None), )], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the ref got deleted in the source repo assert!(source_repo.find_reference("refs/heads/main").is_err()); // Check that the ref got deleted in the cloned repo. This just tests our // assumptions about libgit2 because we want the refs/remotes/origin/main // bookmark to be deleted. assert!( clone_repo .find_reference("refs/remotes/origin/main") .is_err() ); // Check that the repo view got updated let view = tx.repo().view(); assert!( view.get_git_ref("refs/remotes/origin/main".as_ref()) .is_absent() ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent_ref() ); // Check that the repo view reflects the changes in the Git repo setup.jj_repo = tx.commit("test").block_on()?; let mut tx = setup.jj_repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_bookmarks_mixed_deletion_and_addition() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let mut setup = set_up_push_repos(&settings, &temp_dir); let mut tx = setup.jj_repo.start_transaction(); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let import_options = default_import_options(); let targets = GitPushRefTargets { bookmarks: vec![ ( "main".into(), Diff::new(Some(setup.main_commit.id().clone()), None), ), ( "topic".into(), Diff::new(None, Some(setup.child_of_main_commit.id().clone())), ), ], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), GitRefNameBuf( "refs/heads/topic", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the topic ref got updated in the source repo let source_repo = testutils::git::open(&setup.source_repo_dir); let new_target = source_repo.find_reference("refs/heads/topic")?; assert_eq!( new_target.target().id(), git_id(&setup.child_of_main_commit) ); // Check that the main ref got deleted in the source repo assert!(source_repo.find_reference("refs/heads/main").is_err()); // Check that the repo view got updated let view = tx.repo().view(); assert!( view.get_git_ref("refs/remotes/origin/main".as_ref()) .is_absent() ); assert_eq!( view.get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef::absent_ref() ); assert_eq!( *view.get_git_ref("refs/remotes/origin/topic".as_ref()), RefTarget::normal(setup.child_of_main_commit.id().clone()), ); assert_eq!( *view.get_remote_bookmark(remote_symbol("topic", "origin")), RemoteRef { target: RefTarget::normal(setup.child_of_main_commit.id().clone()), state: RemoteRefState::Tracked, }, ); // Check that the repo view reflects the changes in the Git repo setup.jj_repo = tx.commit("test").block_on()?; let mut tx = setup.jj_repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_bookmarks_not_fast_forward() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let mut tx = setup.jj_repo.start_transaction(); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let targets = GitPushRefTargets { bookmarks: vec![( "main".into(), Diff::new( Some(setup.main_commit.id().clone()), Some(setup.sideways_commit.id().clone()), ), )], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the ref got updated in the source repo let source_repo = testutils::git::open(&setup.source_repo_dir); let new_target = source_repo.find_reference("refs/heads/main")?; assert_eq!(new_target.target().id(), git_id(&setup.sideways_commit)); Ok(()) } #[test] fn test_push_bookmarks_partial_success() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let mut tx = setup.jj_repo.start_transaction(); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let targets = GitPushRefTargets { bookmarks: vec![ ( "main".into(), Diff::new( Some(setup.main_commit.id().clone()), Some(setup.child_of_main_commit.id().clone()), ), ), ( "other".into(), Diff::new( Some(setup.main_commit.id().clone()), // bad old state Some(setup.child_of_main_commit.id().clone()), ), ), ], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [ ( GitRefNameBuf( "refs/heads/other", ), Some( "stale info", ), ), ], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the repo view got updated only for the pushed refs let view = tx.repo().view(); assert_eq!( *view.get_git_ref("refs/remotes/origin/main".as_ref()), RefTarget::normal(setup.child_of_main_commit.id().clone()) ); assert_eq!( *view.get_remote_bookmark(remote_symbol("main", "origin")), RemoteRef { target: RefTarget::normal(setup.child_of_main_commit.id().clone()), state: RemoteRefState::Tracked, } ); assert_eq!( view.get_git_ref("refs/remotes/origin/other".as_ref()), RefTarget::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("other", "origin")), RemoteRef::absent_ref() ); Ok(()) } #[test] fn test_push_bookmarks_unmapped_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let subprocess_options = GitSubprocessOptions::from_settings(test_repo.repo.settings())?; let remote_git_repo = testutils::git::init_bare(test_repo.env.root().join("remote")); // Add remote with refspecs that map only specific branch let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let mut remote = git_repo .remote_at(remote_git_repo.path().to_str().unwrap())? .with_refspecs( ["+refs/heads/dummy:refs/remotes/origin/dummy"], gix::remote::Direction::Fetch, )?; let mut config = git_repo.config_snapshot().clone(); remote.save_as_to("origin", &mut config).unwrap(); git::save_git_config(&config)?; // Reload after Git configuration change. let repo = test_repo .env .load_repo_at_head(repo.settings(), test_repo.repo_path()); let git_repo = get_git_repo(&repo); let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); let commit2a = write_random_commit(tx.repo_mut()); let commit2b = write_random_commit(tx.repo_mut()); // Add conflicting remote bookmark git_repo.reference( "refs/remotes/origin/bookmark2", git_id(&commit2a), gix::refs::transaction::PreviousValue::MustNotExist, "", )?; let targets = GitPushRefTargets { bookmarks: vec![ ( "bookmark1".into(), Diff::new(None, Some(commit1.id().clone())), ), ( "bookmark2".into(), Diff::new(None, Some(commit2b.id().clone())), ), ], tags: vec![], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/bookmark1", ), GitRefNameBuf( "refs/heads/bookmark2", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [ ( RemoteRefSymbolBuf { name: RefNameBuf( "bookmark2", ), remote: RemoteNameBuf( "origin", ), }, AddedInJjAddedInGit, ), ], } "#); // Check that the remote refs are exported to Git assert_eq!( git_repo .find_reference("refs/remotes/origin/bookmark1")? .into_fully_peeled_id()?, git_id(&commit1) ); // Check that the repo view got updated only for the exported refs let view = tx.repo().view(); assert_eq!( *view.get_git_ref("refs/remotes/origin/bookmark1".as_ref()), RefTarget::normal(commit1.id().clone()) ); assert_eq!( *view.get_remote_bookmark(remote_symbol("bookmark1", "origin")), RemoteRef { target: RefTarget::normal(commit1.id().clone()), state: RemoteRefState::Tracked, } ); assert_eq!( view.get_git_ref("refs/remotes/origin/bookmark2".as_ref()), RefTarget::absent_ref() ); assert_eq!( view.get_remote_bookmark(remote_symbol("bookmark2", "origin")), RemoteRef::absent_ref() ); Ok(()) } #[test] fn test_push_new_tags() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let origin_repo = test_data.origin_repo; let git_repo = test_data.git_repo; // Create lightweight and annotated tags pointing to the same commit. let commit1_oid = empty_git_commit(&git_repo, "refs/tags/lightweight", &[]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; let annotated_tag1_oid = git_repo .tag("annotated", commit1_oid, kind, None, "", constraint)? .id() .detach(); let mut tx = test_data.repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let update = Diff::new(None, Some(jj_id(commit1_oid))); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Lightweight and annotated tags should be created in the remote repo. assert_eq!( origin_repo.find_reference("refs/tags/lightweight")?.id(), commit1_oid ); assert_eq!( origin_repo.find_reference("refs/tags/annotated")?.id(), annotated_tag1_oid ); // Remote tags should also be recorded locally. let view = tx.repo().view(); assert_eq!( *view.get_remote_tag(remote_symbol("lightweight", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit1_oid)), state: RemoteRefState::Tracked, }, ); assert_eq!( *view.get_remote_tag(remote_symbol("annotated", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit1_oid)), state: RemoteRefState::Tracked, }, ); // There should be no changes to be imported from the Git repo. let repo = tx.commit("test").block_on()?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_deleted_tags() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let origin_repo = test_data.origin_repo; // Create lightweight and annotated tags remotely. let commit1_oid = empty_git_commit(&origin_repo, "refs/tags/lightweight", &[]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; origin_repo.tag("annotated", commit1_oid, kind, None, "", constraint)?; // Fetch and delete local tags. let mut tx = test_data.repo.start_transaction(); fetch_import_all(tx.repo_mut(), "origin".as_ref()); tx.repo_mut() .set_local_tag_target("lightweight".as_ref(), RefTarget::absent()); tx.repo_mut() .set_local_tag_target("annotated".as_ref(), RefTarget::absent()); git::export_refs(tx.repo_mut()).unwrap(); // Remote tags should still exist locally. let view = tx.repo().view(); assert_eq!( *view.get_remote_tag(remote_symbol("lightweight", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit1_oid)), state: RemoteRefState::Tracked, }, ); assert_eq!( *view.get_remote_tag(remote_symbol("annotated", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit1_oid)), state: RemoteRefState::Tracked, }, ); let update = Diff::new(Some(jj_id(commit1_oid)), None); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Lightweight and annotated tags should be deleted in the remote repo. assert!( origin_repo .try_find_reference("refs/tags/lightweight")? .is_none() ); assert!( origin_repo .try_find_reference("refs/tags/annotated")? .is_none() ); // Remote tags should also be deleted locally. let view = tx.repo().view(); assert_eq!( *view.get_remote_tag(remote_symbol("lightweight", "origin")), RemoteRef::absent() ); assert_eq!( *view.get_remote_tag(remote_symbol("annotated", "origin")), RemoteRef::absent() ); // There should be no changes to be imported from the Git repo. let repo = tx.commit("test").block_on()?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_moved_tags_without_fetching() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let origin_repo = test_data.origin_repo; let git_repo = test_data.git_repo; // Create lightweight and annotated tags pointing to the same commit. let commit1_oid = empty_git_commit(&git_repo, "refs/tags/lightweight", &[]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; git_repo.tag("annotated", commit1_oid, kind, None, "", constraint)?; let mut tx = test_data.repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; // Push new tags let update = Diff::new(None, Some(jj_id(commit1_oid))); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options.clone(), "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Move pushed tags let commit2_oid = empty_git_commit(&git_repo, "refs/tags/lightweight", &[commit1_oid]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustExist; let annotated_tag2_oid = git_repo .tag("annotated", commit2_oid, kind, None, "", constraint)? .id() .detach(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; // Push moved tags let update = Diff::new(Some(jj_id(commit1_oid)), Some(jj_id(commit2_oid))); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options.clone(), "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Lightweight and annotated tags should be updated in the remote repo. assert_eq!( origin_repo.find_reference("refs/tags/lightweight")?.id(), commit2_oid ); assert_eq!( origin_repo.find_reference("refs/tags/annotated")?.id(), annotated_tag2_oid ); // Remote tags should also be recorded locally. let view = tx.repo().view(); assert_eq!( *view.get_remote_tag(remote_symbol("lightweight", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit2_oid)), state: RemoteRefState::Tracked, }, ); assert_eq!( *view.get_remote_tag(remote_symbol("annotated", "origin")), RemoteRef { target: RefTarget::normal(jj_id(commit2_oid)), state: RemoteRefState::Tracked, }, ); // There should be no changes to be imported from the Git repo. let repo = tx.commit("test").block_on()?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } #[test] fn test_push_deleted_tags_without_fetching() -> TestResult { let test_data = GitRepoData::create(); let subprocess_options = GitSubprocessOptions::from_settings(test_data.repo.settings())?; let import_options = default_import_options(); let origin_repo = test_data.origin_repo; let git_repo = test_data.git_repo; // Create lightweight and annotated tags pointing to the same commit. let commit1_oid = empty_git_commit(&git_repo, "refs/tags/lightweight", &[]); let kind = gix::object::Kind::Commit; let constraint = gix::refs::transaction::PreviousValue::MustNotExist; git_repo.tag("annotated", commit1_oid, kind, None, "", constraint)?; let mut tx = test_data.repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; // Push new tags let update = Diff::new(None, Some(jj_id(commit1_oid))); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options.clone(), "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Delete pushed tags tx.repo_mut() .set_local_tag_target("lightweight".as_ref(), RefTarget::absent()); tx.repo_mut() .set_local_tag_target("annotated".as_ref(), RefTarget::absent()); git::export_refs(tx.repo_mut()).unwrap(); // Push deleted tags let update = Diff::new(Some(jj_id(commit1_oid)), None); let targets = GitPushRefTargets { bookmarks: vec![], tags: vec![ ("lightweight".into(), update.clone()), ("annotated".into(), update.clone()), ], }; let stats = git::push_refs( tx.repo_mut(), subprocess_options.clone(), "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert_eq!(stats.pushed.len(), 2); assert!(stats.all_ok()); // Lightweight and annotated tags should be deleted in the remote repo. assert!( origin_repo .try_find_reference("refs/tags/lightweight")? .is_none() ); assert!( origin_repo .try_find_reference("refs/tags/annotated")? .is_none() ); // Remote tags should also be deleted locally. let view = tx.repo().view(); assert_eq!( *view.get_remote_tag(remote_symbol("lightweight", "origin")), RemoteRef::absent() ); assert_eq!( *view.get_remote_tag(remote_symbol("annotated", "origin")), RemoteRef::absent() ); // There should be no changes to be imported from the Git repo. let repo = tx.commit("test").block_on()?; let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; assert!(!tx.repo().has_changes()); Ok(()) } // TODO(ilyagr): More tests for push safety checks were originally planned. We // may want to add tests for when a bookmark unexpectedly moved backwards or // unexpectedly does not exist for bookmark deletion. #[test] fn test_push_updates_unexpectedly_moved_sideways_on_remote() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); // The main bookmark is actually at `main_commit` on the remote. If we expect // it to be at `sideways_commit`, it unexpectedly moved sideways from our // perspective. // // We cannot delete it or move it anywhere else. However, "moving" it to the // same place it already is is OK, following the behavior in // `test_merge_ref_targets`. // // For each test, we check that the push succeeds if and only if the bookmark // conflict `jj git fetch` would generate resolves to the push destination. let attempt_push_expecting_sideways = |target: Option<&Commit>| { let subprocess_options = GitSubprocessOptions::from_settings(&settings).unwrap(); let targets = [GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(Some(&setup.sideways_commit), target) .map(|commit| commit.map(git_id)), }]; git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), ) }; assert_eq!( push_status_rejected_references(attempt_push_expecting_sideways(None)?), vec!["refs/heads/main".to_owned()], ); assert_eq!( push_status_rejected_references(attempt_push_expecting_sideways(Some( &setup.child_of_main_commit ))?), vec!["refs/heads/main".to_owned()] ); // Here, the local bookmark hasn't moved from `sideways_commit` from our // perspective, but it moved to `main` on the remote. So, the conflict // resolves to `main`. // // `jj` should not actually attempt a push in this case, but if it did, the // push should fail. assert_eq!( push_status_rejected_references(attempt_push_expecting_sideways(Some( &setup.sideways_commit ))?), vec!["refs/heads/main".to_owned()] ); assert_eq!( push_status_rejected_references(attempt_push_expecting_sideways(Some( &setup.parent_of_main_commit ))?), vec!["refs/heads/main".to_owned()] ); // Moving the bookmark to the same place it already is is OK. let stats = attempt_push_expecting_sideways(Some(&setup.main_commit))?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); Ok(()) } #[test] fn test_push_updates_unexpectedly_moved_forward_on_remote() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); // The main bookmark is actually at `main_commit` on the remote. If we // expected it to be at `parent_of_commit`, it unexpectedly moved forward // from our perspective. // // We cannot delete it or move it sideways. (TODO: Moving it backwards is // also disallowed; there is currently no test for this). However, "moving" // it *forwards* is OK. This is allowed *only* in this test, i.e. if the // actual location is the descendant of the expected location, and the new // location is the descendant of that. // // For each test, we check that the push succeeds if and only if the bookmark // conflict `jj git fetch` would generate resolves to the push destination. let attempt_push_expecting_parent = |target: Option<&Commit>| { let subprocess_options = GitSubprocessOptions::from_settings(&settings).unwrap(); let targets = [GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(Some(&setup.parent_of_main_commit), target) .map(|commit| commit.map(git_id)), }]; git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), ) }; assert_eq!( push_status_rejected_references(attempt_push_expecting_parent(None)?), ["refs/heads/main"].map(GitRefNameBuf::from) ); assert_eq!( push_status_rejected_references(attempt_push_expecting_parent(Some( &setup.sideways_commit ))?), ["refs/heads/main"].map(GitRefNameBuf::from) ); // Here, the local bookmark hasn't moved from `parent_of_main_commit`, but it // moved to `main` on the remote. So, the conflict resolves to `main`. // // `jj` should not actually attempt a push in this case, but if it did, the push // should fail. assert_eq!( push_status_rejected_references(attempt_push_expecting_parent(Some( &setup.parent_of_main_commit ))?), ["refs/heads/main"].map(GitRefNameBuf::from) ); // git is strict about honoring the expected location on --force-with-lease assert_eq!( push_status_rejected_references(attempt_push_expecting_parent(Some( &setup.child_of_main_commit ))?), ["refs/heads/main"].map(GitRefNameBuf::from) ); Ok(()) } #[test] fn test_push_updates_unexpectedly_exists_on_remote() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); // The main bookmark is actually at `main_commit` on the remote. In this test, // we expect it to not exist on the remote at all. // // We cannot move the bookmark backwards or sideways, but we *can* move it // forward (as a special case). // // For each test, we check that the push succeeds if and only if the bookmark // conflict `jj git fetch` would generate resolves to the push destination. let attempt_push_expecting_absence = |target: Option<&Commit>| { let subprocess_options = GitSubprocessOptions::from_settings(&settings).unwrap(); let targets = [GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(None, target).map(|commit| commit.map(git_id)), }]; git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), ) }; assert_eq!( push_status_rejected_references(attempt_push_expecting_absence(Some( &setup.parent_of_main_commit ))?), ["refs/heads/main"].map(GitRefNameBuf::from) ); // Git is strict with enforcing the expected location assert_eq!( push_status_rejected_references(attempt_push_expecting_absence(Some( &setup.child_of_main_commit ))?), ["refs/heads/main"].map(GitRefNameBuf::from) ); Ok(()) } #[test] fn test_push_updates_success() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let clone_repo = get_git_repo(&setup.jj_repo); let stats = git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "origin".as_ref(), &[GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(&setup.main_commit, &setup.child_of_main_commit) .map(|commit| Some(git_id(commit))), }], &mut NullCallback, &GitPushOptions::default(), )?; insta::assert_debug_snapshot!(stats, @r#" GitPushStats { pushed: [ GitRefNameBuf( "refs/heads/main", ), ], rejected: [], remote_rejected: [], unexported_bookmarks: [], } "#); // Check that the ref got updated in the source repo let source_repo = testutils::git::open(&setup.source_repo_dir); let new_target = source_repo.find_reference("refs/heads/main")?; let new_oid = git_id(&setup.child_of_main_commit); assert_eq!(new_target.target().id(), new_oid); // Check that the ref got updated in the cloned repo. This just tests our // assumptions about libgit2 because we want the refs/remotes/origin/main // bookmark to be updated. let new_target = clone_repo.find_reference("refs/remotes/origin/main")?; assert_eq!(new_target.target().id(), new_oid); Ok(()) } #[test] fn test_push_updates_no_such_remote() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let result = git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "invalid-remote".as_ref(), &[GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(&setup.main_commit, &setup.child_of_main_commit) .map(|commit| Some(git_id(commit))), }], &mut NullCallback, &GitPushOptions::default(), ); assert!(matches!(result, Err(GitPushError::NoSuchRemote(_)))); Ok(()) } #[test] fn test_push_updates_invalid_remote() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let result = git::push_updates( setup.jj_repo.as_ref(), subprocess_options, "http://invalid-remote".as_ref(), &[GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(&setup.main_commit, &setup.child_of_main_commit) .map(|commit| Some(git_id(commit))), }], &mut NullCallback, &GitPushOptions::default(), ); assert!(matches!(result, Err(GitPushError::NoSuchRemote(_)))); Ok(()) } #[test] fn test_push_environment_options() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let mut tx = setup.jj_repo.start_transaction(); let mut subprocess_options = GitSubprocessOptions::from_settings(&settings)?; let trace_path = temp_dir.path().join("git-trace.log"); subprocess_options .environment .insert("GIT_TRACE".into(), trace_path.clone().into()); let targets = GitPushRefTargets { bookmarks: vec![( "main".into(), Diff::new( Some(setup.main_commit.id().clone()), Some(setup.child_of_main_commit.id().clone()), ), )], tags: vec![], }; git::push_refs( tx.repo_mut(), subprocess_options, "origin".as_ref(), &targets, &mut NullCallback, &GitPushOptions::default(), )?; assert!(trace_path.exists()); Ok(()) } #[test] fn test_bulk_update_extra_on_import_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); let count_extra_tables = || { let extra_dir = test_repo.repo_path().join("store").join("extra"); extra_dir .read_dir() .unwrap() .filter(|entry| entry.as_ref().unwrap().metadata().unwrap().is_file()) .count() }; let import_refs = |repo: &Arc| { let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options) .block_on() .unwrap(); tx.repo_mut().rebase_descendants().block_on().unwrap(); tx.commit("test").block_on().unwrap() }; // Extra metadata table shouldn't be created per read_commit() call. The number // of the table files should be way smaller than the number of the heads. let mut commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); for _ in 1..10 { commit = empty_git_commit(&git_repo, "refs/heads/main", &[commit]); } let repo = import_refs(repo); assert_eq!(count_extra_tables(), 2); // empty + imported_heads == 2 // Noop import shouldn't create a table file. let repo = import_refs(&repo); assert_eq!(count_extra_tables(), 2); // Importing new head should add exactly one table file. for _ in 0..10 { commit = empty_git_commit(&git_repo, "refs/heads/main", &[commit]); } let repo = import_refs(&repo); assert_eq!(count_extra_tables(), 3); drop(repo); // silence clippy Ok(()) } #[test] fn test_rewrite_imported_commit() -> TestResult { let test_repo = TestRepo::init_with_backend_and_settings( TestRepoBackend::Git, &user_settings_without_change_id(), ); let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // Import git commit, which generates change id from the commit id. let git_commit = empty_git_commit(&git_repo, "refs/heads/main", &[]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; tx.repo_mut().rebase_descendants().block_on()?; let repo = tx.commit("test").block_on()?; let imported_commit = repo.store().get_commit(&jj_id(git_commit))?; // Try to create identical commit with different change id. let mut tx = repo.start_transaction(); let authored_commit = tx .repo_mut() .new_commit( imported_commit.parent_ids().to_vec(), imported_commit.tree(), ) .set_author(imported_commit.author().clone()) .set_committer(imported_commit.committer().clone()) .set_description(imported_commit.description()) .write_unwrap(); let repo = tx.commit("test").block_on()?; // Imported commit shouldn't be reused, and the timestamp of the authored // commit should be adjusted to create new commit. assert_ne!(imported_commit.id(), authored_commit.id()); assert_ne!( imported_commit.committer().timestamp, authored_commit.committer().timestamp, ); // The index should be consistent with the store. assert_eq!( repo.resolve_change_id(imported_commit.change_id())? .and_then(ResolvedChangeTargets::into_visible), Some(vec![imported_commit.id().clone()]), ); assert_eq!( repo.resolve_change_id(authored_commit.change_id())? .and_then(ResolvedChangeTargets::into_visible), Some(vec![authored_commit.id().clone()]), ); Ok(()) } #[test] fn test_concurrent_write_commit() -> TestResult { let settings = &testutils::user_settings(); let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let test_env = &test_repo.env; let repo = &test_repo.repo; // Try to create identical commits with different change ids. Timestamp of the // commits should be adjusted such that each commit has a unique commit id. let num_thread = 8; let (sender, receiver) = mpsc::channel(); thread::scope(|s| { let barrier = Arc::new(Barrier::new(num_thread)); for i in 0..num_thread { let repo = test_env.load_repo_at_head(settings, test_repo.repo_path()); // unshare loader let barrier = barrier.clone(); let sender = sender.clone(); s.spawn(move || { barrier.wait(); let mut tx = repo.start_transaction(); let commit = create_rooted_commit(tx.repo_mut()) .set_description("racy commit") .write_unwrap(); tx.commit(format!("writer {i}")).block_on().unwrap(); sender .send((commit.id().clone(), commit.change_id().clone())) .unwrap(); }); } }); drop(sender); let mut commit_change_ids: BTreeMap> = BTreeMap::new(); for (commit_id, change_id) in receiver { commit_change_ids .entry(commit_id) .or_default() .insert(change_id); } // Ideally, each commit should have unique commit/change ids. assert_eq!(commit_change_ids.len(), num_thread); // All unique commits should be preserved. let repo = repo.reload_at_head().block_on()?; for (commit_id, change_ids) in &commit_change_ids { let commit = repo.store().get_commit(commit_id)?; assert_eq!(commit.id(), commit_id); assert!(change_ids.contains(commit.change_id())); } // The index should be consistent with the store. for commit_id in commit_change_ids.keys() { assert!(repo.index().has_id(commit_id)?); let commit = repo.store().get_commit(commit_id)?; assert_eq!( repo.resolve_change_id(commit.change_id())? .and_then(ResolvedChangeTargets::into_visible), Some(vec![commit_id.clone()]), ); } Ok(()) } #[test] // TODO: Fix flaky test on Windows #[cfg_attr(windows, ignore)] fn test_concurrent_read_write_commit() -> TestResult { let settings = user_settings_without_change_id(); let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let test_env = &test_repo.env; let repo = &test_repo.repo; // Create unique commits and load them concurrently. In this test, we assume // that writer doesn't fall back to timestamp adjustment, so the expected // commit ids are static. If reader could interrupt in the timestamp // adjustment loop, this assumption wouldn't apply. let commit_ids = [ "c5c6efd6ac240102e7f047234c3cade55eedd621", "9f7a96a6c9d044b228f3321a365bdd3514e6033a", "aa7867ad0c566df5bbb708d8d6ddc88eefeea0ff", "930a76e333d5cc17f40a649c3470cb99aae24a0c", "88e9a719df4f0cc3daa740b814e271341f6ea9f4", "4883bdc57448a53b4eef1af85e34b85b9ee31aee", "308345f8d058848e83beed166704faac2ecd4541", "9e35ff61ea8d1d4ef7f01edc5fd23873cc301b30", "8010ac8c65548dd619e7c83551d983d724dda216", "bbe593d556ea31acf778465227f340af7e627b2b", "2f6800f4b8e8fc4c42dc0e417896463d13673654", "a3a7e4fcddeaa11bb84f66f3428f107f65eb3268", "96e17ff3a7ee1b67ddfa5619b2bf5380b80f619a", "34613f7609524c54cc990ada1bdef3dcad0fd29f", "95867e5aed6b62abc2cd6258da9fee8873accfd3", "7635ce107ae7ba71821b8cd74a1405ca6d9e49ac", ] .into_iter() .map(CommitId::from_hex) .collect_vec(); let num_reader_thread = 8; thread::scope(|s| { let barrier = Arc::new(Barrier::new(commit_ids.len() + num_reader_thread)); // Writer assigns random change id for (i, commit_id) in commit_ids.iter().enumerate() { let repo = test_env.load_repo_at_head(&settings, test_repo.repo_path()); // unshare loader let barrier = barrier.clone(); s.spawn(move || { barrier.wait(); let mut tx = repo.start_transaction(); let commit = create_rooted_commit(tx.repo_mut()) .set_description(format!("commit {i}")) .write_unwrap(); tx.commit(format!("writer {i}")).block_on().unwrap(); assert_eq!(commit.id(), commit_id); }); } // Reader may generate change id (if not yet assigned by the writer) for i in 0..num_reader_thread { let mut repo = test_env.load_repo_at_head(&settings, test_repo.repo_path()); // unshare loader let barrier = barrier.clone(); let mut pending_commit_ids = commit_ids.clone(); pending_commit_ids.rotate_left(i); // start lookup from different place s.spawn(move || { barrier.wait(); // This loop should finish within a couple of retries, but terminate in case // it doesn't. for _ in 0..100 { if pending_commit_ids.is_empty() { break; } repo = repo.reload_at_head().block_on().unwrap(); let git_backend = get_git_backend(&repo); let mut tx = repo.start_transaction(); pending_commit_ids = pending_commit_ids .into_iter() .filter_map(|commit_id| { match git_backend.import_head_commits([&commit_id]) { Ok(()) => { // update index as git::import_refs() would do let commit = repo.store().get_commit(&commit_id).unwrap(); tx.repo_mut().add_head(&commit).block_on().unwrap(); None } Err(BackendError::ObjectNotFound { .. }) => Some(commit_id), Err(err) => { eprintln!( "import error in reader {i} (maybe lock contention?): {}", iter::successors( Some(&err as &dyn std::error::Error), |e| e.source(), ) .join(": ") ); Some(commit_id) } } }) .collect_vec(); if tx.repo().has_changes() { tx.commit(format!("reader {i}")).block_on().unwrap(); } thread::yield_now(); } if !pending_commit_ids.is_empty() { // It's not an error if some of the readers couldn't observe the commits. It's // unlikely, but possible if the git backend had strong negative object cache // for example. eprintln!( "reader {i} couldn't observe the following commits: \ {pending_commit_ids:#?}" ); } }); } }); // The index should be consistent with the store. let repo = repo.reload_at_head().block_on()?; for commit_id in &commit_ids { assert!(repo.index().has_id(commit_id)?); let commit = repo.store().get_commit(commit_id)?; assert_eq!( repo.resolve_change_id(commit.change_id())? .and_then(ResolvedChangeTargets::into_visible), Some(vec![commit_id.clone()]), ); } Ok(()) } fn create_rooted_commit(mut_repo: &mut MutableRepo) -> CommitBuilder<'_> { let signature = Signature { name: "Test User".to_owned(), email: "test.user@example.com".to_owned(), timestamp: Timestamp { // avoid underflow during timestamp adjustment timestamp: MillisSinceEpoch(1_000_000), tz_offset: 0, }, }; mut_repo .new_commit( vec![mut_repo.store().root_commit_id().clone()], mut_repo.store().empty_merged_tree(), ) .set_author(signature.clone()) .set_committer(signature) } #[test] fn test_shallow_commits_lack_parents() -> TestResult { let settings = testutils::user_settings(); let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let test_env = &test_repo.env; let repo = &test_repo.repo; let git_repo = get_git_repo(repo); let import_options = default_import_options(); // D E (`main`) // | | // B C // shallow boundary // | / // A // | // git_root let git_root = empty_git_commit(&git_repo, "refs/heads/main", &[]); let a = empty_git_commit(&git_repo, "refs/heads/main", &[git_root]); let b = empty_git_commit(&git_repo, "refs/heads/feature", &[a]); let c = empty_git_commit(&git_repo, "refs/heads/main", &[a]); let d = empty_git_commit(&git_repo, "refs/heads/feature", &[b]); let e = empty_git_commit(&git_repo, "refs/heads/main", &[c]); testutils::git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/main"); let make_shallow = |repo, mut shallow_commits: Vec<_>| { let shallow_file = get_git_backend(repo).git_repo().shallow_file(); shallow_commits.sort(); let mut buf = Vec::::new(); for commit in shallow_commits { writeln!(buf, "{commit}").unwrap(); } fs::write(shallow_file, buf).unwrap(); // Reload the repo to invalidate mtime-based in-memory cache test_env.load_repo_at_head(&settings, test_repo.repo_path()) }; let repo = make_shallow(repo, vec![b, c]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("import").block_on()?; let store = repo.store(); let root = store.root_commit_id(); let expected_heads = hashset! { jj_id(d), jj_id(e), }; assert_eq!(*repo.view().heads(), expected_heads); let parents = |store: &Arc, commit| { let commit = store.get_commit(&jj_id(commit)).unwrap(); commit.parent_ids().to_vec() }; assert_eq!( parents(store, b), vec![root.clone()], "shallow commits have the root commit as a parent" ); assert_eq!( parents(store, c), vec![root.clone()], "shallow commits have the root commit as a parent" ); // deepen the shallow clone let repo = make_shallow(&repo, vec![a]); let mut tx = repo.start_transaction(); git::import_refs(tx.repo_mut(), &import_options).block_on()?; let repo = tx.commit("import").block_on()?; let store = repo.store(); let root = store.root_commit_id(); assert_eq!( parents(store, a), vec![root.clone()], "shallow commits have the root commit as a parent" ); assert_eq!( parents(store, b), vec![jj_id(a)], "unshallowed commits have parents" ); assert_eq!( parents(store, c), vec![jj_id(a)], "unshallowed commits have correct parents" ); // FIXME: new ancestors should be indexed assert!(!repo.index().has_id(&jj_id(a))?); Ok(()) } #[test] fn test_remote_remove_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let mut tx = test_repo.repo.start_transaction(); git::add_remote(tx.repo_mut(), "foo".as_ref(), "https://example.com/", None)?; let _repo = tx.commit("test").block_on()?; // Reload after Git configuration change. let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); let git_repo = get_git_repo(repo); empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]); empty_git_commit(&git_repo, "refs/remotes/foo/x/y", &[]); let commit_foobar_a = empty_git_commit(&git_repo, "refs/remotes/foobar/a", &[]); empty_git_commit(&git_repo, "refs/jj/remote-tags/foo/x/y", &[]); let commit_tag_foobar_a = empty_git_commit(&git_repo, "refs/jj/remote-tags/foobar/a", &[]); let mut tx = repo.start_transaction(); git::remove_remote(tx.repo_mut(), "foo".as_ref())?; let repo = &tx.commit("remove").block_on()?; let git_repo = get_git_repo(repo); // remote bookmarks assert!(git_repo.try_find_reference("refs/remotes/foo/a")?.is_none()); assert!( git_repo .try_find_reference("refs/remotes/foo/x/y")? .is_none() ); assert_eq!( git_repo.find_reference("refs/remotes/foobar/a")?.id(), commit_foobar_a, ); // remote tags assert!( git_repo .try_find_reference("refs/jj/remote-tags/foo/x/y")? .is_none() ); assert_eq!( git_repo .find_reference("refs/jj/remote-tags/foobar/a")? .id(), commit_tag_foobar_a, ); Ok(()) } #[test] fn test_remote_rename_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let mut tx = test_repo.repo.start_transaction(); git::add_remote(tx.repo_mut(), "foo".as_ref(), "https://example.com/", None)?; let _repo = tx.commit("test").block_on()?; // Reload after Git configuration change. let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); let git_repo = get_git_repo(repo); let commit_foo_a = empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]); let commit_foo_x_y = empty_git_commit(&git_repo, "refs/remotes/foo/x/y", &[]); let commit_foobar_a = empty_git_commit(&git_repo, "refs/remotes/foobar/a", &[]); let commit_tag_foo_x_y = empty_git_commit(&git_repo, "refs/jj/remote-tags/foo/x/y", &[]); let commit_tag_foobar_a = empty_git_commit(&git_repo, "refs/jj/remote-tags/foobar/a", &[]); // Add a branch config section with `rebase = true` referencing the remote. // This is standard git config and should not prevent renaming. { let config_path = git_repo.path().join("config"); let mut config = std::fs::read_to_string(&config_path).unwrap(); config.push_str( r#" [branch "main"] remote = foo merge = refs/heads/main rebase = true "#, ); std::fs::write(&config_path, config).unwrap(); } // Reload to pick up the config change. let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); let mut tx = repo.start_transaction(); git::rename_remote(tx.repo_mut(), "foo".as_ref(), "bar".as_ref())?; let repo = &tx.commit("rename").block_on()?; let git_repo = get_git_repo(repo); // remote bookmarks assert!(git_repo.try_find_reference("refs/remotes/foo/a")?.is_none()); assert!( git_repo .try_find_reference("refs/remotes/foo/x/y")? .is_none() ); assert_eq!( git_repo.find_reference("refs/remotes/bar/a")?.id(), commit_foo_a, ); assert_eq!( git_repo.find_reference("refs/remotes/bar/x/y")?.id(), commit_foo_x_y, ); assert_eq!( git_repo.find_reference("refs/remotes/foobar/a")?.id(), commit_foobar_a, ); // remote tags assert!( git_repo .try_find_reference("refs/jj/remote-tags/foo/x/y")? .is_none() ); assert_eq!( git_repo.find_reference("refs/jj/remote-tags/bar/x/y")?.id(), commit_tag_foo_x_y, ); assert_eq!( git_repo .find_reference("refs/jj/remote-tags/foobar/a")? .id(), commit_tag_foobar_a, ); Ok(()) } fn user_settings_without_change_id() -> UserSettings { let mut config = base_user_config(); let mut layer = ConfigLayer::empty(ConfigSource::Default); layer .set_value("git.write-change-id-header", false) .unwrap(); config.add_layer(layer); UserSettings::from_config(config).unwrap() } #[test] fn test_push_updates_with_options() -> TestResult { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let git_settings = GitSettings::from_settings(&settings)?; std::process::Command::new("git") .arg("--git-dir") .arg(&setup.source_repo_dir) .args(["config", "receive.advertisePushOptions", "true"]) .output()?; // Set up pre-receive hook to echo back received options let hooks_dir = setup.source_repo_dir.join("hooks"); fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join("pre-receive"); let hook_content = r#"#!/bin/sh if [ -n "$GIT_PUSH_OPTION_COUNT" ] && [ "$GIT_PUSH_OPTION_COUNT" -gt 0 ]; then i=0 while [ $i -lt "$GIT_PUSH_OPTION_COUNT" ]; do eval "option_value=\$GIT_PUSH_OPTION_$i" echo "Push-Option: $option_value" i=$((i + 1)) done fi "#; fs::write(&hook_path, hook_content)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o700))?; } let remote_output = Arc::new(std::sync::Mutex::new(Vec::new())); struct CapturingCallback { output: Arc>>, } impl GitSubprocessCallback for CapturingCallback { fn needs_progress(&self) -> bool { false } fn progress(&mut self, _progress: &git::GitProgress) -> std::io::Result<()> { Ok(()) } fn local_sideband( &mut self, _message: &[u8], _term: Option, ) -> std::io::Result<()> { Ok(()) } fn remote_sideband( &mut self, message: &[u8], _term: Option, ) -> std::io::Result<()> { if let Ok(mut output) = self.output.lock() { output.extend_from_slice(message); } Ok(()) } } let mut callback = CapturingCallback { output: remote_output.clone(), }; let result = git::push_updates( setup.jj_repo.as_ref(), git_settings.to_subprocess_options(), "origin".as_ref(), &[GitRefUpdate { qualified_name: "refs/heads/main".into(), targets: Diff::new(&setup.main_commit, &setup.child_of_main_commit) .map(|commit| Some(git_id(commit))), }], &mut callback, &GitPushOptions { remote_push_options: vec![ "merge_request.create".to_owned(), "merge_request.draft".to_owned(), ], }, )?; let stats = result; assert_eq!( stats.pushed, vec![jj_lib::ref_name::GitRefNameBuf::from("refs/heads/main")] ); assert!(stats.rejected.is_empty()); assert!(stats.remote_rejected.is_empty()); assert!(stats.unexported_bookmarks.is_empty()); let captured_bytes = remote_output.lock().unwrap(); let captured_string = String::from_utf8_lossy(&captured_bytes); assert!(captured_string.contains("Push-Option: merge_request.create")); assert!(captured_string.contains("Push-Option: merge_request.draft")); Ok(()) } fn auto_track_import_options() -> GitImportOptions { let remotes_used_in_tests = ["origin", "upstream"]; let auto_track_bookmarks = remotes_used_in_tests .into_iter() .map(|name| (name.into(), StringMatcher::all())) .collect(); GitImportOptions { // don't use `auto_local_bookmark: bool` which is deprecated remote_auto_track_bookmarks: auto_track_bookmarks, ..default_import_options() } } fn default_import_options() -> GitImportOptions { GitImportOptions { abandon_unreachable_commits: true, record_synthetic_predecessors: true, remote_auto_track_bookmarks: HashMap::new(), } } #[track_caller] fn assert_fetch_and_push_urls( repo: &Arc, remote_name: &str, expected_fetch_url: Option<&str>, expected_push_url: Option<&str>, ) { let git_repo = get_git_repo(repo); let remote = git_repo .find_remote(remote_name) .expect("unable to find remote"); let actual_fetch_url = remote.url(Direction::Fetch); let actual_push_url = remote.url(Direction::Push); let expected_fetch_url = expected_fetch_url .map(|u| gix::Url::try_from(u).expect("failed to parse the expected fetch url")); let expected_push_url = expected_push_url .map(|u| gix::Url::try_from(u).expect("failed to parse the expected push url")); assert_eq!(actual_fetch_url, expected_fetch_url.as_ref()); assert_eq!(actual_push_url, expected_push_url.as_ref()); } #[test] fn test_set_remote_urls() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let repo = &test_repo.repo; let mut tx = repo.start_transaction(); let remote_name = "foo"; git::add_remote( tx.repo_mut(), remote_name.as_ref(), "https://example.com/repo/path", None, )?; // test initial state after adding the remote let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); assert_fetch_and_push_urls( repo, remote_name, Some("https://example.com/repo/path"), Some("https://example.com/repo/path"), ); // test setting just the push url git::set_remote_urls( repo.store(), remote_name.as_ref(), None, Some("git@example.com:repo/path"), )?; let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); assert_fetch_and_push_urls( repo, remote_name, Some("https://example.com/repo/path"), Some("git@example.com:repo/path"), ); // test setting just the fetch url git::set_remote_urls( repo.store(), remote_name.as_ref(), Some("https://example.com/repo/path2"), None, )?; let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); assert_fetch_and_push_urls( repo, remote_name, Some("https://example.com/repo/path2"), Some("git@example.com:repo/path"), ); // test setting both the fetch and push urls git::set_remote_urls( repo.store(), remote_name.as_ref(), Some("https://example.com/repo/path3"), Some("git@example.com:repo/path3"), )?; let repo = &test_repo .env .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); assert_fetch_and_push_urls( repo, remote_name, Some("https://example.com/repo/path3"), Some("git@example.com:repo/path3"), ); Ok(()) } #[test] fn test_remote_name_validation() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); let try_add_remote = |name: &str| { let mut tx = test_repo.repo.start_transaction(); git::add_remote(tx.repo_mut(), name.as_ref(), "https://example.com/", None) }; // Valid remote name try_add_remote("origin")?; // Empty remote name assert_matches!( try_add_remote(""), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::InvalidName(_) )) ); // Whitespace in remote name assert_matches!( try_add_remote("my remote"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::InvalidName(_) )) ); // Tab in remote name assert_matches!( try_add_remote("my\tremote"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::InvalidName(_) )) ); // Newline in remote name assert_matches!( try_add_remote("my\nremote"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::InvalidName(_) )) ); // Leading whitespace assert_matches!( try_add_remote(" origin"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::InvalidName(_) )) ); // Slash in remote name (jj-specific restriction) assert_matches!( try_add_remote("foo/bar"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::WithSlash(_) )) ); // Reserved name for local git repo assert_matches!( try_add_remote("git"), Err(git::GitRemoteManagementError::RemoteName( git::GitRemoteNameError::ReservedForLocalGitRepo )) ); Ok(()) }