#![allow(missing_docs)]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::default::Default;
use std::fmt;
use std::io::Read;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::str;
use bstr::BStr;
use itertools::Itertools;
use tempfile::NamedTempFile;
use thiserror::Error;
use crate::backend::BackendError;
use crate::backend::BackendResult;
use crate::backend::CommitId;
use crate::backend::TreeValue;
use crate::commit::Commit;
use crate::git_backend::GitBackend;
use crate::git_subprocess::GitSubprocessContext;
use crate::git_subprocess::GitSubprocessError;
use crate::index::Index;
use crate::merged_tree::MergedTree;
use crate::object_id::ObjectId;
use crate::op_store::RefTarget;
use crate::op_store::RefTargetOptionExt;
use crate::op_store::RemoteRef;
use crate::op_store::RemoteRefState;
use crate::refs;
use crate::refs::BookmarkPushUpdate;
use crate::repo::MutableRepo;
use crate::repo::Repo;
use crate::repo_path::RepoPath;
use crate::revset::RevsetExpression;
use crate::settings::GitSettings;
use crate::store::Store;
use crate::str_util::StringPattern;
use crate::view::View;
pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
const UNBORN_ROOT_REF_NAME: &str = "refs/jj/root";
const INDEX_DUMMY_CONFLICT_FILE: &str = ".jj-do-not-resolve-this-conflict";
#[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}"),
}
}
}
#[derive(Debug, Hash, PartialEq, Eq)]
pub(crate) struct RefSpec {
forced: bool,
source: Option<String>,
destination: String,
}
impl RefSpec {
fn forced(source: impl Into<String>, destination: impl Into<String>) -> Self {
RefSpec {
forced: true,
source: Some(source.into()),
destination: destination.into(),
}
}
fn delete(destination: impl Into<String>) -> Self {
RefSpec {
forced: false,
source: None,
destination: destination.into(),
}
}
pub(crate) fn to_git_format(&self) -> String {
format!(
"{}{}",
if self.forced { "+" } else { "" },
self.to_git_format_not_forced()
)
}
pub(crate) fn to_git_format_not_forced(&self) -> String {
if let Some(s) = &self.source {
format!("{}:{}", s, self.destination)
} else {
format!(":{}", self.destination)
}
}
}
pub(crate) struct RefToPush<'a> {
pub(crate) refspec: &'a RefSpec,
pub(crate) expected_location: Option<&'a CommitId>,
}
impl<'a> RefToPush<'a> {
fn new(refspec: &'a RefSpec, expected_locations: &'a HashMap<&str, Option<&CommitId>>) -> Self {
let expected_location = *expected_locations.get(refspec.destination.as_str()).expect(
"The refspecs and the expected locations were both constructed from the same source \
of truth. This means the lookup should always work.",
);
RefToPush {
refspec,
expected_location,
}
}
pub(crate) fn to_git_lease(&self) -> String {
format!(
"{}:{}",
self.refspec.destination,
self.expected_location
.map(|x| x.to_string())
.as_deref()
.unwrap_or("")
)
}
}
pub fn parse_git_ref(ref_name: &str) -> Option<RefName> {
if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") {
(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('/')
.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,
}
}
pub fn is_reserved_git_remote_ref(parsed_ref: &RefName) -> bool {
to_remote_branch(parsed_ref, REMOTE_NAME_FOR_LOCAL_GIT_REPO).is_some()
}
#[derive(Debug, Error)]
#[error("The repo is not backed by a Git repo")]
pub struct UnexpectedGitBackendError;
pub fn get_git_backend(store: &Store) -> Result<&GitBackend, UnexpectedGitBackendError> {
store
.backend_impl()
.downcast_ref()
.ok_or(UnexpectedGitBackendError)
}
pub fn get_git_repo(store: &Store) -> Result<gix::Repository, UnexpectedGitBackendError> {
get_git_backend(store).map(|backend| backend.git_repo())
}
fn resolve_git_ref_to_commit_id(
git_ref: &gix::Reference,
known_target: &RefTarget,
) -> Option<CommitId> {
let mut peeling_ref = Cow::Borrowed(git_ref);
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()) {
return Some(id.clone());
}
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() {
return Some(id.clone());
}
peeling_ref.to_mut().inner.target = gix::refs::Target::Object(oid.detach());
}
}
}
let peeled_id = peeling_ref.into_owned().into_fully_peeled_id().ok()?;
let is_commit = peeled_id
.object()
.is_ok_and(|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}")]
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(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
impl GitImportError {
fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
GitImportError::InternalGitError(source.into())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct GitImportStats {
pub abandoned_commits: Vec<CommitId>,
pub changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>,
}
#[derive(Debug)]
struct RefsToImport {
changed_git_refs: Vec<(String, RefTarget)>,
changed_remote_refs: BTreeMap<RefName, (RemoteRef, RefTarget)>,
}
pub fn import_refs(
mut_repo: &mut MutableRepo,
git_settings: &GitSettings,
) -> Result<GitImportStats, GitImportError> {
import_some_refs(mut_repo, git_settings, |_| true)
}
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)?;
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)?;
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();
let mut head_commits = Vec::new();
let get_commit = |id| {
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);
}
}
mut_repo
.add_heads(&head_commits)
.map_err(GitImportError::InternalBackend)?;
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_bookmark(branch, base_target, &new_remote_ref.target);
}
mut_repo.set_remote_bookmark(
branch,
REMOTE_NAME_FOR_LOCAL_GIT_REPO,
new_remote_ref,
);
}
RefName::RemoteBranch { branch, remote } => {
if new_remote_ref.is_tracking() {
mut_repo.merge_local_bookmark(branch, base_target, &new_remote_ref.target);
}
mut_repo.set_remote_bookmark(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);
}
}
}
}
let abandoned_commits = if git_settings.abandon_unreachable_commits {
abandon_unreachable_commits(mut_repo, &changed_remote_refs)
.map_err(GitImportError::InternalBackend)?
} else {
vec![]
};
let stats = GitImportStats {
abandoned_commits,
changed_remote_refs,
};
Ok(stats)
}
fn abandon_unreachable_commits(
mut_repo: &mut MutableRepo,
changed_remote_refs: &BTreeMap<RefName, (RemoteRef, RefTarget)>,
) -> BackendResult<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 Ok(vec![]);
}
let pinned_expression = RevsetExpression::union_all(&[
RevsetExpression::commits(pinned_commit_ids(mut_repo.view())),
RevsetExpression::commits(remotely_pinned_commit_ids(mut_repo.view()))
.intersection(&RevsetExpression::visible_heads().ancestors()),
RevsetExpression::root(),
]);
let abandoned_expression = pinned_expression
.range(&RevsetExpression::commits(hidable_git_heads))
.intersection(&RevsetExpression::visible_heads().ancestors());
let abandoned_commit_ids: Vec<_> = abandoned_expression
.evaluate(mut_repo)
.map_err(|err| err.expect_backend_error())?
.iter()
.try_collect()
.map_err(|err| err.expect_backend_error())?;
for id in &abandoned_commit_ids {
let commit = mut_repo.store().get_commit(id)?;
mut_repo.record_abandoned_commit(&commit);
}
Ok(abandoned_commit_ids)
}
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)| {
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();
let mut known_remote_refs: HashMap<RefName, (&RefTarget, RemoteRefState)> = itertools::chain(
view.all_remote_bookmarks()
.map(|((branch, remote), remote_ref)| {
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))
}),
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> {
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 {
continue;
};
let Some(ref_name) = parse_git_ref(full_name) else {
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 {
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()));
}
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 {
RefName::LocalBranch(_) | RefName::Tag(_) => RemoteRefState::Tracking,
RefName::RemoteBranch { .. } => {
if git_settings.auto_local_bookmark {
RemoteRefState::Tracking
} else {
RemoteRefState::New
}
}
}
}
fn pinned_commit_ids(view: &View) -> Vec<CommitId> {
itertools::chain(
view.local_bookmarks().map(|(_, target)| target),
view.tags().values(),
)
.flat_map(|target| target.added_ids())
.cloned()
.collect()
}
fn remotely_pinned_commit_ids(view: &View) -> Vec<CommitId> {
view.all_remote_bookmarks()
.filter(|(_, remote_ref)| !remote_ref.is_tracking())
.map(|(_, remote_ref)| &remote_ref.target)
.flat_map(|target| target.added_ids())
.cloned()
.collect()
}
pub fn import_head(mut_repo: &mut MutableRepo) -> Result<(), GitImportError> {
let store = mut_repo.store();
let git_backend = get_git_backend(store)?;
let git_repo = git_backend.git_repo();
let old_git_head = mut_repo.view().git_head();
let new_git_head_id = if let Ok(oid) = git_repo.head_id() {
Some(CommitId::from_bytes(oid.as_bytes()))
} else {
None
};
if old_git_head.as_resolved() == Some(&new_git_head_id) {
return Ok(());
}
if let Some(head_id) = &new_git_head_id {
let index = mut_repo.index();
if !index.has_id(head_id) {
git_backend.import_head_commits([head_id]).map_err(|err| {
GitImportError::MissingHeadTarget {
id: head_id.clone(),
err,
}
})?;
}
store
.get_commit(head_id)
.and_then(|commit| mut_repo.add_head(&commit))
.map_err(GitImportError::InternalBackend)?;
}
mut_repo.set_git_head_target(RefTarget::resolved(new_git_head_id));
Ok(())
}
#[derive(Error, Debug)]
pub enum GitExportError {
#[error("Git error")]
InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
#[error(transparent)]
Backend(#[from] BackendError),
}
impl GitExportError {
fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
GitExportError::InternalGitError(source.into())
}
}
#[derive(Debug)]
pub struct FailedRefExport {
pub name: RefName,
pub reason: FailedRefExportReason,
}
#[derive(Debug, Error)]
pub enum FailedRefExportReason {
#[error("Name is not allowed in Git")]
InvalidGitName,
#[error("Ref was in a conflicted state from the last import")]
ConflictedOldState,
#[error("Ref cannot point to the root commit in Git")]
OnRootCommit,
#[error("Deleted ref had been modified in Git")]
DeletedInJjModifiedInGit,
#[error("Added ref had been added with a different target in Git")]
AddedInJjAddedInGit,
#[error("Modified ref had been deleted in Git")]
ModifiedInJjDeletedInGit,
#[error("Failed to delete")]
FailedToDelete(#[source] Box<gix::reference::edit::Error>),
#[error("Failed to set")]
FailedToSet(#[source] Box<gix::reference::edit::Error>),
}
#[derive(Debug)]
struct RefsToExport {
branches_to_update: BTreeMap<RefName, (Option<gix::ObjectId>, gix::ObjectId)>,
branches_to_delete: BTreeMap<RefName, gix::ObjectId>,
failed_branches: HashMap<RefName, FailedRefExportReason>,
}
pub fn export_refs(mut_repo: &mut MutableRepo) -> Result<Vec<FailedRefExport>, GitExportError> {
export_some_refs(mut_repo, |_| true)
}
pub fn export_some_refs(
mut_repo: &mut MutableRepo,
git_ref_filter: impl Fn(&RefName) -> bool,
) -> Result<Vec<FailedRefExport>, GitExportError> {
let git_repo = get_git_repo(mut_repo.store())?;
let RefsToExport {
branches_to_update,
branches_to_delete,
mut failed_branches,
} = diff_refs_to_export(
mut_repo.view(),
mut_repo.store().root_commit_id(),
&git_ref_filter,
);
if let Ok(head_ref) = git_repo.find_reference("HEAD") {
if let Some(parsed_ref) = head_ref
.target()
.try_name()
.and_then(|name| str::from_utf8(name.as_bstr()).ok())
.and_then(parse_git_ref)
{
let old_target = head_ref.inner.target.clone();
let current_oid = match head_ref.into_fully_peeled_id() {
Ok(id) => Some(id.detach()),
Err(gix::reference::peel::Error::ToId(
gix::refs::peel::to_id::Error::FollowToObject(
gix::refs::peel::to_object::Error::Follow(
gix::refs::file::find::existing::Error::NotFound { .. },
),
),
)) => None, Err(err) => return Err(GitExportError::from_git(err)),
};
let new_oid = if let Some((_old_oid, new_oid)) = branches_to_update.get(&parsed_ref) {
Some(new_oid)
} else if branches_to_delete.contains_key(&parsed_ref) {
None
} else {
current_oid.as_ref()
};
if new_oid != current_oid.as_ref() {
update_git_head(
&git_repo,
gix::refs::transaction::PreviousValue::MustExistAndMatch(old_target),
current_oid,
)?;
}
}
}
for (parsed_ref_name, old_oid) in branches_to_delete {
let Some(git_ref_name) = to_git_ref_name(&parsed_ref_name) else {
failed_branches.insert(parsed_ref_name, FailedRefExportReason::InvalidGitName);
continue;
};
if let Err(reason) = delete_git_ref(&git_repo, &git_ref_name, &old_oid) {
failed_branches.insert(parsed_ref_name, reason);
} else {
let new_target = RefTarget::absent();
mut_repo.set_git_ref_target(&git_ref_name, new_target);
}
}
for (parsed_ref_name, (old_oid, new_oid)) in branches_to_update {
let Some(git_ref_name) = to_git_ref_name(&parsed_ref_name) else {
failed_branches.insert(parsed_ref_name, FailedRefExportReason::InvalidGitName);
continue;
};
if let Err(reason) = update_git_ref(&git_repo, &git_ref_name, old_oid, new_oid) {
failed_branches.insert(parsed_ref_name, reason);
} else {
let new_target = RefTarget::normal(CommitId::from_bytes(new_oid.as_bytes()));
mut_repo.set_git_ref_target(&git_ref_name, new_target);
}
}
copy_exportable_local_branches_to_remote_view(
mut_repo,
REMOTE_NAME_FOR_LOCAL_GIT_REPO,
|ref_name| git_ref_filter(ref_name) && !failed_branches.contains_key(ref_name),
);
let failed_branches = failed_branches
.into_iter()
.map(|(name, reason)| FailedRefExport { name, reason })
.sorted_unstable_by(|a, b| a.name.cmp(&b.name))
.collect();
Ok(failed_branches)
}
fn copy_exportable_local_branches_to_remote_view(
mut_repo: &mut MutableRepo,
remote_name: &str,
git_ref_filter: impl Fn(&RefName) -> bool,
) {
let new_local_branches = mut_repo
.view()
.local_remote_bookmarks(remote_name)
.filter_map(|(branch, targets)| {
let old_target = &targets.remote_ref.target;
let new_target = targets.local_target;
(!new_target.has_conflict() && old_target != new_target).then_some((branch, new_target))
})
.filter(|&(branch, _)| git_ref_filter(&RefName::LocalBranch(branch.to_owned())))
.map(|(branch, new_target)| (branch.to_owned(), new_target.clone()))
.collect_vec();
for (branch, new_target) in new_local_branches {
let new_remote_ref = RemoteRef {
target: new_target,
state: RemoteRefState::Tracking,
};
mut_repo.set_remote_bookmark(&branch, remote_name, new_remote_ref);
}
}
fn diff_refs_to_export(
view: &View,
root_commit_id: &CommitId,
git_ref_filter: impl Fn(&RefName) -> bool,
) -> RefsToExport {
let mut all_branch_targets: HashMap<RefName, (&RefTarget, &RefTarget)> = itertools::chain(
view.local_bookmarks()
.map(|(branch, target)| (RefName::LocalBranch(branch.to_owned()), target)),
view.all_remote_bookmarks()
.filter(|&((_, remote), _)| remote != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
.map(|((branch, remote), remote_ref)| {
let ref_name = RefName::RemoteBranch {
branch: branch.to_owned(),
remote: remote.to_owned(),
};
(ref_name, &remote_ref.target)
}),
)
.map(|(ref_name, new_target)| (ref_name, (RefTarget::absent_ref(), new_target)))
.filter(|(ref_name, _)| git_ref_filter(ref_name))
.collect();
let known_git_refs = view
.git_refs()
.iter()
.map(|(full_name, target)| {
let ref_name = parse_git_ref(full_name).expect("stored git ref should be parsable");
(ref_name, target)
})
.filter(|(ref_name, _)| {
matches!(
ref_name,
RefName::LocalBranch(..) | RefName::RemoteBranch { .. }
)
})
.filter(|(ref_name, _)| git_ref_filter(ref_name));
for (ref_name, target) in known_git_refs {
all_branch_targets
.entry(ref_name)
.and_modify(|(old_target, _)| *old_target = target)
.or_insert((target, RefTarget::absent_ref()));
}
let mut branches_to_update = BTreeMap::new();
let mut branches_to_delete = BTreeMap::new();
let mut failed_branches = HashMap::new();
let root_commit_target = RefTarget::normal(root_commit_id.clone());
for (ref_name, (old_target, new_target)) in all_branch_targets {
if new_target == old_target {
continue;
}
if *new_target == root_commit_target {
failed_branches.insert(ref_name, FailedRefExportReason::OnRootCommit);
continue;
}
let old_oid = if let Some(id) = old_target.as_normal() {
Some(gix::ObjectId::try_from(id.as_bytes()).unwrap())
} else if old_target.has_conflict() {
failed_branches.insert(ref_name, FailedRefExportReason::ConflictedOldState);
continue;
} else {
assert!(old_target.is_absent());
None
};
if let Some(id) = new_target.as_normal() {
let new_oid = gix::ObjectId::try_from(id.as_bytes()).unwrap();
branches_to_update.insert(ref_name, (old_oid, new_oid));
} else if new_target.has_conflict() {
continue;
} else {
assert!(new_target.is_absent());
branches_to_delete.insert(ref_name, old_oid.unwrap());
}
}
RefsToExport {
branches_to_update,
branches_to_delete,
failed_branches,
}
}
fn delete_git_ref(
git_repo: &gix::Repository,
git_ref_name: &str,
old_oid: &gix::oid,
) -> Result<(), FailedRefExportReason> {
if let Ok(git_ref) = git_repo.find_reference(git_ref_name) {
if git_ref.inner.target.try_id() == Some(old_oid) {
git_ref
.delete()
.map_err(|err| FailedRefExportReason::FailedToDelete(err.into()))?;
} else {
return Err(FailedRefExportReason::DeletedInJjModifiedInGit);
}
} else {
}
Ok(())
}
fn update_git_ref(
git_repo: &gix::Repository,
git_ref_name: &str,
old_oid: Option<gix::ObjectId>,
new_oid: gix::ObjectId,
) -> Result<(), FailedRefExportReason> {
match old_oid {
None => {
if let Ok(git_repo_ref) = git_repo.find_reference(git_ref_name) {
if git_repo_ref.inner.target.try_id() != Some(&new_oid) {
return Err(FailedRefExportReason::AddedInJjAddedInGit);
}
} else {
git_repo
.reference(
git_ref_name,
new_oid,
gix::refs::transaction::PreviousValue::MustNotExist,
"export from jj",
)
.map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?;
}
}
Some(old_oid) => {
if let Err(err) = git_repo.reference(
git_ref_name,
new_oid,
gix::refs::transaction::PreviousValue::MustExistAndMatch(old_oid.into()),
"export from jj",
) {
if let Ok(git_repo_ref) = git_repo.find_reference(git_ref_name) {
if git_repo_ref.inner.target.try_id() != Some(&new_oid) {
return Err(FailedRefExportReason::FailedToSet(err.into()));
}
} else {
return Err(FailedRefExportReason::ModifiedInJjDeletedInGit);
}
} else {
}
}
}
Ok(())
}
fn update_git_head(
git_repo: &gix::Repository,
expected_ref: gix::refs::transaction::PreviousValue,
new_oid: Option<gix::ObjectId>,
) -> Result<(), GitExportError> {
let mut ref_edits = Vec::new();
let new_target = if let Some(oid) = new_oid {
gix::refs::Target::Object(oid)
} else {
ref_edits.push(gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Delete {
expected: gix::refs::transaction::PreviousValue::Any,
log: gix::refs::transaction::RefLog::AndReference,
},
name: UNBORN_ROOT_REF_NAME.try_into().unwrap(),
deref: false,
});
gix::refs::Target::Symbolic(UNBORN_ROOT_REF_NAME.try_into().unwrap())
};
ref_edits.push(gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Update {
log: gix::refs::transaction::LogChange {
message: "export from jj".into(),
..Default::default()
},
expected: expected_ref,
new: new_target,
},
name: "HEAD".try_into().unwrap(),
deref: false,
});
git_repo
.edit_references(ref_edits)
.map_err(GitExportError::from_git)?;
Ok(())
}
pub fn reset_head(mut_repo: &mut MutableRepo, wc_commit: &Commit) -> Result<(), GitExportError> {
let git_repo = get_git_repo(mut_repo.store())?;
let first_parent_id = &wc_commit.parent_ids()[0];
let first_parent = if first_parent_id != mut_repo.store().root_commit_id() {
RefTarget::normal(first_parent_id.clone())
} else {
RefTarget::absent()
};
if mut_repo.git_head() != first_parent {
update_git_head(
&git_repo,
gix::refs::transaction::PreviousValue::MustExist,
first_parent
.as_normal()
.map(|id| gix::ObjectId::from_bytes_or_panic(id.as_bytes())),
)?;
mut_repo.set_git_head_target(first_parent);
}
if git_repo.state().is_some() {
get_git_backend(mut_repo.store())?
.open_git_repo()
.map_err(GitExportError::from_git)?
.cleanup_state()
.map_err(GitExportError::from_git)?;
}
let parent_tree = wc_commit.parent_tree(mut_repo)?;
let mut index = if let Some(tree) = parent_tree.as_merge().as_resolved() {
if tree.id() == mut_repo.store().empty_tree_id() {
gix::index::File::from_state(
gix::index::State::new(git_repo.object_hash()),
git_repo.index_path(),
)
} else {
git_repo
.index_from_tree(&gix::ObjectId::from_bytes_or_panic(tree.id().as_bytes()))
.map_err(GitExportError::from_git)?
}
} else {
build_index_from_merged_tree(&git_repo, parent_tree)?
};
if let Some(old_index) = git_repo.try_index().map_err(GitExportError::from_git)? {
index
.entries_mut_with_paths()
.merge_join_by(old_index.entries(), |(entry, path), old_entry| {
gix::index::Entry::cmp_filepaths(path, old_entry.path(&old_index))
.then_with(|| entry.stage().cmp(&old_entry.stage()))
})
.filter_map(|merged| merged.both())
.map(|((entry, _), old_entry)| (entry, old_entry))
.filter(|(entry, old_entry)| entry.id == old_entry.id && entry.mode == old_entry.mode)
.for_each(|(entry, old_entry)| entry.stat = old_entry.stat);
}
debug_assert!(index.verify_entries().is_ok());
index
.write(gix::index::write::Options::default())
.map_err(GitExportError::from_git)?;
Ok(())
}
fn build_index_from_merged_tree(
git_repo: &gix::Repository,
merged_tree: MergedTree,
) -> Result<gix::index::File, GitExportError> {
let mut index = gix::index::File::from_state(
gix::index::State::new(git_repo.object_hash()),
git_repo.index_path(),
);
let mut push_index_entry =
|path: &RepoPath, maybe_entry: &Option<TreeValue>, stage: gix::index::entry::Stage| {
let Some(entry) = maybe_entry else {
return;
};
let (id, mode) = match entry {
TreeValue::File { id, executable } => {
if *executable {
(id.as_bytes(), gix::index::entry::Mode::FILE_EXECUTABLE)
} else {
(id.as_bytes(), gix::index::entry::Mode::FILE)
}
}
TreeValue::Symlink(id) => (id.as_bytes(), gix::index::entry::Mode::SYMLINK),
TreeValue::Tree(_) => {
return;
}
TreeValue::GitSubmodule(id) => (id.as_bytes(), gix::index::entry::Mode::COMMIT),
TreeValue::Conflict(_) => panic!("unexpected merged tree entry: {entry:?}"),
};
let path = BStr::new(path.as_internal_file_string());
index.dangerously_push_entry(
gix::index::entry::Stat::default(),
gix::ObjectId::from_bytes_or_panic(id),
gix::index::entry::Flags::from_stage(stage),
mode,
path,
);
};
let mut has_many_sided_conflict = false;
for (path, entry) in merged_tree.entries() {
let entry = entry?;
if let Some(resolved) = entry.as_resolved() {
push_index_entry(&path, resolved, gix::index::entry::Stage::Unconflicted);
continue;
}
let conflict = entry.simplify();
if let [left, base, right] = conflict.as_slice() {
push_index_entry(&path, left, gix::index::entry::Stage::Ours);
push_index_entry(&path, base, gix::index::entry::Stage::Base);
push_index_entry(&path, right, gix::index::entry::Stage::Theirs);
} else {
has_many_sided_conflict = true;
push_index_entry(
&path,
conflict.first(),
gix::index::entry::Stage::Unconflicted,
);
}
}
index.sort_entries();
if has_many_sided_conflict
&& index
.entry_index_by_path(INDEX_DUMMY_CONFLICT_FILE.into())
.is_err()
{
let file_blob = git_repo
.write_blob(
b"The working copy commit contains conflicts which cannot be resolved using Git.\n",
)
.map_err(GitExportError::from_git)?;
index.dangerously_push_entry(
gix::index::entry::Stat::default(),
file_blob.detach(),
gix::index::entry::Flags::from_stage(gix::index::entry::Stage::Ours),
gix::index::entry::Mode::FILE,
INDEX_DUMMY_CONFLICT_FILE.into(),
);
index.sort_entries();
}
Ok(index)
}
#[derive(Debug, Error)]
pub enum GitRemoteManagementError {
#[error("No git remote named '{0}'")]
NoSuchRemote(String),
#[error("Git remote named '{0}' already exists")]
RemoteAlreadyExists(String),
#[error(
"Git remote named '{name}' is reserved for local Git repository",
name = REMOTE_NAME_FOR_LOCAL_GIT_REPO
)]
RemoteReservedForLocalGitRepo,
#[error(transparent)]
InternalGitError(git2::Error),
}
fn is_remote_not_found_err(err: &git2::Error) -> bool {
matches!(
(err.class(), err.code()),
(
git2::ErrorClass::Config,
git2::ErrorCode::NotFound | git2::ErrorCode::InvalidSpec
)
)
}
fn is_remote_exists_err(err: &git2::Error) -> bool {
matches!(
(err.class(), err.code()),
(git2::ErrorClass::Config, git2::ErrorCode::Exists)
)
}
pub fn is_special_git_remote(remote: &str) -> bool {
remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
}
pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitBackendError> {
let git_repo = get_git_repo(store)?;
let names = git_repo
.remote_names()
.into_iter()
.filter(|name| git_repo.try_find_remote(name.as_ref()).is_some())
.filter_map(|name| String::from_utf8(name.into_owned().into()).ok())
.collect();
Ok(names)
}
pub fn add_remote(
git_repo: &git2::Repository,
remote_name: &str,
url: &str,
) -> Result<(), GitRemoteManagementError> {
if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
}
git_repo.remote(remote_name, url).map_err(|err| {
if is_remote_exists_err(&err) {
GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
} else {
GitRemoteManagementError::InternalGitError(err)
}
})?;
Ok(())
}
pub fn remove_remote(
mut_repo: &mut MutableRepo,
git_repo: &git2::Repository,
remote_name: &str,
) -> Result<(), GitRemoteManagementError> {
git_repo.remote_delete(remote_name).map_err(|err| {
if is_remote_not_found_err(&err) {
GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
} else {
GitRemoteManagementError::InternalGitError(err)
}
})?;
if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
remove_remote_refs(mut_repo, remote_name);
}
Ok(())
}
fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
mut_repo.remove_remote(remote_name);
let prefix = format!("refs/remotes/{remote_name}/");
let git_refs_to_delete = mut_repo
.view()
.git_refs()
.keys()
.filter(|&r| r.starts_with(&prefix))
.cloned()
.collect_vec();
for git_ref in git_refs_to_delete {
mut_repo.set_git_ref_target(&git_ref, RefTarget::absent());
}
}
pub fn rename_remote(
mut_repo: &mut MutableRepo,
git_repo: &git2::Repository,
old_remote_name: &str,
new_remote_name: &str,
) -> Result<(), GitRemoteManagementError> {
if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
}
git_repo
.remote_rename(old_remote_name, new_remote_name)
.map_err(|err| {
if is_remote_not_found_err(&err) {
GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
} else if is_remote_exists_err(&err) {
GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
} else {
GitRemoteManagementError::InternalGitError(err)
}
})?;
if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
}
Ok(())
}
pub fn set_remote_url(
git_repo: &git2::Repository,
remote_name: &str,
new_remote_url: &str,
) -> Result<(), GitRemoteManagementError> {
if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
}
git_repo.find_remote(remote_name).map_err(|err| {
if is_remote_not_found_err(&err) {
GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
} else {
GitRemoteManagementError::InternalGitError(err)
}
})?;
git_repo
.remote_set_url(remote_name, new_remote_url)
.map_err(GitRemoteManagementError::InternalGitError)?;
Ok(())
}
fn rename_remote_refs(mut_repo: &mut MutableRepo, old_remote_name: &str, new_remote_name: &str) {
mut_repo.rename_remote(old_remote_name, new_remote_name);
let prefix = format!("refs/remotes/{old_remote_name}/");
let git_refs = mut_repo
.view()
.git_refs()
.iter()
.filter_map(|(r, target)| {
r.strip_prefix(&prefix).map(|p| {
(
r.clone(),
format!("refs/remotes/{new_remote_name}/{p}"),
target.clone(),
)
})
})
.collect_vec();
for (old, new, target) in git_refs {
mut_repo.set_git_ref_target(&old, RefTarget::absent());
mut_repo.set_git_ref_target(&new, target);
}
}
const INVALID_REFSPEC_CHARS: [char; 5] = [':', '^', '?', '[', ']'];
#[derive(Error, Debug)]
pub enum GitFetchError {
#[error("No git remote named '{0}'")]
NoSuchRemote(String),
#[error(
"Invalid branch pattern provided. When fetching, branch names and globs may not contain the characters `{chars}`",
chars = INVALID_REFSPEC_CHARS.iter().join("`, `")
)]
InvalidBranchPattern(StringPattern),
#[error("Unexpected git error when fetching")]
InternalGitError(#[from] git2::Error),
#[error(transparent)]
Subprocess(#[from] GitSubprocessError),
}
#[derive(Debug, Error)]
pub enum GitFetchPrepareError {
#[error(transparent)]
Git2(#[from] git2::Error),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
fn git2_fetch_options(
mut callbacks: RemoteCallbacks<'_>,
depth: Option<NonZeroU32>,
) -> git2::FetchOptions<'_> {
let mut proxy_options = git2::ProxyOptions::new();
proxy_options.auto();
let mut fetch_options = git2::FetchOptions::new();
fetch_options.proxy_options(proxy_options);
if callbacks.progress.is_none() {
callbacks.sideband_progress = None;
}
fetch_options.remote_callbacks(callbacks.into_git());
if let Some(depth) = depth {
fetch_options.depth(depth.get().try_into().unwrap_or(i32::MAX));
}
fetch_options
}
struct FetchedBranches {
remote: String,
branches: Vec<StringPattern>,
}
pub struct GitFetch<'a> {
mut_repo: &'a mut MutableRepo,
fetch_impl: GitFetchImpl<'a>,
git_settings: &'a GitSettings,
fetched: Vec<FetchedBranches>,
}
impl<'a> GitFetch<'a> {
pub fn new(
mut_repo: &'a mut MutableRepo,
git_settings: &'a GitSettings,
) -> Result<Self, GitFetchPrepareError> {
let fetch_impl = GitFetchImpl::new(mut_repo.store(), git_settings)?;
Ok(GitFetch {
mut_repo,
fetch_impl,
git_settings,
fetched: vec![],
})
}
#[tracing::instrument(skip(self, callbacks))]
pub fn fetch(
&mut self,
remote_name: &str,
branch_names: &[StringPattern],
callbacks: RemoteCallbacks<'_>,
depth: Option<NonZeroU32>,
) -> Result<(), GitFetchError> {
self.fetch_impl
.fetch(remote_name, branch_names, callbacks, depth)?;
self.fetched.push(FetchedBranches {
remote: remote_name.to_string(),
branches: branch_names.to_vec(),
});
Ok(())
}
#[tracing::instrument(skip(self, callbacks))]
pub fn get_default_branch(
&self,
remote_name: &str,
callbacks: RemoteCallbacks<'_>,
) -> Result<Option<String>, GitFetchError> {
self.fetch_impl.get_default_branch(remote_name, callbacks)
}
#[tracing::instrument(skip(self))]
pub fn import_refs(&mut self) -> Result<GitImportStats, GitImportError> {
tracing::debug!("import_refs");
let import_stats =
import_some_refs(
self.mut_repo,
self.git_settings,
|ref_name| match ref_name {
RefName::LocalBranch(_) => false,
RefName::Tag(_) => true,
RefName::RemoteBranch { branch, remote } => {
self.fetched.iter().any(|fetched| {
if fetched.remote != *remote {
return false;
}
fetched
.branches
.iter()
.any(|pattern| pattern.matches(branch))
})
}
},
)?;
self.fetched.clear();
Ok(import_stats)
}
}
fn expand_fetch_refspecs(
remote_name: &str,
branch_names: &[StringPattern],
) -> Result<Vec<RefSpec>, GitFetchError> {
branch_names
.iter()
.map(|pattern| {
pattern
.to_glob()
.filter(
|glob| !glob.contains(INVALID_REFSPEC_CHARS),
)
.map(|glob| {
RefSpec::forced(
format!("refs/heads/{glob}"),
format!("refs/remotes/{remote_name}/{glob}"),
)
})
.ok_or_else(|| GitFetchError::InvalidBranchPattern(pattern.clone()))
})
.collect()
}
enum GitFetchImpl<'a> {
Git2 {
git_repo: git2::Repository,
},
Subprocess {
git_repo: gix::Repository,
git_ctx: GitSubprocessContext<'a>,
},
}
impl<'a> GitFetchImpl<'a> {
fn new(store: &Store, git_settings: &'a GitSettings) -> Result<Self, GitFetchPrepareError> {
let git_backend = get_git_backend(store)?;
if git_settings.subprocess {
let git_repo = git_backend.git_repo();
let git_ctx =
GitSubprocessContext::from_git_backend(git_backend, &git_settings.executable_path);
Ok(GitFetchImpl::Subprocess { git_repo, git_ctx })
} else {
let git_repo = git_backend.open_git_repo()?;
Ok(GitFetchImpl::Git2 { git_repo })
}
}
fn fetch(
&self,
remote_name: &str,
branch_names: &[StringPattern],
callbacks: RemoteCallbacks<'_>,
depth: Option<NonZeroU32>,
) -> Result<(), GitFetchError> {
match self {
GitFetchImpl::Git2 { git_repo } => {
git2_fetch(git_repo, remote_name, branch_names, callbacks, depth)
}
GitFetchImpl::Subprocess { git_repo, git_ctx } => subprocess_fetch(
git_repo,
git_ctx,
remote_name,
branch_names,
callbacks,
depth,
),
}
}
fn get_default_branch(
&self,
remote_name: &str,
callbacks: RemoteCallbacks<'_>,
) -> Result<Option<String>, GitFetchError> {
match self {
GitFetchImpl::Git2 { git_repo } => {
git2_get_default_branch(git_repo, remote_name, callbacks)
}
GitFetchImpl::Subprocess { git_repo, git_ctx } => {
subprocess_get_default_branch(git_repo, git_ctx, remote_name, callbacks)
}
}
}
}
fn git2_fetch(
git_repo: &git2::Repository,
remote_name: &str,
branch_names: &[StringPattern],
callbacks: RemoteCallbacks<'_>,
depth: Option<NonZeroU32>,
) -> Result<(), GitFetchError> {
let mut remote = git_repo.find_remote(remote_name).map_err(|err| {
if is_remote_not_found_err(&err) {
GitFetchError::NoSuchRemote(remote_name.to_string())
} else {
GitFetchError::InternalGitError(err)
}
})?;
let refspecs: Vec<String> = expand_fetch_refspecs(remote_name, branch_names)?
.iter()
.map(|refspec| refspec.to_git_format())
.collect();
if refspecs.is_empty() {
return Ok(());
}
tracing::debug!("remote.download");
remote.download(&refspecs, Some(&mut git2_fetch_options(callbacks, depth)))?;
tracing::debug!("remote.prune");
remote.prune(None)?;
tracing::debug!("remote.update_tips");
remote.update_tips(
None,
git2::RemoteUpdateFlags::empty(),
git2::AutotagOption::Unspecified,
None,
)?;
tracing::debug!("remote.disconnect");
remote.disconnect()?;
Ok(())
}
fn git2_get_default_branch(
git_repo: &git2::Repository,
remote_name: &str,
callbacks: RemoteCallbacks<'_>,
) -> Result<Option<String>, GitFetchError> {
let mut remote = git_repo.find_remote(remote_name).map_err(|err| {
if is_remote_not_found_err(&err) {
GitFetchError::NoSuchRemote(remote_name.to_string())
} else {
GitFetchError::InternalGitError(err)
}
})?;
tracing::debug!("remote.connect");
let connection = {
let mut proxy_options = git2::ProxyOptions::new();
proxy_options.auto();
remote.connect_auth(
git2::Direction::Fetch,
Some(callbacks.into_git()),
Some(proxy_options),
)?
};
let mut default_branch = None;
tracing::debug!("remote.default_branch");
if let Ok(default_ref_buf) = connection.default_branch() {
if let Some(default_ref) = default_ref_buf.as_str() {
if let Some(RefName::LocalBranch(branch_name)) = parse_git_ref(default_ref) {
tracing::debug!(default_branch = branch_name);
default_branch = Some(branch_name);
}
}
}
Ok(default_branch)
}
fn subprocess_fetch(
git_repo: &gix::Repository,
git_ctx: &GitSubprocessContext,
remote_name: &str,
branch_names: &[StringPattern],
mut callbacks: RemoteCallbacks<'_>,
depth: Option<NonZeroU32>,
) -> Result<(), GitFetchError> {
if git_repo.try_find_remote(remote_name).is_none() {
return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
}
let mut remaining_refspecs: Vec<_> = expand_fetch_refspecs(remote_name, branch_names)?;
if remaining_refspecs.is_empty() {
return Ok(());
}
let mut branches_to_prune = Vec::new();
while let Some(failing_refspec) =
git_ctx.spawn_fetch(remote_name, &remaining_refspecs, &mut callbacks, depth)?
{
remaining_refspecs.retain(|r| r.source.as_ref() != Some(&failing_refspec));
if let Some(branch_name) = failing_refspec.strip_prefix("refs/heads/") {
branches_to_prune.push(format!("{remote_name}/{branch_name}"));
}
}
git_ctx.spawn_branch_prune(&branches_to_prune)?;
Ok(())
}
fn subprocess_get_default_branch(
git_repo: &gix::Repository,
git_ctx: &GitSubprocessContext,
remote_name: &str,
_callbacks: RemoteCallbacks<'_>,
) -> Result<Option<String>, GitFetchError> {
if git_repo.try_find_remote(remote_name).is_none() {
return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
}
let default_branch = git_ctx.spawn_remote_show(remote_name)?;
tracing::debug!(default_branch = default_branch);
Ok(default_branch)
}
#[derive(Error, Debug)]
pub enum GitPushError {
#[error("No git remote named '{0}'")]
NoSuchRemote(String),
#[error(
"Git remote named '{name}' is reserved for local Git repository",
name = REMOTE_NAME_FOR_LOCAL_GIT_REPO
)]
RemoteReservedForLocalGitRepo,
#[error("Refs in unexpected location: {0:?}")]
RefInUnexpectedLocation(Vec<String>),
#[error("Remote rejected the update of some refs (do you have permission to push to {0:?}?)")]
RefUpdateRejected(Vec<String>),
#[error("Unexpected git error when pushing")]
InternalGitError(#[from] git2::Error),
#[error(transparent)]
Subprocess(#[from] GitSubprocessError),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
#[derive(Clone, Debug)]
pub struct GitBranchPushTargets {
pub branch_updates: Vec<(String, BookmarkPushUpdate)>,
}
pub struct GitRefUpdate {
pub qualified_name: String,
pub expected_current_target: Option<CommitId>,
pub new_target: Option<CommitId>,
}
pub fn push_branches(
mut_repo: &mut MutableRepo,
git_settings: &GitSettings,
remote_name: &str,
targets: &GitBranchPushTargets,
callbacks: RemoteCallbacks<'_>,
) -> Result<(), GitPushError> {
let ref_updates = targets
.branch_updates
.iter()
.map(|(branch_name, update)| GitRefUpdate {
qualified_name: format!("refs/heads/{branch_name}"),
expected_current_target: update.old_target.clone(),
new_target: update.new_target.clone(),
})
.collect_vec();
push_updates(mut_repo, git_settings, remote_name, &ref_updates, callbacks)?;
for (branch_name, update) in &targets.branch_updates {
let git_ref_name = format!("refs/remotes/{remote_name}/{branch_name}");
let new_remote_ref = RemoteRef {
target: RefTarget::resolved(update.new_target.clone()),
state: RemoteRefState::Tracking,
};
mut_repo.set_git_ref_target(&git_ref_name, new_remote_ref.target.clone());
mut_repo.set_remote_bookmark(branch_name, remote_name, new_remote_ref);
}
Ok(())
}
pub fn push_updates(
repo: &dyn Repo,
git_settings: &GitSettings,
remote_name: &str,
updates: &[GitRefUpdate],
callbacks: RemoteCallbacks<'_>,
) -> Result<(), GitPushError> {
if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
return Err(GitPushError::RemoteReservedForLocalGitRepo);
}
let mut qualified_remote_refs_expected_locations = HashMap::new();
let mut refspecs = vec![];
for update in updates {
qualified_remote_refs_expected_locations.insert(
update.qualified_name.as_str(),
update.expected_current_target.as_ref(),
);
if let Some(new_target) = &update.new_target {
refspecs.push(RefSpec::forced(new_target.hex(), &update.qualified_name));
} else {
refspecs.push(RefSpec::delete(&update.qualified_name));
}
}
let git_backend = get_git_backend(repo.store())?;
if git_settings.subprocess {
let git_repo = git_backend.git_repo();
let git_ctx =
GitSubprocessContext::from_git_backend(git_backend, &git_settings.executable_path);
subprocess_push_refs(
&git_repo,
&git_ctx,
remote_name,
&qualified_remote_refs_expected_locations,
&refspecs,
callbacks,
)
} else {
let git_repo = git_backend.open_git_repo()?;
let refspecs: Vec<String> = refspecs.iter().map(RefSpec::to_git_format).collect();
git2_push_refs(
repo,
&git_repo,
remote_name,
&qualified_remote_refs_expected_locations,
&refspecs,
callbacks,
)
}
}
fn git2_push_refs(
repo: &dyn Repo,
git_repo: &git2::Repository,
remote_name: &str,
qualified_remote_refs_expected_locations: &HashMap<&str, Option<&CommitId>>,
refspecs: &[String],
callbacks: RemoteCallbacks<'_>,
) -> Result<(), GitPushError> {
let mut remote = git_repo.find_remote(remote_name).map_err(|err| {
if is_remote_not_found_err(&err) {
GitPushError::NoSuchRemote(remote_name.to_string())
} else {
GitPushError::InternalGitError(err)
}
})?;
let mut remaining_remote_refs: HashSet<_> = qualified_remote_refs_expected_locations
.keys()
.copied()
.collect();
let mut failed_push_negotiations = vec![];
let push_result = {
let mut push_options = git2::PushOptions::new();
let mut proxy_options = git2::ProxyOptions::new();
proxy_options.auto();
push_options.proxy_options(proxy_options);
let mut callbacks = callbacks.into_git();
callbacks.push_negotiation(|updates| {
for update in updates {
let dst_refname = update
.dst_refname()
.expect("Expect reference name to be valid UTF-8");
let expected_remote_location = *qualified_remote_refs_expected_locations
.get(dst_refname)
.expect("Push is trying to move a ref it wasn't asked to move");
let oid_to_maybe_commitid =
|oid: git2::Oid| (!oid.is_zero()).then(|| CommitId::from_bytes(oid.as_bytes()));
let actual_remote_location = oid_to_maybe_commitid(update.src());
let local_location = oid_to_maybe_commitid(update.dst());
match allow_push(
repo.index(),
actual_remote_location.as_ref(),
expected_remote_location,
local_location.as_ref(),
) {
Ok(PushAllowReason::NormalMatch) => {}
Ok(PushAllowReason::UnexpectedNoop) => {
tracing::info!(
"The push of {dst_refname} is unexpectedly a no-op, the remote branch \
is already at {actual_remote_location:?}. We expected it to be at \
{expected_remote_location:?}. We don't consider this an error.",
);
}
Ok(PushAllowReason::ExceptionalFastforward) => {
tracing::info!(
"We allow the push of {dst_refname} to {local_location:?}, even \
though it is unexpectedly at {actual_remote_location:?} on the \
server rather than the expected {expected_remote_location:?}. The \
desired location is a descendant of the actual location, and the \
actual location is a descendant of the expected location.",
);
}
Err(()) => {
tracing::info!(
"Cannot push {dst_refname} to {local_location:?}; it is at \
unexpectedly at {actual_remote_location:?} on the server as opposed \
to the expected {expected_remote_location:?}",
);
failed_push_negotiations.push(dst_refname.to_string());
}
}
}
if failed_push_negotiations.is_empty() {
Ok(())
} else {
Err(git2::Error::from_str("failed push negotiation"))
}
});
callbacks.push_update_reference(|refname, status| {
if status.is_none() {
remaining_remote_refs.remove(refname);
}
Ok(())
});
push_options.remote_callbacks(callbacks);
remote.push(refspecs, Some(&mut push_options))
};
if !failed_push_negotiations.is_empty() {
assert!(push_result.is_err());
failed_push_negotiations.sort();
Err(GitPushError::RefInUnexpectedLocation(
failed_push_negotiations,
))
} else {
push_result?;
if remaining_remote_refs.is_empty() {
Ok(())
} else {
Err(GitPushError::RefUpdateRejected(
remaining_remote_refs
.iter()
.sorted()
.map(|name| name.to_string())
.collect(),
))
}
}
}
fn subprocess_push_refs(
git_repo: &gix::Repository,
git_ctx: &GitSubprocessContext,
remote_name: &str,
qualified_remote_refs_expected_locations: &HashMap<&str, Option<&CommitId>>,
refspecs: &[RefSpec],
mut callbacks: RemoteCallbacks<'_>,
) -> Result<(), GitPushError> {
if git_repo.try_find_remote(remote_name).is_none() {
return Err(GitPushError::NoSuchRemote(remote_name.to_owned()));
}
let mut remaining_remote_refs: HashSet<_> = qualified_remote_refs_expected_locations
.keys()
.copied()
.collect();
let refs_to_push: Vec<RefToPush> = refspecs
.iter()
.map(|full_refspec| RefToPush::new(full_refspec, qualified_remote_refs_expected_locations))
.collect();
let (failed_ref_matches, successful_pushes) =
git_ctx.spawn_push(remote_name, &refs_to_push, &mut callbacks)?;
for remote_ref in successful_pushes {
remaining_remote_refs.remove(remote_ref.as_str());
}
if !failed_ref_matches.is_empty() {
let mut refs_in_unexpected_locations = failed_ref_matches;
refs_in_unexpected_locations.sort();
Err(GitPushError::RefInUnexpectedLocation(
refs_in_unexpected_locations,
))
} else if remaining_remote_refs.is_empty() {
Ok(())
} else {
Err(GitPushError::RefUpdateRejected(
remaining_remote_refs
.iter()
.sorted()
.map(|name| name.to_string())
.collect(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PushAllowReason {
NormalMatch,
ExceptionalFastforward,
UnexpectedNoop,
}
fn allow_push(
index: &dyn Index,
actual_remote_location: Option<&CommitId>,
expected_remote_location: Option<&CommitId>,
destination_location: Option<&CommitId>,
) -> Result<PushAllowReason, ()> {
if actual_remote_location == expected_remote_location {
return Ok(PushAllowReason::NormalMatch);
}
if !actual_remote_location.map_or(true, |id| index.has_id(id)) {
return Err(());
}
let remote_target = RefTarget::resolved(actual_remote_location.cloned());
let base_target = RefTarget::resolved(expected_remote_location.cloned());
let local_target = RefTarget::resolved(destination_location.cloned());
if refs::merge_ref_targets(index, &remote_target, &base_target, &local_target) == local_target {
Ok(if actual_remote_location == destination_location {
PushAllowReason::UnexpectedNoop
} else {
PushAllowReason::ExceptionalFastforward
})
} else {
Err(())
}
}
#[non_exhaustive]
#[derive(Default)]
#[allow(clippy::type_complexity)]
pub struct RemoteCallbacks<'a> {
pub progress: Option<&'a mut dyn FnMut(&Progress)>,
pub sideband_progress: Option<&'a mut dyn FnMut(&[u8])>,
pub get_ssh_keys: Option<&'a mut dyn FnMut(&str) -> Vec<PathBuf>>,
pub get_password: Option<&'a mut dyn FnMut(&str, &str) -> Option<String>>,
pub get_username_password: Option<&'a mut dyn FnMut(&str) -> Option<(String, String)>>,
}
impl<'a> RemoteCallbacks<'a> {
fn into_git(mut self) -> git2::RemoteCallbacks<'a> {
let mut callbacks = git2::RemoteCallbacks::new();
if let Some(progress_cb) = self.progress {
callbacks.transfer_progress(move |progress| {
progress_cb(&Progress {
bytes_downloaded: (progress.received_objects() < progress.total_objects())
.then(|| progress.received_bytes() as u64),
overall: (progress.indexed_objects() + progress.indexed_deltas()) as f32
/ (progress.total_objects() + progress.total_deltas()) as f32,
});
true
});
}
if let Some(sideband_progress_cb) = self.sideband_progress {
callbacks.sideband_progress(move |data| {
sideband_progress_cb(data);
true
});
}
let mut tried_ssh_agent = false;
let mut ssh_key_paths_to_try: Option<Vec<PathBuf>> = None;
callbacks.credentials(move |url, username_from_url, allowed_types| {
let span = tracing::debug_span!("RemoteCallbacks.credentials");
let _ = span.enter();
let git_config = git2::Config::open_default();
let credential_helper = git_config
.and_then(|conf| git2::Cred::credential_helper(&conf, url, username_from_url));
if let Ok(creds) = credential_helper {
tracing::info!("using credential_helper");
return Ok(creds);
} else if let Some(username) = username_from_url {
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
if !tried_ssh_agent {
tracing::info!(username, "trying ssh_key_from_agent");
tried_ssh_agent = true;
return git2::Cred::ssh_key_from_agent(username).map_err(|err| {
tracing::error!(err = %err);
err
});
}
let paths = ssh_key_paths_to_try.get_or_insert_with(|| {
if let Some(ref mut cb) = self.get_ssh_keys {
let mut paths = cb(username);
paths.reverse();
paths
} else {
vec![]
}
});
if let Some(path) = paths.pop() {
tracing::info!(username, path = ?path, "trying ssh_key");
return git2::Cred::ssh_key(username, None, &path, None).map_err(|err| {
tracing::error!(err = %err);
err
});
}
}
if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Some(ref mut cb) = self.get_password {
if let Some(pw) = cb(url, username) {
tracing::info!(
username,
"using userpass_plaintext with username from url"
);
return git2::Cred::userpass_plaintext(username, &pw).map_err(|err| {
tracing::error!(err = %err);
err
});
}
}
}
} else if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Some(ref mut cb) = self.get_username_password {
if let Some((username, pw)) = cb(url) {
tracing::info!(username, "using userpass_plaintext");
return git2::Cred::userpass_plaintext(&username, &pw).map_err(|err| {
tracing::error!(err = %err);
err
});
}
}
}
tracing::info!("using default");
git2::Cred::default()
});
callbacks
}
}
#[derive(Clone, Debug)]
pub struct Progress {
pub bytes_downloaded: Option<u64>,
pub overall: f32,
}
#[derive(Default)]
struct PartialSubmoduleConfig {
path: Option<String>,
url: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct SubmoduleConfig {
pub name: String,
pub path: String,
pub url: String,
}
#[derive(Error, Debug)]
pub enum GitConfigParseError {
#[error("Unexpected io error when parsing config")]
IoError(#[from] std::io::Error),
#[error("Unexpected git error when parsing config")]
InternalGitError(#[from] git2::Error),
}
pub fn parse_gitmodules(
config: &mut dyn Read,
) -> Result<BTreeMap<String, SubmoduleConfig>, GitConfigParseError> {
let mut temp_file = NamedTempFile::new()?;
std::io::copy(config, &mut temp_file)?;
let path = temp_file.into_temp_path();
let git_config = git2::Config::open(&path)?;
let mut partial_configs: BTreeMap<String, PartialSubmoduleConfig> = BTreeMap::new();
let entries = git_config.entries(Some(r"submodule\..+\."))?;
entries.for_each(|entry| {
let (config_name, config_value) = match (entry.name(), entry.value()) {
(Some(name), Some(value)) => (name, value),
_ => return,
};
let (submod_name, submod_var) = config_name
.strip_prefix("submodule.")
.unwrap()
.split_once('.')
.unwrap();
let map_entry = partial_configs.entry(submod_name.to_string()).or_default();
match (submod_var.to_ascii_lowercase().as_str(), &map_entry) {
("path", PartialSubmoduleConfig { path: None, .. }) => {
map_entry.path = Some(config_value.to_string());
}
("url", PartialSubmoduleConfig { url: None, .. }) => {
map_entry.url = Some(config_value.to_string());
}
_ => (),
};
})?;
let ret = partial_configs
.into_iter()
.filter_map(|(name, val)| {
Some((
name.clone(),
SubmoduleConfig {
name,
path: val.path?,
url: val.url?,
},
))
})
.collect();
Ok(ret)
}