// 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. #![allow(missing_docs)] use std::borrow::Cow;use std::collections::{BTreeMap, HashMap, HashSet};use std::default::Default;use std::io::Read;use std::path::PathBuf;use std::{fmt, iter, str}; use git2::Oid;use itertools::Itertools;use tempfile::NamedTempFile;use thiserror::Error; use crate::backend::{BackendError, CommitId};use crate::commit::Commit;use crate::git_backend::GitBackend;use crate::object_id::ObjectId;use crate::op_store::{RefTarget, RefTargetOptionExt, RemoteRef, RemoteRefState};use crate::refs::BranchPushUpdate;use crate::repo::{MutableRepo, Repo};use crate::revset::RevsetExpression;use crate::settings::GitSettings;use crate::store::Store;use crate::str_util::StringPattern;use crate::view::View; /// Reserved remote name for the backing Git repo.pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";/// Ref name used as a placeholder to unset HEAD without a commit.const UNBORN_ROOT_REF_NAME: &str = "refs/jj/root"; #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash, Debug)]pub enum RefName { LocalBranch(String), RemoteBranch { branch: String, remote: String }, Tag(String),} impl fmt::Display for RefName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { RefName::LocalBranch(name) => write!(f, "{name}"), RefName::RemoteBranch { branch, remote } => write!(f, "{branch}@{remote}"), RefName::Tag(name) => write!(f, "{name}"), } }} fn parse_git_ref(ref_name: &str) -> Option<RefName> { if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { // Git CLI says 'HEAD' is not a valid branch name (branch_name != "HEAD").then(|| RefName::LocalBranch(branch_name.to_string())) } else if let Some(remote_and_branch) = ref_name.strip_prefix("refs/remotes/") { remote_and_branch .split_once('/') // "refs/remotes/origin/HEAD" isn't a real remote-tracking branch .filter(|&(_, branch)| branch != "HEAD") .map(|(remote, branch)| RefName::RemoteBranch { remote: remote.to_string(), branch: branch.to_string(), }) } else { ref_name .strip_prefix("refs/tags/") .map(|tag_name| RefName::Tag(tag_name.to_string())) }} fn to_git_ref_name(parsed_ref: &RefName) -> Option<String> { match parsed_ref { RefName::LocalBranch(branch) => { (!branch.is_empty() && branch != "HEAD").then(|| format!("refs/heads/{branch}")) } RefName::RemoteBranch { branch, remote } => (!branch.is_empty() && branch != "HEAD") .then(|| format!("refs/remotes/{remote}/{branch}")), RefName::Tag(tag) => Some(format!("refs/tags/{tag}")), }} fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'a str> { match parsed_ref { RefName::RemoteBranch { branch, remote } => (remote == remote_name).then_some(branch), RefName::LocalBranch(..) | RefName::Tag(..) => None, }} /// Returns true if the `parsed_ref` won't be imported because its remote name/// is reserved.////// Use this as a negative `git_ref_filter` to be passed in to/// `import_some_refs()`.pub fn is_reserved_git_remote_ref(parsed_ref: &RefName) -> bool { to_remote_branch(parsed_ref, REMOTE_NAME_FOR_LOCAL_GIT_REPO).is_some()} fn get_git_backend(store: &Store) -> Option<&GitBackend> { store.backend_impl().downcast_ref()} fn get_git_repo(store: &Store) -> Option<gix::Repository> { get_git_backend(store).map(|backend| backend.git_repo())} /// Checks if `git_ref` points to a Git commit object, and returns its id.////// If the ref points to the previously `known_target` (i.e. unchanged), this/// should be faster than `git_ref.into_fully_peeled_id()`.fn resolve_git_ref_to_commit_id( git_ref: &gix::Reference, known_target: &RefTarget,) -> Option<CommitId> { let mut peeling_ref = Cow::Borrowed(git_ref); // Try fast path if we have a candidate id which is known to be a commit object. if let Some(id) = known_target.as_normal() { let raw_ref = &git_ref.inner; if matches!(raw_ref.target.try_id(), Some(oid) if oid.as_bytes() == id.as_bytes()) { return Some(id.clone()); } if matches!(raw_ref.peeled, Some(oid) if oid.as_bytes() == id.as_bytes()) { // Perhaps an annotated tag stored in packed-refs file, and pointing to the // already known target commit. return Some(id.clone()); } // A tag (according to ref name.) Try to peel one more level. This is slightly // faster than recurse into into_fully_peeled_id(). If we recorded a tag oid, we // could skip this at all. if raw_ref.peeled.is_none() && git_ref.name().as_bstr().starts_with(b"refs/tags/") { let maybe_tag = git_ref .try_id() .and_then(|id| id.object().ok()) .and_then(|object| object.try_into_tag().ok()); if let Some(oid) = maybe_tag.as_ref().and_then(|tag| tag.target_id().ok()) { if oid.as_bytes() == id.as_bytes() { // An annotated tag pointing to the already known target commit. return Some(id.clone()); } // Unknown id. Recurse from the current state. A tag may point to // non-commit object. peeling_ref.to_mut().inner.target = gix::refs::Target::Peeled(oid.detach()); } } } // Alternatively, we might want to inline the first half of the peeling // loop. into_fully_peeled_id() looks up the target object to see if it's // a tag or not, and we need to check if it's a commit object. let peeled_id = peeling_ref.into_owned().into_fully_peeled_id().ok()?; let is_commit = peeled_id .object() .map_or(false, |object| object.kind.is_commit()); is_commit.then(|| CommitId::from_bytes(peeled_id.as_bytes()))} #[derive(Error, Debug)]pub enum GitImportError { #[error("Failed to read Git HEAD target commit {id}", id=id.hex())] MissingHeadTarget { id: CommitId, #[source] err: BackendError, }, #[error("Ancestor of Git ref {ref_name} is missing")] MissingRefAncestor { ref_name: String, #[source] err: BackendError, }, #[error( "Git remote named '{name}' is reserved for local Git repository", name = REMOTE_NAME_FOR_LOCAL_GIT_REPO )] RemoteReservedForLocalGitRepo, #[error("Unexpected backend error when importing refs")] InternalBackend(#[source] BackendError), #[error("Unexpected git error when importing refs")] InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>), #[error("The repo is not backed by a Git repo")] UnexpectedBackend,} impl GitImportError { fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self { GitImportError::InternalGitError(source.into()) }} /// Describes changes made by `import_refs()` or `fetch()`.#[derive(Clone, Debug, Eq, PartialEq, Default)]pub struct GitImportStats { /// Commits superseded by newly imported commits. pub abandoned_commits: Vec<CommitId>, /// Remote `(ref_name, (old_remote_ref, new_target))`s to be merged in to /// the local refs. pub changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>,} #[derive(Debug)]struct RefsToImport { /// Git ref `(full_name, new_target)`s to be copied to the view. changed_git_refs: Vec<(String, RefTarget)>, /// Remote `(ref_name, (old_remote_ref, new_target))`s to be merged in to /// the local refs. changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>,} /// Reflect changes made in the underlying Git repo in the Jujutsu repo.////// This function detects conflicts (if both Git and JJ modified a branch) and/// records them in JJ's view.pub fn import_refs( mut_repo: &mut MutableRepo, git_settings: &GitSettings,) -> Result<GitImportStats, GitImportError> { import_some_refs(mut_repo, git_settings, |_| true)} /// Reflect changes made in the underlying Git repo in the Jujutsu repo.////// Only branches whose git full reference name pass the filter will be/// considered for addition, update, or deletion.pub fn import_some_refs( mut_repo: &mut MutableRepo, git_settings: &GitSettings, git_ref_filter: impl Fn(&RefName) -> bool,) -> Result<GitImportStats, GitImportError> { let store = mut_repo.store(); let git_backend = get_git_backend(store).ok_or(GitImportError::UnexpectedBackend)?; let git_repo = git_backend.git_repo(); let RefsToImport { changed_git_refs, changed_remote_refs, } = diff_refs_to_import(mut_repo.view(), &git_repo, git_ref_filter)?; // Bulk-import all reachable Git commits to the backend to reduce overhead // of table merging and ref updates. // // changed_remote_refs might contain new_targets that are not in // changed_git_refs, but such targets should have already been imported to // the backend. let index = mut_repo.index(); let missing_head_ids = changed_git_refs .iter() .flat_map(|(_, new_target)| new_target.added_ids()) .filter(|&id| !index.has_id(id)); let heads_imported = git_backend.import_head_commits(missing_head_ids).is_ok(); // Import new remote heads let mut head_commits = Vec::new(); let get_commit = |id| { // If bulk-import failed, try again to find bad head or ref. if !heads_imported && !index.has_id(id) { git_backend.import_head_commits([id])?; } store.get_commit(id) }; for (ref_name, (_, new_target)) in &changed_remote_refs { for id in new_target.added_ids() { let commit = get_commit(id).map_err(|err| GitImportError::MissingRefAncestor { ref_name: ref_name.to_string(), err, })?; head_commits.push(commit); } } // It's unlikely the imported commits were missing, but I/O-related error // can still occur. mut_repo .add_heads(&head_commits) .map_err(GitImportError::InternalBackend)?; // Apply the change that happened in git since last time we imported refs. for (full_name, new_target) in changed_git_refs { mut_repo.set_git_ref_target(&full_name, new_target); } for (ref_name, (old_remote_ref, new_target)) in &changed_remote_refs { let base_target = old_remote_ref.tracking_target(); let new_remote_ref = RemoteRef { target: new_target.clone(), state: if old_remote_ref.is_present() { old_remote_ref.state } else { default_remote_ref_state_for(ref_name, git_settings) }, }; match ref_name { RefName::LocalBranch(branch) => { if new_remote_ref.is_tracking() { mut_repo.merge_local_branch(branch, base_target, &new_remote_ref.target); } // Update Git-tracking branch like the other remote branches. mut_repo.set_remote_branch(branch, REMOTE_NAME_FOR_LOCAL_GIT_REPO, new_remote_ref); } RefName::RemoteBranch { branch, remote } => { if new_remote_ref.is_tracking() { mut_repo.merge_local_branch(branch, base_target, &new_remote_ref.target); } // Remote-tracking branch is the last known state of the branch in the remote. // It shouldn't diverge even if we had inconsistent view. mut_repo.set_remote_branch(branch, remote, new_remote_ref); } RefName::Tag(name) => { if new_remote_ref.is_tracking() { mut_repo.merge_tag(name, base_target, &new_remote_ref.target); } // TODO: If we add Git-tracking tag, it will be updated here. } } } let abandoned_commits = if git_settings.abandon_unreachable_commits { abandon_unreachable_commits(mut_repo, &changed_remote_refs) } else { vec![] }; let stats = GitImportStats { abandoned_commits, changed_remote_refs, }; Ok(stats)} /// Finds commits that used to be reachable in git that no longer are reachable./// Those commits will be recorded as abandoned in the `MutableRepo`.fn abandon_unreachable_commits( mut_repo: &mut MutableRepo, changed_remote_refs: &BTreeMap<RefName, (RemoteRef, RefTarget)>,) -> Vec<CommitId> { let hidable_git_heads = changed_remote_refs .values() .flat_map(|(old_remote_ref, _)| old_remote_ref.target.added_ids()) .cloned() .collect_vec(); if hidable_git_heads.is_empty() { return vec![]; } let pinned_heads = itertools::chain!( changed_remote_refs .values() .flat_map(|(_, new_target)| new_target.added_ids()), pinned_commit_ids(mut_repo.view()), iter::once(mut_repo.store().root_commit_id()), ) .cloned() .collect_vec(); let abandoned_expression = RevsetExpression::commits(pinned_heads) .range(&RevsetExpression::commits(hidable_git_heads)) .intersection(&RevsetExpression::visible_heads().ancestors()); let abandoned_commits = abandoned_expression .evaluate_programmatic(mut_repo) .unwrap() .iter() .collect_vec(); for abandoned_commit in &abandoned_commits { mut_repo.record_abandoned_commit(abandoned_commit.clone()); } abandoned_commits} /// Calculates diff of git refs to be imported.fn diff_refs_to_import( view: &View, git_repo: &gix::Repository, git_ref_filter: impl Fn(&RefName) -> bool,) -> Result<RefsToImport, GitImportError> { let mut known_git_refs: HashMap<&str, &RefTarget> = view .git_refs() .iter() .filter_map(|(full_name, target)| { // TODO: or clean up invalid ref in case it was stored due to historical bug? let ref_name = parse_git_ref(full_name).expect("stored git ref should be parsable"); git_ref_filter(&ref_name).then_some((full_name.as_ref(), target)) }) .collect(); // TODO: migrate tags to the remote view, and don't destructure &RemoteRef let mut known_remote_refs: HashMap<RefName, (&RefTarget, RemoteRefState)> = itertools::chain( view.all_remote_branches() .map(|((branch, remote), remote_ref)| { // TODO: want to abstract local ref as "git" tracking remote, but // we'll probably need to refactor the git_ref_filter API first. let ref_name = if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO { RefName::LocalBranch(branch.to_owned()) } else { RefName::RemoteBranch { branch: branch.to_owned(), remote: remote.to_owned(), } }; let RemoteRef { target, state } = remote_ref; (ref_name, (target, *state)) }), // TODO: compare to tags stored in the "git" remote view. Since tags should never // be moved locally in jj, we can consider local tags as merge base. view.tags().iter().map(|(name, target)| { let ref_name = RefName::Tag(name.to_owned()); (ref_name, (target, RemoteRefState::Tracking)) }), ) .filter(|(ref_name, _)| git_ref_filter(ref_name)) .collect(); let mut changed_git_refs = Vec::new(); let mut changed_remote_refs = BTreeMap::new(); let git_references = git_repo.references().map_err(GitImportError::from_git)?; let chain_git_refs_iters = || -> Result<_, gix::reference::iter::init::Error> { // Exclude uninteresting directories such as refs/jj/keep. Ok(itertools::chain!( git_references.local_branches()?, git_references.remote_branches()?, git_references.tags()?, )) }; for git_ref in chain_git_refs_iters().map_err(GitImportError::from_git)? { let git_ref = git_ref.map_err(GitImportError::from_git)?; let Ok(full_name) = str::from_utf8(git_ref.name().as_bstr()) else { // Skip non-utf8 refs. continue; }; let Some(ref_name) = parse_git_ref(full_name) else { // Skip other refs (such as notes) and symbolic refs. continue; }; if !git_ref_filter(&ref_name) { continue; } if is_reserved_git_remote_ref(&ref_name) { return Err(GitImportError::RemoteReservedForLocalGitRepo); } let old_git_target = known_git_refs.get(full_name).copied().flatten(); let Some(id) = resolve_git_ref_to_commit_id(&git_ref, old_git_target) else { // Skip (or remove existing) invalid refs. continue; }; let new_target = RefTarget::normal(id); known_git_refs.remove(full_name); if new_target != *old_git_target { changed_git_refs.push((full_name.to_owned(), new_target.clone())); } // TODO: Make it configurable which remotes are publishing and update public // heads here. let (old_remote_target, old_remote_state) = known_remote_refs .remove(&ref_name) .unwrap_or_else(|| (RefTarget::absent_ref(), RemoteRefState::New)); if new_target != *old_remote_target { let old_remote_ref = RemoteRef { target: old_remote_target.clone(), state: old_remote_state, }; changed_remote_refs.insert(ref_name, (old_remote_ref, new_target)); } } for full_name in known_git_refs.into_keys() { changed_git_refs.push((full_name.to_owned(), RefTarget::absent())); } for (ref_name, (old_target, old_state)) in known_remote_refs { let old_remote_ref = RemoteRef { target: old_target.clone(), state: old_state, }; changed_remote_refs.insert(ref_name, (old_remote_ref, RefTarget::absent())); } Ok(RefsToImport { changed_git_refs, changed_remote_refs, })} fn default_remote_ref_state_for(ref_name: &RefName, git_settings: &GitSettings) -> RemoteRefState { match ref_name { // LocalBranch means Git-tracking branch RefName::LocalBranch(_) | RefName::Tag(_) => RemoteRefState::Tracking, RefName::RemoteBranch { .. } => { if git_settings.auto_local_branch { RemoteRefState::Tracking } else { RemoteRefState::New } } }} /// Commits referenced by local branches, tags, or HEAD@git.////// On `import_refs()`, this is similar to collecting commits referenced byLines 1–500 of 1545. Load more
// 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. #![allow(missing_docs)] use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::default::Default; use std::io::Read; use std::path::PathBuf; use std::{fmt, iter, str}; use git2::Oid; use itertools::Itertools; use tempfile::NamedTempFile; use thiserror::Error; use crate::backend::{BackendError, CommitId}; use crate::commit::Commit; use crate::git_backend::GitBackend; use crate::object_id::ObjectId; use crate::op_store::{RefTarget, RefTargetOptionExt, RemoteRef, RemoteRefState}; use crate::refs::BranchPushUpdate; use crate::repo::{MutableRepo, Repo}; use crate::revset::RevsetExpression; use crate::settings::GitSettings; use crate::store::Store; use crate::str_util::StringPattern; use crate::view::View; /// Reserved remote name for the backing Git repo. pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git"; /// Ref name used as a placeholder to unset HEAD without a commit. const UNBORN_ROOT_REF_NAME: &str = "refs/jj/root"; #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Hash, Debug)] pub enum RefName { LocalBranch(String), RemoteBranch { branch: String, remote: String }, Tag(String), } impl fmt::Display for RefName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { RefName::LocalBranch(name) => write!(f, "{name}"), RefName::RemoteBranch { branch, remote } => write!(f, "{branch}@{remote}"), RefName::Tag(name) => write!(f, "{name}"), } } } fn parse_git_ref(ref_name: &str) -> Option<RefName> { if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { // Git CLI says 'HEAD' is not a valid branch name (branch_name != "HEAD").then(|| RefName::LocalBranch(branch_name.to_string())) } else if let Some(remote_and_branch) = ref_name.strip_prefix("refs/remotes/") { remote_and_branch .split_once('/') // "refs/remotes/origin/HEAD" isn't a real remote-tracking branch .filter(|&(_, branch)| branch != "HEAD") .map(|(remote, branch)| RefName::RemoteBranch { remote: remote.to_string(), branch: branch.to_string(), }) } else { ref_name .strip_prefix("refs/tags/") .map(|tag_name| RefName::Tag(tag_name.to_string())) } } fn to_git_ref_name(parsed_ref: &RefName) -> Option<String> { match parsed_ref { RefName::LocalBranch(branch) => { (!branch.is_empty() && branch != "HEAD").then(|| format!("refs/heads/{branch}")) } RefName::RemoteBranch { branch, remote } => (!branch.is_empty() && branch != "HEAD") .then(|| format!("refs/remotes/{remote}/{branch}")), RefName::Tag(tag) => Some(format!("refs/tags/{tag}")), } } fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'a str> { match parsed_ref { RefName::RemoteBranch { branch, remote } => (remote == remote_name).then_some(branch), RefName::LocalBranch(..) | RefName::Tag(..) => None, } } /// Returns true if the `parsed_ref` won't be imported because its remote name /// is reserved. /// /// Use this as a negative `git_ref_filter` to be passed in to /// `import_some_refs()`. pub fn is_reserved_git_remote_ref(parsed_ref: &RefName) -> bool { to_remote_branch(parsed_ref, REMOTE_NAME_FOR_LOCAL_GIT_REPO).is_some() } fn get_git_backend(store: &Store) -> Option<&GitBackend> { store.backend_impl().downcast_ref() } fn get_git_repo(store: &Store) -> Option<gix::Repository> { get_git_backend(store).map(|backend| backend.git_repo()) } /// Checks if `git_ref` points to a Git commit object, and returns its id. /// /// If the ref points to the previously `known_target` (i.e. unchanged), this /// should be faster than `git_ref.into_fully_peeled_id()`. fn resolve_git_ref_to_commit_id( git_ref: &gix::Reference, known_target: &RefTarget, ) -> Option<CommitId> { let mut peeling_ref = Cow::Borrowed(git_ref); // Try fast path if we have a candidate id which is known to be a commit object. if let Some(id) = known_target.as_normal() { let raw_ref = &git_ref.inner; if matches!(raw_ref.target.try_id(), Some(oid) if oid.as_bytes() == id.as_bytes()) { return Some(id.clone()); } if matches!(raw_ref.peeled, Some(oid) if oid.as_bytes() == id.as_bytes()) { // Perhaps an annotated tag stored in packed-refs file, and pointing to the // already known target commit. return Some(id.clone()); } // A tag (according to ref name.) Try to peel one more level. This is slightly // faster than recurse into into_fully_peeled_id(). If we recorded a tag oid, we // could skip this at all. if raw_ref.peeled.is_none() && git_ref.name().as_bstr().starts_with(b"refs/tags/") { let maybe_tag = git_ref .try_id() .and_then(|id| id.object().ok()) .and_then(|object| object.try_into_tag().ok()); if let Some(oid) = maybe_tag.as_ref().and_then(|tag| tag.target_id().ok()) { if oid.as_bytes() == id.as_bytes() { // An annotated tag pointing to the already known target commit. return Some(id.clone()); } // Unknown id. Recurse from the current state. A tag may point to // non-commit object. peeling_ref.to_mut().inner.target = gix::refs::Target::Peeled(oid.detach()); } } } // Alternatively, we might want to inline the first half of the peeling // loop. into_fully_peeled_id() looks up the target object to see if it's // a tag or not, and we need to check if it's a commit object. let peeled_id = peeling_ref.into_owned().into_fully_peeled_id().ok()?; let is_commit = peeled_id .object() .map_or(false, |object| object.kind.is_commit()); is_commit.then(|| CommitId::from_bytes(peeled_id.as_bytes())) } #[derive(Error, Debug)] pub enum GitImportError { #[error("Failed to read Git HEAD target commit {id}", id=id.hex())] MissingHeadTarget { id: CommitId, #[source] err: BackendError, }, #[error("Ancestor of Git ref {ref_name} is missing")] MissingRefAncestor { ref_name: String, #[source] err: BackendError, }, #[error( "Git remote named '{name}' is reserved for local Git repository", name = REMOTE_NAME_FOR_LOCAL_GIT_REPO )] RemoteReservedForLocalGitRepo, #[error("Unexpected backend error when importing refs")] InternalBackend(#[source] BackendError), #[error("Unexpected git error when importing refs")] InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>), #[error("The repo is not backed by a Git repo")] UnexpectedBackend, } impl GitImportError { fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self { GitImportError::InternalGitError(source.into()) } } /// Describes changes made by `import_refs()` or `fetch()`. #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct GitImportStats { /// Commits superseded by newly imported commits. pub abandoned_commits: Vec<CommitId>, /// Remote `(ref_name, (old_remote_ref, new_target))`s to be merged in to /// the local refs. pub changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>, } #[derive(Debug)] struct RefsToImport { /// Git ref `(full_name, new_target)`s to be copied to the view. changed_git_refs: Vec<(String, RefTarget)>, /// Remote `(ref_name, (old_remote_ref, new_target))`s to be merged in to /// the local refs. changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>, } /// Reflect changes made in the underlying Git repo in the Jujutsu repo. /// /// This function detects conflicts (if both Git and JJ modified a branch) and /// records them in JJ's view. pub fn import_refs( mut_repo: &mut MutableRepo, git_settings: &GitSettings, ) -> Result<GitImportStats, GitImportError> { import_some_refs(mut_repo, git_settings, |_| true) } /// Reflect changes made in the underlying Git repo in the Jujutsu repo. /// /// Only branches whose git full reference name pass the filter will be /// considered for addition, update, or deletion. pub fn import_some_refs( mut_repo: &mut MutableRepo, git_settings: &GitSettings, git_ref_filter: impl Fn(&RefName) -> bool, ) -> Result<GitImportStats, GitImportError> { let store = mut_repo.store(); let git_backend = get_git_backend(store).ok_or(GitImportError::UnexpectedBackend)?; let git_repo = git_backend.git_repo(); let RefsToImport { changed_git_refs, changed_remote_refs, } = diff_refs_to_import(mut_repo.view(), &git_repo, git_ref_filter)?; // Bulk-import all reachable Git commits to the backend to reduce overhead // of table merging and ref updates. // // changed_remote_refs might contain new_targets that are not in // changed_git_refs, but such targets should have already been imported to // the backend. let index = mut_repo.index(); let missing_head_ids = changed_git_refs .iter() .flat_map(|(_, new_target)| new_target.added_ids()) .filter(|&id| !index.has_id(id)); let heads_imported = git_backend.import_head_commits(missing_head_ids).is_ok(); // Import new remote heads let mut head_commits = Vec::new(); let get_commit = |id| { // If bulk-import failed, try again to find bad head or ref. if !heads_imported && !index.has_id(id) { git_backend.import_head_commits([id])?; } store.get_commit(id) }; for (ref_name, (_, new_target)) in &changed_remote_refs { for id in new_target.added_ids() { let commit = get_commit(id).map_err(|err| GitImportError::MissingRefAncestor { ref_name: ref_name.to_string(), err, })?; head_commits.push(commit); } } // It's unlikely the imported commits were missing, but I/O-related error // can still occur. mut_repo .add_heads(&head_commits) .map_err(GitImportError::InternalBackend)?; // Apply the change that happened in git since last time we imported refs. for (full_name, new_target) in changed_git_refs { mut_repo.set_git_ref_target(&full_name, new_target); } for (ref_name, (old_remote_ref, new_target)) in &changed_remote_refs { let base_target = old_remote_ref.tracking_target(); let new_remote_ref = RemoteRef { target: new_target.clone(), state: if old_remote_ref.is_present() { old_remote_ref.state } else { default_remote_ref_state_for(ref_name, git_settings) }, }; match ref_name { RefName::LocalBranch(branch) => { if new_remote_ref.is_tracking() { mut_repo.merge_local_branch(branch, base_target, &new_remote_ref.target); } // Update Git-tracking branch like the other remote branches. mut_repo.set_remote_branch(branch, REMOTE_NAME_FOR_LOCAL_GIT_REPO, new_remote_ref); } RefName::RemoteBranch { branch, remote } => { if new_remote_ref.is_tracking() { mut_repo.merge_local_branch(branch, base_target, &new_remote_ref.target); } // Remote-tracking branch is the last known state of the branch in the remote. // It shouldn't diverge even if we had inconsistent view. mut_repo.set_remote_branch(branch, remote, new_remote_ref); } RefName::Tag(name) => { if new_remote_ref.is_tracking() { mut_repo.merge_tag(name, base_target, &new_remote_ref.target); } // TODO: If we add Git-tracking tag, it will be updated here. } } } let abandoned_commits = if git_settings.abandon_unreachable_commits { abandon_unreachable_commits(mut_repo, &changed_remote_refs) } else { vec![] }; let stats = GitImportStats { abandoned_commits, changed_remote_refs, }; Ok(stats) } /// Finds commits that used to be reachable in git that no longer are reachable. /// Those commits will be recorded as abandoned in the `MutableRepo`. fn abandon_unreachable_commits( mut_repo: &mut MutableRepo, changed_remote_refs: &BTreeMap<RefName, (RemoteRef, RefTarget)>, ) -> Vec<CommitId> { let hidable_git_heads = changed_remote_refs .values() .flat_map(|(old_remote_ref, _)| old_remote_ref.target.added_ids()) .cloned() .collect_vec(); if hidable_git_heads.is_empty() { return vec![]; } let pinned_heads = itertools::chain!( changed_remote_refs .values() .flat_map(|(_, new_target)| new_target.added_ids()), pinned_commit_ids(mut_repo.view()), iter::once(mut_repo.store().root_commit_id()), ) .cloned() .collect_vec(); let abandoned_expression = RevsetExpression::commits(pinned_heads) .range(&RevsetExpression::commits(hidable_git_heads)) .intersection(&RevsetExpression::visible_heads().ancestors()); let abandoned_commits = abandoned_expression .evaluate_programmatic(mut_repo) .unwrap() .iter() .collect_vec(); for abandoned_commit in &abandoned_commits { mut_repo.record_abandoned_commit(abandoned_commit.clone()); } abandoned_commits } /// Calculates diff of git refs to be imported. fn diff_refs_to_import( view: &View, git_repo: &gix::Repository, git_ref_filter: impl Fn(&RefName) -> bool, ) -> Result<RefsToImport, GitImportError> { let mut known_git_refs: HashMap<&str, &RefTarget> = view .git_refs() .iter() .filter_map(|(full_name, target)| { // TODO: or clean up invalid ref in case it was stored due to historical bug? let ref_name = parse_git_ref(full_name).expect("stored git ref should be parsable"); git_ref_filter(&ref_name).then_some((full_name.as_ref(), target)) }) .collect(); // TODO: migrate tags to the remote view, and don't destructure &RemoteRef let mut known_remote_refs: HashMap<RefName, (&RefTarget, RemoteRefState)> = itertools::chain( view.all_remote_branches() .map(|((branch, remote), remote_ref)| { // TODO: want to abstract local ref as "git" tracking remote, but // we'll probably need to refactor the git_ref_filter API first. let ref_name = if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO { RefName::LocalBranch(branch.to_owned()) } else { RefName::RemoteBranch { branch: branch.to_owned(), remote: remote.to_owned(), } }; let RemoteRef { target, state } = remote_ref; (ref_name, (target, *state)) }), // TODO: compare to tags stored in the "git" remote view. Since tags should never // be moved locally in jj, we can consider local tags as merge base. view.tags().iter().map(|(name, target)| { let ref_name = RefName::Tag(name.to_owned()); (ref_name, (target, RemoteRefState::Tracking)) }), ) .filter(|(ref_name, _)| git_ref_filter(ref_name)) .collect(); let mut changed_git_refs = Vec::new(); let mut changed_remote_refs = BTreeMap::new(); let git_references = git_repo.references().map_err(GitImportError::from_git)?; let chain_git_refs_iters = || -> Result<_, gix::reference::iter::init::Error> { // Exclude uninteresting directories such as refs/jj/keep. Ok(itertools::chain!( git_references.local_branches()?, git_references.remote_branches()?, git_references.tags()?, )) }; for git_ref in chain_git_refs_iters().map_err(GitImportError::from_git)? { let git_ref = git_ref.map_err(GitImportError::from_git)?; let Ok(full_name) = str::from_utf8(git_ref.name().as_bstr()) else { // Skip non-utf8 refs. continue; }; let Some(ref_name) = parse_git_ref(full_name) else { // Skip other refs (such as notes) and symbolic refs. continue; }; if !git_ref_filter(&ref_name) { continue; } if is_reserved_git_remote_ref(&ref_name) { return Err(GitImportError::RemoteReservedForLocalGitRepo); } let old_git_target = known_git_refs.get(full_name).copied().flatten(); let Some(id) = resolve_git_ref_to_commit_id(&git_ref, old_git_target) else { // Skip (or remove existing) invalid refs. continue; }; let new_target = RefTarget::normal(id); known_git_refs.remove(full_name); if new_target != *old_git_target { changed_git_refs.push((full_name.to_owned(), new_target.clone())); } // TODO: Make it configurable which remotes are publishing and update public // heads here. let (old_remote_target, old_remote_state) = known_remote_refs .remove(&ref_name) .unwrap_or_else(|| (RefTarget::absent_ref(), RemoteRefState::New)); if new_target != *old_remote_target { let old_remote_ref = RemoteRef { target: old_remote_target.clone(), state: old_remote_state, }; changed_remote_refs.insert(ref_name, (old_remote_ref, new_target)); } } for full_name in known_git_refs.into_keys() { changed_git_refs.push((full_name.to_owned(), RefTarget::absent())); } for (ref_name, (old_target, old_state)) in known_remote_refs { let old_remote_ref = RemoteRef { target: old_target.clone(), state: old_state, }; changed_remote_refs.insert(ref_name, (old_remote_ref, RefTarget::absent())); } Ok(RefsToImport { changed_git_refs, changed_remote_refs, }) } fn default_remote_ref_state_for(ref_name: &RefName, git_settings: &GitSettings) -> RemoteRefState { match ref_name { // LocalBranch means Git-tracking branch RefName::LocalBranch(_) | RefName::Tag(_) => RemoteRefState::Tracking, RefName::RemoteBranch { .. } => { if git_settings.auto_local_branch { RemoteRefState::Tracking } else { RemoteRefState::New } } } } /// Commits referenced by local branches, tags, or HEAD@git. /// /// On `import_refs()`, this is similar to collecting commits referenced by