#![expect(missing_docs)]
use std::borrow::Borrow;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::default::Default;
use std::ffi::OsString;
use std::fs::File;
use std::iter;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::sync::Arc;
use bstr::BStr;
use bstr::BString;
use futures::StreamExt as _;
use futures::TryStreamExt as _;
use gix::refspec::Instruction;
use itertools::Itertools as _;
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::config::ConfigGetError;
use crate::file_util::IoResultExt as _;
use crate::file_util::PathError;
use crate::git_backend::GitBackend;
use crate::git_subprocess::GitFetchStatus;
pub use crate::git_subprocess::GitProgress;
pub use crate::git_subprocess::GitSidebandLineTerminator;
pub use crate::git_subprocess::GitSubprocessCallback;
use crate::git_subprocess::GitSubprocessContext;
use crate::git_subprocess::GitSubprocessError;
use crate::index::IndexError;
use crate::matchers::EverythingMatcher;
use crate::merge::Diff;
use crate::merged_tree::MergedTree;
use crate::merged_tree::TreeDiffEntry;
use crate::object_id::ObjectId as _;
use crate::op_store::RefTarget;
use crate::op_store::RefTargetOptionExt as _;
use crate::op_store::RemoteRef;
use crate::op_store::RemoteRefState;
use crate::ref_name::GitRefName;
use crate::ref_name::GitRefNameBuf;
use crate::ref_name::RefName;
use crate::ref_name::RefNameBuf;
use crate::ref_name::RemoteName;
use crate::ref_name::RemoteNameBuf;
use crate::ref_name::RemoteRefSymbol;
use crate::ref_name::RemoteRefSymbolBuf;
use crate::repo::MutableRepo;
use crate::repo::Repo;
use crate::repo_path::RepoPath;
use crate::revset::RevsetExpression;
use crate::settings::UserSettings;
use crate::store::Store;
use crate::str_util::StringExpression;
use crate::str_util::StringMatcher;
use crate::str_util::StringPattern;
use crate::view::View;
pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &RemoteName = RemoteName::new("git");
pub const RESERVED_REMOTE_REF_NAMESPACE: &str = "refs/remotes/git/";
const REMOTE_BOOKMARK_REF_NAMESPACE: &str = "refs/remotes/";
const REMOTE_TAG_REF_NAMESPACE: &str = "refs/jj/remote-tags/";
const UNBORN_ROOT_REF_NAME: &str = "refs/jj/root";
const INDEX_DUMMY_CONFLICT_FILE: &str = ".jj-do-not-resolve-this-conflict";
#[derive(Clone, Debug)]
pub struct GitSettings {
pub auto_local_bookmark: bool,
pub abandon_unreachable_commits: bool,
pub executable_path: PathBuf,
pub write_change_id_header: bool,
}
impl GitSettings {
pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
Ok(Self {
auto_local_bookmark: settings.get_bool("git.auto-local-bookmark")?,
abandon_unreachable_commits: settings.get_bool("git.abandon-unreachable-commits")?,
executable_path: settings.get("git.executable-path")?,
write_change_id_header: settings.get("git.write-change-id-header")?,
})
}
pub fn to_subprocess_options(&self) -> GitSubprocessOptions {
GitSubprocessOptions {
executable_path: self.executable_path.clone(),
environment: HashMap::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct GitSubprocessOptions {
pub executable_path: PathBuf,
pub environment: HashMap<OsString, OsString>,
}
impl GitSubprocessOptions {
pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
Ok(Self {
executable_path: settings.get("git.executable-path")?,
environment: HashMap::new(),
})
}
}
#[derive(Debug, Error)]
pub enum GitRemoteNameError {
#[error(
"Git remote named '{name}' is reserved for local Git repository",
name = REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol()
)]
ReservedForLocalGitRepo,
#[error("Git remotes with slashes are incompatible with jj: {}", .0.as_symbol())]
WithSlash(RemoteNameBuf),
}
fn validate_remote_name(name: &RemoteName) -> Result<(), GitRemoteNameError> {
if name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
Err(GitRemoteNameError::ReservedForLocalGitRepo)
} else if name.as_str().contains('/') {
Err(GitRemoteNameError::WithSlash(name.to_owned()))
} else {
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitRefKind {
Bookmark,
Tag,
}
#[derive(Debug, Default)]
pub struct GitPushStats {
pub pushed: Vec<GitRefNameBuf>,
pub rejected: Vec<(GitRefNameBuf, Option<String>)>,
pub remote_rejected: Vec<(GitRefNameBuf, Option<String>)>,
pub unexported_bookmarks: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
}
impl GitPushStats {
pub fn all_ok(&self) -> bool {
self.rejected.is_empty()
&& self.remote_rejected.is_empty()
&& self.unexported_bookmarks.is_empty()
}
pub fn some_exported(&self) -> bool {
self.pushed.len() > self.unexported_bookmarks.len()
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct RemoteRefKey<'a>(RemoteRefSymbol<'a>);
impl<'a: 'b, 'b> Borrow<RemoteRefSymbol<'b>> for RemoteRefKey<'a> {
fn borrow(&self) -> &RemoteRefSymbol<'b> {
&self.0
}
}
#[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 {
Self {
forced: true,
source: Some(source.into()),
destination: destination.into(),
}
}
fn delete(destination: impl Into<String>) -> Self {
Self {
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)
}
}
}
#[derive(Debug)]
#[repr(transparent)]
pub(crate) struct NegativeRefSpec {
source: String,
}
impl NegativeRefSpec {
fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
}
}
pub(crate) fn to_git_format(&self) -> String {
format!("^{}", self.source)
}
}
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<&GitRefName, Option<&CommitId>>,
) -> Self {
let expected_location = *expected_locations
.get(GitRefName::new(&refspec.destination))
.expect(
"The refspecs and the expected locations were both constructed from the same \
source of truth. This means the lookup should always work.",
);
Self {
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(full_name: &GitRefName) -> Option<(GitRefKind, RemoteRefSymbol<'_>)> {
if let Some(name) = full_name.as_str().strip_prefix("refs/heads/") {
if name == "HEAD" {
return None;
}
let name = RefName::new(name);
let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
Some((GitRefKind::Bookmark, RemoteRefSymbol { name, remote }))
} else if let Some(remote_and_name) = full_name
.as_str()
.strip_prefix(REMOTE_BOOKMARK_REF_NAMESPACE)
{
let (remote, name) = remote_and_name.split_once('/')?;
if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO || name == "HEAD" {
return None;
}
let name = RefName::new(name);
let remote = RemoteName::new(remote);
Some((GitRefKind::Bookmark, RemoteRefSymbol { name, remote }))
} else if let Some(name) = full_name.as_str().strip_prefix("refs/tags/") {
let name = RefName::new(name);
let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
Some((GitRefKind::Tag, RemoteRefSymbol { name, remote }))
} else {
None
}
}
fn parse_remote_tag_ref(full_name: &GitRefName) -> Option<(GitRefKind, RemoteRefSymbol<'_>)> {
let remote_and_name = full_name.as_str().strip_prefix(REMOTE_TAG_REF_NAMESPACE)?;
let (remote, name) = remote_and_name.split_once('/')?;
if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
return None;
}
let name = RefName::new(name);
let remote = RemoteName::new(remote);
Some((GitRefKind::Tag, RemoteRefSymbol { name, remote }))
}
fn to_git_ref_name(kind: GitRefKind, symbol: RemoteRefSymbol<'_>) -> Option<GitRefNameBuf> {
let RemoteRefSymbol { name, remote } = symbol;
let name = name.as_str();
let remote = remote.as_str();
if name.is_empty() || remote.is_empty() {
return None;
}
match kind {
GitRefKind::Bookmark => {
if name == "HEAD" {
return None;
}
if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
Some(format!("refs/heads/{name}").into())
} else {
Some(format!("{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{name}").into())
}
}
GitRefKind::Tag => {
(remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO).then(|| format!("refs/tags/{name}").into())
}
}
}
fn to_remote_tag_ref_name(symbol: RemoteRefSymbol<'_>) -> Option<GitRefNameBuf> {
let RemoteRefSymbol { name, remote } = symbol;
let name = name.as_str();
let remote = remote.as_str();
(remote != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
.then(|| format!("{REMOTE_TAG_REF_NAMESPACE}{remote}/{name}").into())
}
#[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().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_commit_oid: Option<&gix::oid>,
) -> Option<gix::ObjectId> {
let mut peeling_ref = Cow::Borrowed(git_ref);
if let Some(known_oid) = known_commit_oid {
let raw_ref = &git_ref.inner;
if let Some(oid) = raw_ref.target.try_id()
&& oid == known_oid
{
return Some(oid.to_owned());
}
if let Some(oid) = raw_ref.peeled
&& oid == known_oid
{
return Some(oid);
}
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()) {
let oid = oid.detach();
if oid == known_oid {
return Some(oid);
}
peeling_ref.to_mut().inner.target = gix::refs::Target::Object(oid);
}
}
}
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_some(peeled_id.detach())
}
#[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 {symbol} is missing")]
MissingRefAncestor {
symbol: RemoteRefSymbolBuf,
#[source]
err: BackendError,
},
#[error(transparent)]
Backend(#[from] BackendError),
#[error(transparent)]
Index(#[from] IndexError),
#[error(transparent)]
Git(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 {
Self::Git(source.into())
}
}
#[derive(Debug)]
pub struct GitImportOptions {
pub auto_local_bookmark: bool,
pub abandon_unreachable_commits: bool,
pub remote_auto_track_bookmarks: HashMap<RemoteNameBuf, StringMatcher>,
}
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct GitImportStats {
pub abandoned_commits: Vec<CommitId>,
pub changed_remote_bookmarks: Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
pub changed_remote_tags: Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
pub failed_ref_names: Vec<BString>,
}
#[derive(Debug)]
struct RefsToImport {
changed_git_refs: Vec<(GitRefNameBuf, RefTarget)>,
changed_remote_bookmarks: Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
changed_remote_tags: Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
failed_ref_names: Vec<BString>,
}
pub async fn import_refs(
mut_repo: &mut MutableRepo,
options: &GitImportOptions,
) -> Result<GitImportStats, GitImportError> {
import_some_refs(mut_repo, options, |_, _| true).await
}
pub async fn import_some_refs(
mut_repo: &mut MutableRepo,
options: &GitImportOptions,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<GitImportStats, GitImportError> {
let git_repo = get_git_repo(mut_repo.store())?;
for remote_name in iter_remote_names(&git_repo) {
mut_repo.ensure_remote(&remote_name);
}
let all_remote_tags = false;
let refs_to_import =
diff_refs_to_import(mut_repo.view(), &git_repo, all_remote_tags, git_ref_filter)?;
import_refs_inner(mut_repo, refs_to_import, options).await
}
async fn import_refs_inner(
mut_repo: &mut MutableRepo,
refs_to_import: RefsToImport,
options: &GitImportOptions,
) -> Result<GitImportStats, GitImportError> {
let store = mut_repo.store();
let git_backend = get_git_backend(store).expect("backend type should have been tested");
let RefsToImport {
changed_git_refs,
changed_remote_bookmarks,
changed_remote_tags,
failed_ref_names,
} = refs_to_import;
let iter_changed_refs = || itertools::chain(&changed_remote_bookmarks, &changed_remote_tags);
let index = mut_repo.index();
let missing_head_ids: Vec<&CommitId> = iter_changed_refs()
.flat_map(|(_, (_, new_target))| new_target.added_ids())
.filter_map(|id| match index.has_id(id) {
Ok(false) => Some(Ok(id)),
Ok(true) => None,
Err(e) => Some(Err(e)),
})
.try_collect()?;
let heads_imported = git_backend.import_head_commits(missing_head_ids).is_ok();
let mut head_commits = Vec::new();
let get_commit = async |id: &CommitId, symbol: &RemoteRefSymbolBuf| {
let missing_ref_err = |err| GitImportError::MissingRefAncestor {
symbol: symbol.clone(),
err,
};
if !heads_imported && !index.has_id(id).map_err(GitImportError::Index)? {
git_backend
.import_head_commits([id])
.map_err(missing_ref_err)?;
}
store.get_commit_async(id).await.map_err(missing_ref_err)
};
for (symbol, (_, new_target)) in iter_changed_refs() {
for id in new_target.added_ids() {
let commit = get_commit(id, symbol).await?;
head_commits.push(commit);
}
}
mut_repo
.add_heads(&head_commits)
.await
.map_err(GitImportError::Backend)?;
for (full_name, new_target) in changed_git_refs {
mut_repo.set_git_ref_target(&full_name, new_target);
}
for (symbol, (old_remote_ref, new_target)) in &changed_remote_bookmarks {
let symbol = symbol.as_ref();
let base_target = old_remote_ref.tracked_target();
let new_remote_ref = RemoteRef {
target: new_target.clone(),
state: if old_remote_ref != RemoteRef::absent_ref() {
old_remote_ref.state
} else {
default_remote_ref_state_for(GitRefKind::Bookmark, symbol, options)
},
};
if new_remote_ref.is_tracked() {
mut_repo.merge_local_bookmark(symbol.name, base_target, &new_remote_ref.target)?;
}
mut_repo.set_remote_bookmark(symbol, new_remote_ref);
}
for (symbol, (old_remote_ref, new_target)) in &changed_remote_tags {
let symbol = symbol.as_ref();
let base_target = old_remote_ref.tracked_target();
let new_remote_ref = RemoteRef {
target: new_target.clone(),
state: if old_remote_ref != RemoteRef::absent_ref() {
old_remote_ref.state
} else {
default_remote_ref_state_for(GitRefKind::Tag, symbol, options)
},
};
if new_remote_ref.is_tracked() {
mut_repo.merge_local_tag(symbol.name, base_target, &new_remote_ref.target)?;
}
mut_repo.set_remote_tag(symbol, new_remote_ref);
}
let abandoned_commits = if options.abandon_unreachable_commits {
abandon_unreachable_commits(mut_repo, &changed_remote_bookmarks, &changed_remote_tags)
.await
.map_err(GitImportError::Backend)?
} else {
vec![]
};
let stats = GitImportStats {
abandoned_commits,
changed_remote_bookmarks,
changed_remote_tags,
failed_ref_names,
};
Ok(stats)
}
async fn abandon_unreachable_commits(
mut_repo: &mut MutableRepo,
changed_remote_bookmarks: &[(RemoteRefSymbolBuf, (RemoteRef, RefTarget))],
changed_remote_tags: &[(RemoteRefSymbolBuf, (RemoteRef, RefTarget))],
) -> BackendResult<Vec<CommitId>> {
let hidable_git_heads = itertools::chain(changed_remote_bookmarks, changed_remote_tags)
.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.into_backend_error())?
.stream()
.try_collect()
.await
.map_err(|err| err.into_backend_error())?;
for id in &abandoned_commit_ids {
let commit = mut_repo.store().get_commit_async(id).await?;
mut_repo.record_abandoned_commit(&commit);
}
Ok(abandoned_commit_ids)
}
fn diff_refs_to_import(
view: &View,
git_repo: &gix::Repository,
all_remote_tags: bool,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<RefsToImport, GitImportError> {
let mut known_git_refs = view
.git_refs()
.iter()
.filter_map(|(full_name, target)| {
let (kind, symbol) =
parse_git_ref(full_name).expect("stored git ref should be parsable");
git_ref_filter(kind, symbol).then_some((full_name.as_ref(), target))
})
.collect();
let mut known_remote_bookmarks = view
.all_remote_bookmarks()
.filter(|&(symbol, _)| git_ref_filter(GitRefKind::Bookmark, symbol))
.map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
.collect();
let mut known_remote_tags = if all_remote_tags {
view.all_remote_tags()
.filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
.map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
.collect()
} else {
let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
view.remote_tags(remote)
.map(|(name, remote_ref)| (name.to_remote_symbol(remote), remote_ref))
.filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
.map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
.collect()
};
let mut changed_git_refs = Vec::new();
let mut changed_remote_bookmarks = Vec::new();
let mut changed_remote_tags = Vec::new();
let mut failed_ref_names = Vec::new();
let actual = git_repo.references().map_err(GitImportError::from_git)?;
collect_changed_refs_to_import(
actual.local_branches().map_err(GitImportError::from_git)?,
&mut known_git_refs,
&mut known_remote_bookmarks,
&mut changed_git_refs,
&mut changed_remote_bookmarks,
&mut failed_ref_names,
&git_ref_filter,
)?;
collect_changed_refs_to_import(
actual.remote_branches().map_err(GitImportError::from_git)?,
&mut known_git_refs,
&mut known_remote_bookmarks,
&mut changed_git_refs,
&mut changed_remote_bookmarks,
&mut failed_ref_names,
&git_ref_filter,
)?;
collect_changed_refs_to_import(
actual.tags().map_err(GitImportError::from_git)?,
&mut known_git_refs,
&mut known_remote_tags,
&mut changed_git_refs,
&mut changed_remote_tags,
&mut failed_ref_names,
&git_ref_filter,
)?;
if all_remote_tags {
collect_changed_remote_tags_to_import(
actual
.prefixed(REMOTE_TAG_REF_NAMESPACE)
.map_err(GitImportError::from_git)?,
&mut known_remote_tags,
&mut changed_remote_tags,
&mut failed_ref_names,
&git_ref_filter,
)?;
}
for full_name in known_git_refs.into_keys() {
changed_git_refs.push((full_name.to_owned(), RefTarget::absent()));
}
for (RemoteRefKey(symbol), old) in known_remote_bookmarks {
if old.is_present() {
changed_remote_bookmarks.push((symbol.to_owned(), (old.clone(), RefTarget::absent())));
}
}
for (RemoteRefKey(symbol), old) in known_remote_tags {
if old.is_present() {
changed_remote_tags.push((symbol.to_owned(), (old.clone(), RefTarget::absent())));
}
}
changed_git_refs.sort_unstable_by(|(name1, _), (name2, _)| name1.cmp(name2));
changed_remote_bookmarks.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
changed_remote_tags.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
failed_ref_names.sort_unstable();
Ok(RefsToImport {
changed_git_refs,
changed_remote_bookmarks,
changed_remote_tags,
failed_ref_names,
})
}
fn collect_changed_refs_to_import(
actual_git_refs: gix::reference::iter::Iter,
known_git_refs: &mut HashMap<&GitRefName, &RefTarget>,
known_remote_refs: &mut HashMap<RemoteRefKey<'_>, &RemoteRef>,
changed_git_refs: &mut Vec<(GitRefNameBuf, RefTarget)>,
changed_remote_refs: &mut Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
failed_ref_names: &mut Vec<BString>,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<(), GitImportError> {
for git_ref in actual_git_refs {
let git_ref = git_ref.map_err(GitImportError::from_git)?;
let full_name_bytes = git_ref.name().as_bstr();
let Ok(full_name) = str::from_utf8(full_name_bytes) else {
failed_ref_names.push(full_name_bytes.to_owned());
continue;
};
if full_name.starts_with(RESERVED_REMOTE_REF_NAMESPACE) {
failed_ref_names.push(full_name_bytes.to_owned());
continue;
}
let full_name = GitRefName::new(full_name);
let Some((kind, symbol)) = parse_git_ref(full_name) else {
continue;
};
if !git_ref_filter(kind, symbol) {
continue;
}
let old_git_target = known_git_refs.get(full_name).copied().flatten();
let old_git_oid = old_git_target
.as_normal()
.map(|id| gix::oid::from_bytes_unchecked(id.as_bytes()));
let Some(oid) = resolve_git_ref_to_commit_id(&git_ref, old_git_oid) else {
continue;
};
let new_target = RefTarget::normal(CommitId::from_bytes(oid.as_bytes()));
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_ref = known_remote_refs
.remove(&symbol)
.unwrap_or_else(|| RemoteRef::absent_ref());
if new_target != old_remote_ref.target {
changed_remote_refs.push((symbol.to_owned(), (old_remote_ref.clone(), new_target)));
}
}
Ok(())
}
fn collect_changed_remote_tags_to_import(
actual_git_refs: gix::reference::iter::Iter,
known_remote_refs: &mut HashMap<RemoteRefKey<'_>, &RemoteRef>,
changed_remote_refs: &mut Vec<(RemoteRefSymbolBuf, (RemoteRef, RefTarget))>,
failed_ref_names: &mut Vec<BString>,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<(), GitImportError> {
for git_ref in actual_git_refs {
let git_ref = git_ref.map_err(GitImportError::from_git)?;
let full_name_bytes = git_ref.name().as_bstr();
let Ok(full_name) = str::from_utf8(full_name_bytes) else {
failed_ref_names.push(full_name_bytes.to_owned());
continue;
};
let full_name = GitRefName::new(full_name);
let Some((kind, symbol)) = parse_remote_tag_ref(full_name) else {
continue;
};
if !git_ref_filter(kind, symbol) {
continue;
}
let old_remote_ref = known_remote_refs
.get(&symbol)
.copied()
.unwrap_or_else(|| RemoteRef::absent_ref());
let old_git_oid = old_remote_ref
.target
.as_normal()
.map(|id| gix::oid::from_bytes_unchecked(id.as_bytes()));
let Some(oid) = resolve_git_ref_to_commit_id(&git_ref, old_git_oid) else {
continue;
};
let new_target = RefTarget::normal(CommitId::from_bytes(oid.as_bytes()));
known_remote_refs.remove(&symbol);
if new_target != old_remote_ref.target {
changed_remote_refs.push((symbol.to_owned(), (old_remote_ref.clone(), new_target)));
}
}
Ok(())
}
fn default_remote_ref_state_for(
kind: GitRefKind,
symbol: RemoteRefSymbol<'_>,
options: &GitImportOptions,
) -> RemoteRefState {
match kind {
GitRefKind::Bookmark => {
if symbol.remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
|| options.auto_local_bookmark
|| options
.remote_auto_track_bookmarks
.get(symbol.remote)
.is_some_and(|matcher| matcher.is_match(symbol.name.as_str()))
{
RemoteRefState::Tracked
} else {
RemoteRefState::New
}
}
GitRefKind::Tag => RemoteRefState::Tracked,
}
}
fn pinned_commit_ids(view: &View) -> Vec<CommitId> {
itertools::chain(view.local_bookmarks(), view.local_tags())
.flat_map(|(_, target)| target.added_ids())
.cloned()
.collect()
}
fn remotely_pinned_commit_ids(view: &View) -> Vec<CommitId> {
itertools::chain(view.all_remote_bookmarks(), view.all_remote_tags())
.filter(|(_, remote_ref)| !remote_ref.is_tracked())
.map(|(_, remote_ref)| &remote_ref.target)
.flat_map(|target| target.added_ids())
.cloned()
.collect()
}
pub async 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,
}
})?;
}
let commit = store
.get_commit_async(head_id)
.await
.map_err(GitImportError::Backend)?;
mut_repo
.add_head(&commit)
.await
.map_err(GitImportError::Backend)?;
}
mut_repo.set_git_head_target(RefTarget::resolved(new_git_head_id));
Ok(())
}
#[derive(Error, Debug)]
pub enum GitExportError {
#[error(transparent)]
Git(Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
impl GitExportError {
fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self::Git(source.into())
}
}
#[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<dyn std::error::Error + Send + Sync>),
#[error("Failed to set")]
FailedToSet(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[derive(Debug)]
pub struct GitExportStats {
pub failed_bookmarks: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
pub failed_tags: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
}
#[derive(Debug)]
struct AllRefsToExport {
bookmarks: RefsToExport,
tags: RefsToExport,
}
#[derive(Debug)]
struct RefsToExport {
to_update: Vec<(RemoteRefSymbolBuf, (Option<gix::ObjectId>, gix::ObjectId))>,
to_delete: Vec<(RemoteRefSymbolBuf, gix::ObjectId)>,
failed: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
}
pub fn export_refs(mut_repo: &mut MutableRepo) -> Result<GitExportStats, GitExportError> {
export_some_refs(mut_repo, |_, _| true)
}
pub fn export_some_refs(
mut_repo: &mut MutableRepo,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<GitExportStats, GitExportError> {
fn get<'a, V>(map: &'a [(RemoteRefSymbolBuf, V)], key: RemoteRefSymbol<'_>) -> Option<&'a V> {
debug_assert!(map.is_sorted_by_key(|(k, _)| k));
let index = map.binary_search_by_key(&key, |(k, _)| k.as_ref()).ok()?;
let (_, value) = &map[index];
Some(value)
}
let AllRefsToExport { bookmarks, tags } = diff_refs_to_export(
mut_repo.view(),
mut_repo.store().root_commit_id(),
&git_ref_filter,
);
let check_and_detach_head = |git_repo: &gix::Repository| -> Result<(), GitExportError> {
let Ok(head_ref) = git_repo.find_reference("HEAD") else {
return Ok(());
};
let target_name = head_ref.target().try_name().map(|name| name.to_owned());
if let Some((kind, symbol)) = target_name
.as_ref()
.and_then(|name| str::from_utf8(name.as_bstr()).ok())
.and_then(|name| parse_git_ref(name.as_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 refs = match kind {
GitRefKind::Bookmark => &bookmarks,
GitRefKind::Tag => &tags,
};
let new_oid = if let Some((_old_oid, new_oid)) = get(&refs.to_update, symbol) {
Some(new_oid)
} else if get(&refs.to_delete, symbol).is_some() {
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,
)
.map_err(GitExportError::from_git)?;
}
}
Ok(())
};
let git_repo = get_git_repo(mut_repo.store())?;
check_and_detach_head(&git_repo)?;
for worktree in git_repo.worktrees().map_err(GitExportError::from_git)? {
if let Ok(worktree_repo) = worktree.into_repo_with_possibly_inaccessible_worktree() {
check_and_detach_head(&worktree_repo)?;
}
}
let failed_bookmarks = export_refs_to_git(mut_repo, &git_repo, GitRefKind::Bookmark, bookmarks);
let failed_tags = export_refs_to_git(mut_repo, &git_repo, GitRefKind::Tag, tags);
copy_exportable_local_bookmarks_to_remote_view(
mut_repo,
REMOTE_NAME_FOR_LOCAL_GIT_REPO,
|name| {
let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
git_ref_filter(GitRefKind::Bookmark, symbol) && get(&failed_bookmarks, symbol).is_none()
},
);
copy_exportable_local_tags_to_remote_view(mut_repo, REMOTE_NAME_FOR_LOCAL_GIT_REPO, |name| {
let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
git_ref_filter(GitRefKind::Tag, symbol) && get(&failed_tags, symbol).is_none()
});
Ok(GitExportStats {
failed_bookmarks,
failed_tags,
})
}
fn export_refs_to_git(
mut_repo: &mut MutableRepo,
git_repo: &gix::Repository,
kind: GitRefKind,
refs: RefsToExport,
) -> Vec<(RemoteRefSymbolBuf, FailedRefExportReason)> {
let mut failed = refs.failed;
for (symbol, old_oid) in refs.to_delete {
let Some(git_ref_name) = to_git_ref_name(kind, symbol.as_ref()) else {
failed.push((symbol, FailedRefExportReason::InvalidGitName));
continue;
};
if let Err(reason) = delete_git_ref(git_repo, &git_ref_name, &old_oid) {
failed.push((symbol, reason));
} else {
let new_target = RefTarget::absent();
mut_repo.set_git_ref_target(&git_ref_name, new_target);
}
}
for (symbol, (old_commit_oid, new_commit_oid)) in refs.to_update {
let Some(git_ref_name) = to_git_ref_name(kind, symbol.as_ref()) else {
failed.push((symbol, FailedRefExportReason::InvalidGitName));
continue;
};
let new_ref_oid = match kind {
GitRefKind::Bookmark => None,
GitRefKind::Tag => {
find_git_tag_oid_to_copy(mut_repo.view(), git_repo, &symbol.name, &new_commit_oid)
}
};
if let Err(reason) = update_git_ref(
git_repo,
&git_ref_name,
old_commit_oid,
new_commit_oid,
new_ref_oid,
) {
failed.push((symbol, reason));
} else {
let new_target = RefTarget::normal(CommitId::from_bytes(new_commit_oid.as_bytes()));
mut_repo.set_git_ref_target(&git_ref_name, new_target);
}
}
failed.sort_unstable_by(|(name1, _), (name2, _)| name1.cmp(name2));
failed
}
fn copy_exportable_local_bookmarks_to_remote_view(
mut_repo: &mut MutableRepo,
remote: &RemoteName,
name_filter: impl Fn(&RefName) -> bool,
) {
let new_local_bookmarks = mut_repo
.view()
.local_remote_bookmarks(remote)
.filter_map(|(name, targets)| {
let old_target = &targets.remote_ref.target;
let new_target = targets.local_target;
(!new_target.has_conflict() && old_target != new_target).then_some((name, new_target))
})
.filter(|&(name, _)| name_filter(name))
.map(|(name, new_target)| (name.to_owned(), new_target.clone()))
.collect_vec();
for (name, new_target) in new_local_bookmarks {
let new_remote_ref = RemoteRef {
target: new_target,
state: RemoteRefState::Tracked,
};
mut_repo.set_remote_bookmark(name.to_remote_symbol(remote), new_remote_ref);
}
}
fn copy_exportable_local_tags_to_remote_view(
mut_repo: &mut MutableRepo,
remote: &RemoteName,
name_filter: impl Fn(&RefName) -> bool,
) {
let new_local_tags = mut_repo
.view()
.local_remote_tags(remote)
.filter_map(|(name, targets)| {
let old_target = &targets.remote_ref.target;
let new_target = targets.local_target;
(!new_target.has_conflict() && old_target != new_target).then_some((name, new_target))
})
.filter(|&(name, _)| name_filter(name))
.map(|(name, new_target)| (name.to_owned(), new_target.clone()))
.collect_vec();
for (name, new_target) in new_local_tags {
let new_remote_ref = RemoteRef {
target: new_target,
state: RemoteRefState::Tracked,
};
mut_repo.set_remote_tag(name.to_remote_symbol(remote), new_remote_ref);
}
}
fn diff_refs_to_export(
view: &View,
root_commit_id: &CommitId,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> AllRefsToExport {
let mut all_bookmark_targets: HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)> =
itertools::chain(
view.local_bookmarks().map(|(name, target)| {
let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
(symbol, target)
}),
view.all_remote_bookmarks()
.filter(|&(symbol, _)| symbol.remote != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
.map(|(symbol, remote_ref)| (symbol, &remote_ref.target)),
)
.filter(|&(symbol, _)| git_ref_filter(GitRefKind::Bookmark, symbol))
.map(|(symbol, new_target)| (symbol, (RefTarget::absent_ref(), new_target)))
.collect();
let mut all_tag_targets: HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)> = view
.local_tags()
.map(|(name, target)| {
let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
(symbol, target)
})
.filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
.map(|(symbol, new_target)| (symbol, (RefTarget::absent_ref(), new_target)))
.collect();
let known_git_refs = view
.git_refs()
.iter()
.map(|(full_name, target)| {
let (kind, symbol) =
parse_git_ref(full_name).expect("stored git ref should be parsable");
((kind, symbol), target)
})
.filter(|&((kind, symbol), _)| git_ref_filter(kind, symbol));
for ((kind, symbol), target) in known_git_refs {
let ref_targets = match kind {
GitRefKind::Bookmark => &mut all_bookmark_targets,
GitRefKind::Tag => &mut all_tag_targets,
};
ref_targets
.entry(symbol)
.and_modify(|(old_target, _)| *old_target = target)
.or_insert((target, RefTarget::absent_ref()));
}
let root_commit_target = RefTarget::normal(root_commit_id.clone());
let bookmarks = collect_changed_refs_to_export(&all_bookmark_targets, &root_commit_target);
let tags = collect_changed_refs_to_export(&all_tag_targets, &root_commit_target);
AllRefsToExport { bookmarks, tags }
}
fn collect_changed_refs_to_export(
old_new_ref_targets: &HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)>,
root_commit_target: &RefTarget,
) -> RefsToExport {
let mut to_update = Vec::new();
let mut to_delete = Vec::new();
let mut failed = Vec::new();
for (&symbol, &(old_target, new_target)) in old_new_ref_targets {
if new_target == old_target {
continue;
}
if new_target == root_commit_target {
failed.push((symbol.to_owned(), FailedRefExportReason::OnRootCommit));
continue;
}
let old_oid = if let Some(id) = old_target.as_normal() {
Some(gix::ObjectId::from_bytes_or_panic(id.as_bytes()))
} else if old_target.has_conflict() {
failed.push((symbol.to_owned(), FailedRefExportReason::ConflictedOldState));
continue;
} else {
assert!(old_target.is_absent());
None
};
if let Some(id) = new_target.as_normal() {
let new_oid = gix::ObjectId::from_bytes_or_panic(id.as_bytes());
to_update.push((symbol.to_owned(), (old_oid, new_oid)));
} else if new_target.has_conflict() {
continue;
} else {
assert!(new_target.is_absent());
to_delete.push((symbol.to_owned(), old_oid.unwrap()));
}
}
to_update.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
to_delete.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
failed.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
RefsToExport {
to_update,
to_delete,
failed,
}
}
fn find_git_tag_oid_to_copy(
view: &View,
git_repo: &gix::Repository,
name: &RefName,
commit_oid: &gix::oid,
) -> Option<gix::ObjectId> {
view.remote_tags_matching(&StringMatcher::exact(name), &StringMatcher::all())
.filter(|(_, remote_ref)| {
let maybe_id = remote_ref.tracked_target().as_normal();
maybe_id.is_some_and(|id| id.as_bytes() == commit_oid.as_bytes())
})
.filter_map(|(symbol, _)| {
let git_ref_name = to_remote_tag_ref_name(symbol)?;
git_repo.find_reference(git_ref_name.as_str()).ok()
})
.filter(|git_ref| {
resolve_git_ref_to_commit_id(git_ref, Some(commit_oid)).as_deref() == Some(commit_oid)
})
.find_map(|git_ref| git_ref.inner.target.try_into_id().ok())
}
fn delete_git_ref(
git_repo: &gix::Repository,
git_ref_name: &GitRefName,
old_oid: &gix::oid,
) -> Result<(), FailedRefExportReason> {
let Some(git_ref) = git_repo
.try_find_reference(git_ref_name.as_str())
.map_err(|err| FailedRefExportReason::FailedToDelete(err.into()))?
else {
return Ok(());
};
if resolve_git_ref_to_commit_id(&git_ref, Some(old_oid)).as_deref() == Some(old_oid) {
git_ref
.delete()
.map_err(|err| FailedRefExportReason::FailedToDelete(err.into()))
} else {
Err(FailedRefExportReason::DeletedInJjModifiedInGit)
}
}
fn create_git_ref(
git_repo: &gix::Repository,
git_ref_name: &GitRefName,
new_commit_oid: gix::ObjectId,
new_ref_oid: Option<gix::ObjectId>,
) -> Result<(), FailedRefExportReason> {
let new_oid = new_ref_oid.unwrap_or(new_commit_oid);
let constraint = gix::refs::transaction::PreviousValue::MustNotExist;
let Err(set_err) =
git_repo.reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
else {
return Ok(());
};
let Some(git_ref) = git_repo
.try_find_reference(git_ref_name.as_str())
.map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?
else {
return Err(FailedRefExportReason::FailedToSet(set_err.into()));
};
if resolve_git_ref_to_commit_id(&git_ref, None) == Some(new_commit_oid) {
Ok(())
} else {
Err(FailedRefExportReason::AddedInJjAddedInGit)
}
}
fn move_git_ref(
git_repo: &gix::Repository,
git_ref_name: &GitRefName,
old_commit_oid: gix::ObjectId,
new_commit_oid: gix::ObjectId,
new_ref_oid: Option<gix::ObjectId>,
) -> Result<(), FailedRefExportReason> {
let new_oid = new_ref_oid.unwrap_or(new_commit_oid);
let constraint =
gix::refs::transaction::PreviousValue::MustExistAndMatch(old_commit_oid.into());
let Err(set_err) =
git_repo.reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
else {
return Ok(());
};
let Some(git_ref) = git_repo
.try_find_reference(git_ref_name.as_str())
.map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?
else {
return Err(FailedRefExportReason::ModifiedInJjDeletedInGit);
};
let git_commit_oid = resolve_git_ref_to_commit_id(&git_ref, Some(&old_commit_oid));
if git_commit_oid == Some(new_commit_oid) {
Ok(())
} else if git_commit_oid == Some(old_commit_oid) {
let constraint =
gix::refs::transaction::PreviousValue::MustExistAndMatch(git_ref.inner.target);
git_repo
.reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
.map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?;
Ok(())
} else {
Err(FailedRefExportReason::FailedToSet(set_err.into()))
}
}
fn update_git_ref(
git_repo: &gix::Repository,
git_ref_name: &GitRefName,
old_commit_oid: Option<gix::ObjectId>,
new_commit_oid: gix::ObjectId,
new_ref_oid: Option<gix::ObjectId>,
) -> Result<(), FailedRefExportReason> {
match old_commit_oid {
None => create_git_ref(git_repo, git_ref_name, new_commit_oid, new_ref_oid),
Some(old_oid) => move_git_ref(git_repo, git_ref_name, old_oid, new_commit_oid, new_ref_oid),
}
}
fn update_git_head(
git_repo: &gix::Repository,
expected_ref: gix::refs::transaction::PreviousValue,
new_oid: Option<gix::ObjectId>,
) -> Result<(), gix::reference::edit::Error> {
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)?;
Ok(())
}
#[derive(Debug, Error)]
pub enum GitResetHeadError {
#[error(transparent)]
Backend(#[from] BackendError),
#[error(transparent)]
Git(Box<dyn std::error::Error + Send + Sync>),
#[error("Failed to update Git HEAD ref")]
UpdateHeadRef(#[source] Box<gix::reference::edit::Error>),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
impl GitResetHeadError {
fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self::Git(source.into())
}
}
pub async fn reset_head(
mut_repo: &mut MutableRepo,
wc_commit: &Commit,
) -> Result<(), GitResetHeadError> {
let git_repo = get_git_repo(mut_repo.store())?;
let first_parent_id = &wc_commit.parent_ids()[0];
let new_head_target = if first_parent_id != mut_repo.store().root_commit_id() {
RefTarget::normal(first_parent_id.clone())
} else {
RefTarget::absent()
};
let old_head_target = mut_repo.git_head();
if old_head_target != new_head_target {
let expected_ref = if let Some(id) = old_head_target.as_normal() {
let actual_head = git_repo.head().map_err(GitResetHeadError::from_git)?;
if actual_head.is_detached() {
let id = gix::ObjectId::from_bytes_or_panic(id.as_bytes());
gix::refs::transaction::PreviousValue::MustExistAndMatch(id.into())
} else {
gix::refs::transaction::PreviousValue::MustExist
}
} else {
gix::refs::transaction::PreviousValue::MustExist
};
let new_oid = new_head_target
.as_normal()
.map(|id| gix::ObjectId::from_bytes_or_panic(id.as_bytes()));
update_git_head(&git_repo, expected_ref, new_oid)
.map_err(|err| GitResetHeadError::UpdateHeadRef(err.into()))?;
mut_repo.set_git_head_target(new_head_target);
}
if git_repo.state().is_some() {
clear_operation_state(&git_repo)?;
}
reset_index(mut_repo, &git_repo, wc_commit).await
}
fn clear_operation_state(git_repo: &gix::Repository) -> Result<(), GitResetHeadError> {
const STATE_FILE_NAMES: &[&str] = &[
"MERGE_HEAD",
"MERGE_MODE",
"MERGE_MSG",
"REVERT_HEAD",
"CHERRY_PICK_HEAD",
"BISECT_LOG",
];
const STATE_DIR_NAMES: &[&str] = &["rebase-merge", "rebase-apply", "sequencer"];
let handle_err = |err: PathError| match err.source.kind() {
std::io::ErrorKind::NotFound => Ok(()),
_ => Err(GitResetHeadError::from_git(err)),
};
for file_name in STATE_FILE_NAMES {
let path = git_repo.path().join(file_name);
std::fs::remove_file(&path)
.context(&path)
.or_else(handle_err)?;
}
for dir_name in STATE_DIR_NAMES {
let path = git_repo.path().join(dir_name);
std::fs::remove_dir_all(&path)
.context(&path)
.or_else(handle_err)?;
}
Ok(())
}
async fn reset_index(
repo: &dyn Repo,
git_repo: &gix::Repository,
wc_commit: &Commit,
) -> Result<(), GitResetHeadError> {
let parent_tree = wc_commit.parent_tree(repo).await?;
let mut index = if let Some(tree_id) = parent_tree.tree_ids().as_resolved() {
if tree_id == 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(GitResetHeadError::from_git)?
}
} else {
build_index_from_merged_tree(git_repo, &parent_tree)?
};
let wc_tree = wc_commit.tree();
update_intent_to_add_impl(git_repo, &mut index, &parent_tree, &wc_tree).await?;
if let Some(old_index) = git_repo.try_index().map_err(GitResetHeadError::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(GitResetHeadError::from_git)
}
fn build_index_from_merged_tree(
git_repo: &gix::Repository,
merged_tree: &MergedTree,
) -> Result<gix::index::File, GitResetHeadError> {
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,
copy_id: _,
} => {
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),
};
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(GitResetHeadError::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)
}
pub async fn update_intent_to_add(
repo: &dyn Repo,
old_tree: &MergedTree,
new_tree: &MergedTree,
) -> Result<(), GitResetHeadError> {
let git_repo = get_git_repo(repo.store())?;
let mut index = git_repo
.index_or_empty()
.map_err(GitResetHeadError::from_git)?;
let mut_index = Arc::make_mut(&mut index);
update_intent_to_add_impl(&git_repo, mut_index, old_tree, new_tree).await?;
debug_assert!(mut_index.verify_entries().is_ok());
mut_index
.write(gix::index::write::Options::default())
.map_err(GitResetHeadError::from_git)?;
Ok(())
}
async fn update_intent_to_add_impl(
git_repo: &gix::Repository,
index: &mut gix::index::File,
old_tree: &MergedTree,
new_tree: &MergedTree,
) -> Result<(), GitResetHeadError> {
let mut diff_stream = old_tree.diff_stream(new_tree, &EverythingMatcher);
let mut added_paths = vec![];
let mut removed_paths = HashSet::new();
while let Some(TreeDiffEntry { path, values }) = diff_stream.next().await {
let values = values?;
if values.before.is_absent() {
let executable = match values.after.as_normal() {
Some(TreeValue::File {
id: _,
executable,
copy_id: _,
}) => *executable,
Some(TreeValue::Symlink(_)) => false,
_ => {
continue;
}
};
if index
.entry_index_by_path(BStr::new(path.as_internal_file_string()))
.is_err()
{
added_paths.push((BString::from(path.into_internal_string()), executable));
}
} else if values.after.is_absent() {
removed_paths.insert(BString::from(path.into_internal_string()));
}
}
if added_paths.is_empty() && removed_paths.is_empty() {
return Ok(());
}
if !added_paths.is_empty() {
let empty_blob = git_repo
.write_blob(b"")
.map_err(GitResetHeadError::from_git)?
.detach();
for (path, executable) in added_paths {
index.dangerously_push_entry(
gix::index::entry::Stat::default(),
empty_blob,
gix::index::entry::Flags::INTENT_TO_ADD | gix::index::entry::Flags::EXTENDED,
if executable {
gix::index::entry::Mode::FILE_EXECUTABLE
} else {
gix::index::entry::Mode::FILE
},
path.as_ref(),
);
}
}
if !removed_paths.is_empty() {
index.remove_entries(|_size, path, entry| {
entry
.flags
.contains(gix::index::entry::Flags::INTENT_TO_ADD)
&& removed_paths.contains(path)
});
}
index.sort_entries();
Ok(())
}
#[derive(Debug, Error)]
pub enum GitRemoteManagementError {
#[error("No git remote named '{}'", .0.as_symbol())]
NoSuchRemote(RemoteNameBuf),
#[error("Git remote named '{}' already exists", .0.as_symbol())]
RemoteAlreadyExists(RemoteNameBuf),
#[error(transparent)]
RemoteName(#[from] GitRemoteNameError),
#[error("Git remote named '{}' has nonstandard configuration", .0.as_symbol())]
NonstandardConfiguration(RemoteNameBuf),
#[error("Error saving Git configuration")]
GitConfigSaveError(#[source] std::io::Error),
#[error("Unexpected Git error when managing remotes")]
InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
#[error(transparent)]
RefExpansionError(#[from] GitRefExpansionError),
}
impl GitRemoteManagementError {
fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self::InternalGitError(source.into())
}
}
fn default_fetch_refspec(remote: &RemoteName) -> String {
format!(
"+refs/heads/*:{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/*",
remote = remote.as_str()
)
}
fn add_ref(
name: gix::refs::FullName,
target: gix::refs::Target,
message: BString,
) -> gix::refs::transaction::RefEdit {
gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Update {
log: gix::refs::transaction::LogChange {
mode: gix::refs::transaction::RefLog::AndReference,
force_create_reflog: false,
message,
},
expected: gix::refs::transaction::PreviousValue::MustNotExist,
new: target,
},
name,
deref: false,
}
}
fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
gix::refs::transaction::RefEdit {
change: gix::refs::transaction::Change::Delete {
expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
reference.target().into_owned(),
),
log: gix::refs::transaction::RefLog::AndReference,
},
name: reference.name().to_owned(),
deref: false,
}
}
pub fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
let mut config_file = File::create(
config
.meta()
.path
.as_ref()
.expect("Git repository to have a config file"),
)?;
config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
}
fn save_remote(
config: &mut gix::config::File<'static>,
remote_name: &RemoteName,
remote: &mut gix::Remote,
) -> Result<(), GitRemoteManagementError> {
config
.new_section(
"remote",
Some(Cow::Owned(BString::from(remote_name.as_str()))),
)
.map_err(GitRemoteManagementError::from_git)?;
remote
.save_as_to(remote_name.as_str(), config)
.map_err(GitRemoteManagementError::from_git)?;
Ok(())
}
fn git_config_branch_section_ids_by_remote(
config: &gix::config::File,
remote_name: &RemoteName,
) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
config
.sections_by_name("branch")
.into_iter()
.flatten()
.filter_map(|section| {
let remote_values = section.values("remote");
let push_remote_values = section.values("pushRemote");
if !remote_values
.iter()
.chain(push_remote_values.iter())
.any(|branch_remote_name| **branch_remote_name == remote_name.as_str())
{
return None;
}
if remote_values.len() > 1
|| push_remote_values.len() > 1
|| section.value_names().any(|name| {
!name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
})
{
return Some(Err(GitRemoteManagementError::NonstandardConfiguration(
remote_name.to_owned(),
)));
}
Some(Ok(section.id()))
})
.collect()
}
fn rename_remote_in_git_branch_config_sections(
config: &mut gix::config::File,
old_remote_name: &RemoteName,
new_remote_name: &RemoteName,
) -> Result<(), GitRemoteManagementError> {
for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
config
.section_mut_by_id(id)
.expect("found section to exist")
.set(
"remote"
.try_into()
.expect("'remote' to be a valid value name"),
BStr::new(new_remote_name.as_str()),
);
}
Ok(())
}
fn remove_remote_git_branch_config_sections(
config: &mut gix::config::File,
remote_name: &RemoteName,
) -> Result<(), GitRemoteManagementError> {
for id in git_config_branch_section_ids_by_remote(config, remote_name)? {
config
.remove_section_by_id(id)
.expect("removed section to exist");
}
Ok(())
}
fn remove_remote_git_config_sections(
config: &mut gix::config::File,
remote_name: &RemoteName,
) -> Result<(), GitRemoteManagementError> {
let section_ids_to_remove: Vec<_> = config
.sections_by_name("remote")
.into_iter()
.flatten()
.filter(|section| {
section.header().subsection_name() == Some(BStr::new(remote_name.as_str()))
})
.map(|section| {
if section.value_names().any(|name| {
!name.eq_ignore_ascii_case(b"url")
&& !name.eq_ignore_ascii_case(b"fetch")
&& !name.eq_ignore_ascii_case(b"tagOpt")
}) {
return Err(GitRemoteManagementError::NonstandardConfiguration(
remote_name.to_owned(),
));
}
Ok(section.id())
})
.try_collect()?;
for id in section_ids_to_remove {
config
.remove_section_by_id(id)
.expect("removed section to exist");
}
Ok(())
}
pub fn get_all_remote_names(
store: &Store,
) -> Result<Vec<RemoteNameBuf>, UnexpectedGitBackendError> {
let git_repo = get_git_repo(store)?;
Ok(iter_remote_names(&git_repo).collect())
}
fn iter_remote_names(git_repo: &gix::Repository) -> impl Iterator<Item = RemoteNameBuf> {
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())
.map(RemoteNameBuf::from)
}
pub fn add_remote(
mut_repo: &mut MutableRepo,
remote_name: &RemoteName,
url: &str,
push_url: Option<&str>,
fetch_tags: gix::remote::fetch::Tags,
bookmark_expr: &StringExpression,
) -> Result<(), GitRemoteManagementError> {
let git_repo = get_git_repo(mut_repo.store())?;
validate_remote_name(remote_name)?;
if git_repo.try_find_remote(remote_name.as_str()).is_some() {
return Err(GitRemoteManagementError::RemoteAlreadyExists(
remote_name.to_owned(),
));
}
let ref_expr = GitFetchRefExpression {
bookmark: bookmark_expr.clone(),
tag: StringExpression::none(),
};
let ExpandedFetchRefSpecs {
expr: _,
refspecs,
negative_refspecs,
} = expand_fetch_refspecs(remote_name, ref_expr)?;
let fetch_refspecs = itertools::chain(
refspecs.iter().map(|spec| spec.to_git_format()),
negative_refspecs.iter().map(|spec| spec.to_git_format()),
)
.map(BString::from);
let mut remote = git_repo
.remote_at(url)
.map_err(GitRemoteManagementError::from_git)?
.with_fetch_tags(fetch_tags)
.with_refspecs(fetch_refspecs, gix::remote::Direction::Fetch)
.expect("previously-parsed refspecs to be valid");
if let Some(push_url) = push_url {
remote = remote
.with_push_url(push_url)
.map_err(GitRemoteManagementError::from_git)?;
}
let mut config = git_repo.config_snapshot().clone();
save_remote(&mut config, remote_name, &mut remote)?;
save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
mut_repo.ensure_remote(remote_name);
Ok(())
}
pub fn remove_remote(
mut_repo: &mut MutableRepo,
remote_name: &RemoteName,
) -> Result<(), GitRemoteManagementError> {
let mut git_repo = get_git_repo(mut_repo.store())?;
if git_repo.try_find_remote(remote_name.as_str()).is_none() {
return Err(GitRemoteManagementError::NoSuchRemote(
remote_name.to_owned(),
));
}
let mut config = git_repo.config_snapshot().clone();
remove_remote_git_branch_config_sections(&mut config, remote_name)?;
remove_remote_git_config_sections(&mut config, remote_name)?;
save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
remove_remote_git_refs(&mut git_repo, remote_name)
.map_err(GitRemoteManagementError::from_git)?;
if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
remove_remote_refs(mut_repo, remote_name);
}
Ok(())
}
fn remove_remote_git_refs(
git_repo: &mut gix::Repository,
remote: &RemoteName,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let bookmark_prefix = format!(
"{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
remote = remote.as_str()
);
let tag_prefix = format!(
"{REMOTE_TAG_REF_NAMESPACE}{remote}/",
remote = remote.as_str()
);
let edits: Vec<_> = itertools::chain(
git_repo
.references()?
.prefixed(bookmark_prefix.as_str())?
.map_ok(remove_ref),
git_repo
.references()?
.prefixed(tag_prefix.as_str())?
.map_ok(remove_ref),
)
.try_collect()?;
git_repo.edit_references(edits)?;
Ok(())
}
fn remove_remote_refs(mut_repo: &mut MutableRepo, remote: &RemoteName) {
mut_repo.remove_remote(remote);
let prefix = format!(
"{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
remote = remote.as_str()
);
let git_refs_to_delete = mut_repo
.view()
.git_refs()
.keys()
.filter(|&r| r.as_str().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,
old_remote_name: &RemoteName,
new_remote_name: &RemoteName,
) -> Result<(), GitRemoteManagementError> {
let mut git_repo = get_git_repo(mut_repo.store())?;
validate_remote_name(new_remote_name)?;
let Some(result) = git_repo.try_find_remote(old_remote_name.as_str()) else {
return Err(GitRemoteManagementError::NoSuchRemote(
old_remote_name.to_owned(),
));
};
let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
if git_repo.try_find_remote(new_remote_name.as_str()).is_some() {
return Err(GitRemoteManagementError::RemoteAlreadyExists(
new_remote_name.to_owned(),
));
}
match (
remote.refspecs(gix::remote::Direction::Fetch),
remote.refspecs(gix::remote::Direction::Push),
) {
([refspec], [])
if refspec.to_ref().to_bstring()
== default_fetch_refspec(old_remote_name).as_bytes() => {}
_ => {
return Err(GitRemoteManagementError::NonstandardConfiguration(
old_remote_name.to_owned(),
));
}
}
remote
.replace_refspecs(
[default_fetch_refspec(new_remote_name).as_bytes()],
gix::remote::Direction::Fetch,
)
.expect("default refspec to be valid");
let mut config = git_repo.config_snapshot().clone();
save_remote(&mut config, new_remote_name, &mut remote)?;
rename_remote_in_git_branch_config_sections(&mut config, old_remote_name, new_remote_name)?;
remove_remote_git_config_sections(&mut config, old_remote_name)?;
save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
.map_err(GitRemoteManagementError::from_git)?;
if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
}
Ok(())
}
fn rename_remote_git_refs(
git_repo: &mut gix::Repository,
old_remote_name: &RemoteName,
new_remote_name: &RemoteName,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let to_prefixes = |namespace: &str| {
(
format!("{namespace}{remote}/", remote = old_remote_name.as_str()),
format!("{namespace}{remote}/", remote = new_remote_name.as_str()),
)
};
let to_rename_edits = {
let ref_log_message = BString::from(format!(
"renamed remote {old_remote_name} to {new_remote_name}",
old_remote_name = old_remote_name.as_symbol(),
new_remote_name = new_remote_name.as_symbol(),
));
move |old_prefix: &str, new_prefix: &str, old_ref: gix::Reference| {
let new_name = BString::new(
[
new_prefix.as_bytes(),
&old_ref.name().as_bstr()[old_prefix.len()..],
]
.concat(),
);
[
add_ref(
new_name.try_into().expect("new ref name to be valid"),
old_ref.target().into_owned(),
ref_log_message.clone(),
),
remove_ref(old_ref),
]
}
};
let (old_bookmark_prefix, new_bookmark_prefix) = to_prefixes(REMOTE_BOOKMARK_REF_NAMESPACE);
let (old_tag_prefix, new_tag_prefix) = to_prefixes(REMOTE_TAG_REF_NAMESPACE);
let edits: Vec<_> = itertools::chain(
git_repo
.references()?
.prefixed(old_bookmark_prefix.as_str())?
.map_ok(|old_ref| to_rename_edits(&old_bookmark_prefix, &new_bookmark_prefix, old_ref)),
git_repo
.references()?
.prefixed(old_tag_prefix.as_str())?
.map_ok(|old_ref| to_rename_edits(&old_tag_prefix, &new_tag_prefix, old_ref)),
)
.flatten_ok()
.try_collect()?;
git_repo.edit_references(edits)?;
Ok(())
}
pub fn set_remote_urls(
store: &Store,
remote_name: &RemoteName,
new_url: Option<&str>,
new_push_url: Option<&str>,
) -> Result<(), GitRemoteManagementError> {
if new_url.is_none() && new_push_url.is_none() {
return Ok(());
}
let git_repo = get_git_repo(store)?;
validate_remote_name(remote_name)?;
let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name.as_str()) else {
return Err(GitRemoteManagementError::NoSuchRemote(
remote_name.to_owned(),
));
};
let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
if let Some(url) = new_url {
remote = remote
.with_url(url)
.map_err(GitRemoteManagementError::from_git)?;
}
if let Some(url) = new_push_url {
remote = remote
.with_push_url(url)
.map_err(GitRemoteManagementError::from_git)?;
}
let mut config = git_repo.config_snapshot().clone();
save_remote(&mut config, remote_name, &mut remote)?;
save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
Ok(())
}
fn rename_remote_refs(
mut_repo: &mut MutableRepo,
old_remote_name: &RemoteName,
new_remote_name: &RemoteName,
) {
mut_repo.rename_remote(old_remote_name.as_ref(), new_remote_name.as_ref());
let prefix = format!(
"{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
remote = old_remote_name.as_str()
);
let git_refs = mut_repo
.view()
.git_refs()
.iter()
.filter_map(|(old, target)| {
old.as_str().strip_prefix(&prefix).map(|p| {
let new: GitRefNameBuf = format!(
"{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{p}",
remote = new_remote_name.as_str()
)
.into();
(old.clone(), new, 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.as_symbol())]
NoSuchRemote(RemoteNameBuf),
#[error(transparent)]
RemoteName(#[from] GitRemoteNameError),
#[error("Failed to update refs: {}", .0.iter().map(|n| n.as_symbol()).join(", "))]
RejectedUpdates(Vec<GitRefNameBuf>),
#[error(transparent)]
Subprocess(#[from] GitSubprocessError),
}
#[derive(Error, Debug)]
pub enum GitDefaultRefspecError {
#[error("No git remote named '{}'", .0.as_symbol())]
NoSuchRemote(RemoteNameBuf),
#[error("Invalid configuration for remote `{}`", .0.as_symbol())]
InvalidRemoteConfiguration(RemoteNameBuf, #[source] Box<gix::remote::find::Error>),
}
struct FetchedRefs {
remote: RemoteNameBuf,
bookmark_matcher: StringMatcher,
tag_matcher: StringMatcher,
}
#[derive(Clone, Debug)]
pub struct GitFetchRefExpression {
pub bookmark: StringExpression,
pub tag: StringExpression,
}
#[derive(Debug)]
pub struct ExpandedFetchRefSpecs {
expr: GitFetchRefExpression,
refspecs: Vec<RefSpec>,
negative_refspecs: Vec<NegativeRefSpec>,
}
#[derive(Error, Debug)]
pub enum GitRefExpansionError {
#[error(transparent)]
Expression(#[from] GitRefExpressionError),
#[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),
}
pub fn expand_fetch_refspecs(
remote: &RemoteName,
expr: GitFetchRefExpression,
) -> Result<ExpandedFetchRefSpecs, GitRefExpansionError> {
let (positive_bookmarks, negative_bookmarks) =
split_into_positive_negative_patterns(&expr.bookmark)?;
let (positive_tags, negative_tags) = split_into_positive_negative_patterns(&expr.tag)?;
let refspecs = itertools::chain(
positive_bookmarks
.iter()
.map(|&pattern| pattern_to_refspec_glob(pattern))
.map_ok(|glob| {
RefSpec::forced(
format!("refs/heads/{glob}"),
format!(
"{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{glob}",
remote = remote.as_str()
),
)
}),
positive_tags
.iter()
.map(|&pattern| pattern_to_refspec_glob(pattern))
.map_ok(|glob| {
RefSpec::forced(
format!("refs/tags/{glob}"),
format!(
"{REMOTE_TAG_REF_NAMESPACE}{remote}/{glob}",
remote = remote.as_str()
),
)
}),
)
.try_collect()?;
let negative_refspecs = itertools::chain(
negative_bookmarks
.iter()
.map(|&pattern| pattern_to_refspec_glob(pattern))
.map_ok(|glob| NegativeRefSpec::new(format!("refs/heads/{glob}"))),
negative_tags
.iter()
.map(|&pattern| pattern_to_refspec_glob(pattern))
.map_ok(|glob| NegativeRefSpec::new(format!("refs/tags/{glob}"))),
)
.try_collect()?;
Ok(ExpandedFetchRefSpecs {
expr,
refspecs,
negative_refspecs,
})
}
fn pattern_to_refspec_glob(pattern: &StringPattern) -> Result<Cow<'_, str>, GitRefExpansionError> {
pattern
.to_glob()
.filter(|glob| !glob.contains(INVALID_REFSPEC_CHARS))
.ok_or_else(|| GitRefExpansionError::InvalidBranchPattern(pattern.clone()))
}
#[derive(Debug, Error)]
pub enum GitRefExpressionError {
#[error("Cannot use `~` in sub expression")]
NestedNotIn,
#[error("Cannot use `&` in sub expression")]
NestedIntersection,
#[error("Cannot use `&` for positive expressions")]
PositiveIntersection,
}
fn split_into_positive_negative_patterns(
expr: &StringExpression,
) -> Result<(Vec<&StringPattern>, Vec<&StringPattern>), GitRefExpressionError> {
static ALL: StringPattern = StringPattern::all();
fn visit_positive<'a>(
expr: &'a StringExpression,
positives: &mut Vec<&'a StringPattern>,
negatives: &mut Vec<&'a StringPattern>,
) -> Result<(), GitRefExpressionError> {
match expr {
StringExpression::Pattern(pattern) => {
positives.push(pattern);
Ok(())
}
StringExpression::NotIn(complement) => {
positives.push(&ALL);
visit_negative(complement, negatives)
}
StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, positives),
StringExpression::Intersection(expr1, expr2) => {
match (expr1.as_ref(), expr2.as_ref()) {
(other, StringExpression::NotIn(complement))
| (StringExpression::NotIn(complement), other) => {
visit_positive(other, positives, negatives)?;
visit_negative(complement, negatives)
}
_ => Err(GitRefExpressionError::PositiveIntersection),
}
}
}
}
fn visit_negative<'a>(
expr: &'a StringExpression,
negatives: &mut Vec<&'a StringPattern>,
) -> Result<(), GitRefExpressionError> {
match expr {
StringExpression::Pattern(pattern) => {
negatives.push(pattern);
Ok(())
}
StringExpression::NotIn(_) => Err(GitRefExpressionError::NestedNotIn),
StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, negatives),
StringExpression::Intersection(_, _) => Err(GitRefExpressionError::NestedIntersection),
}
}
fn visit_union<'a>(
expr1: &'a StringExpression,
expr2: &'a StringExpression,
patterns: &mut Vec<&'a StringPattern>,
) -> Result<(), GitRefExpressionError> {
visit_union_sub(expr1, patterns)?;
visit_union_sub(expr2, patterns)
}
fn visit_union_sub<'a>(
expr: &'a StringExpression,
patterns: &mut Vec<&'a StringPattern>,
) -> Result<(), GitRefExpressionError> {
match expr {
StringExpression::Pattern(pattern) => {
patterns.push(pattern);
Ok(())
}
StringExpression::NotIn(_) => Err(GitRefExpressionError::NestedNotIn),
StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, patterns),
StringExpression::Intersection(_, _) => Err(GitRefExpressionError::NestedIntersection),
}
}
let mut positives = Vec::new();
let mut negatives = Vec::new();
visit_positive(expr, &mut positives, &mut negatives)?;
if positives.iter().all(|pattern| pattern.is_all())
&& !negatives.is_empty()
&& negatives.iter().all(|pattern| pattern.is_all())
{
Ok((vec![], vec![]))
} else {
Ok((positives, negatives))
}
}
#[derive(Debug)]
#[must_use = "warnings should be surfaced in the UI"]
pub struct IgnoredRefspecs(pub Vec<IgnoredRefspec>);
#[derive(Debug)]
pub struct IgnoredRefspec {
pub refspec: BString,
pub reason: &'static str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FetchRefSpecKind {
Positive,
Negative,
}
pub fn load_default_fetch_bookmarks(
remote_name: &RemoteName,
git_repo: &gix::Repository,
) -> Result<(IgnoredRefspecs, StringExpression), GitDefaultRefspecError> {
let remote = git_repo
.try_find_remote(remote_name.as_str())
.ok_or_else(|| GitDefaultRefspecError::NoSuchRemote(remote_name.to_owned()))?
.map_err(|e| {
GitDefaultRefspecError::InvalidRemoteConfiguration(remote_name.to_owned(), Box::new(e))
})?;
let remote_refspecs = remote.refspecs(gix::remote::Direction::Fetch);
let mut ignored_refspecs = Vec::with_capacity(remote_refspecs.len());
let mut positive_bookmarks = Vec::with_capacity(remote_refspecs.len());
let mut negative_bookmarks = Vec::new();
for refspec in remote_refspecs {
let refspec = refspec.to_ref();
match parse_fetch_refspec(remote_name, refspec) {
Ok((FetchRefSpecKind::Positive, bookmark)) => {
positive_bookmarks.push(StringExpression::pattern(bookmark));
}
Ok((FetchRefSpecKind::Negative, bookmark)) => {
negative_bookmarks.push(StringExpression::pattern(bookmark));
}
Err(reason) => {
let refspec = refspec.to_bstring();
ignored_refspecs.push(IgnoredRefspec { refspec, reason });
}
}
}
let mut bookmark_expr = StringExpression::union_all(positive_bookmarks);
if !negative_bookmarks.is_empty() {
bookmark_expr =
bookmark_expr.intersection(StringExpression::union_all(negative_bookmarks).negated());
}
Ok((IgnoredRefspecs(ignored_refspecs), bookmark_expr))
}
fn parse_fetch_refspec(
remote_name: &RemoteName,
refspec: gix::refspec::RefSpecRef<'_>,
) -> Result<(FetchRefSpecKind, StringPattern), &'static str> {
let ensure_utf8 = |s| str::from_utf8(s).map_err(|_| "invalid UTF-8");
let (src, positive_dst) = match refspec.instruction() {
Instruction::Push(_) => panic!("push refspec should be filtered out by caller"),
Instruction::Fetch(fetch) => match fetch {
gix::refspec::instruction::Fetch::Only { src: _ } => {
return Err("fetch-only refspecs are not supported");
}
gix::refspec::instruction::Fetch::AndUpdate {
src,
dst,
allow_non_fast_forward,
} => {
if !allow_non_fast_forward {
return Err("non-forced refspecs are not supported");
}
(ensure_utf8(src)?, Some(ensure_utf8(dst)?))
}
gix::refspec::instruction::Fetch::Exclude { src } => (ensure_utf8(src)?, None),
},
};
let src_branch = src
.strip_prefix("refs/heads/")
.ok_or("only refs/heads/ is supported for refspec sources")?;
let branch = StringPattern::glob(src_branch).map_err(|_| "invalid pattern")?;
if let Some(dst) = positive_dst {
let dst_without_prefix = dst
.strip_prefix(REMOTE_BOOKMARK_REF_NAMESPACE)
.ok_or("only refs/remotes/ is supported for fetch destinations")?;
let dst_branch = dst_without_prefix
.strip_prefix(remote_name.as_str())
.and_then(|d| d.strip_prefix("/"))
.ok_or("remote renaming not supported")?;
if src_branch != dst_branch {
return Err("renaming is not supported");
}
Ok((FetchRefSpecKind::Positive, branch))
} else {
Ok((FetchRefSpecKind::Negative, branch))
}
}
pub struct GitFetch<'a> {
mut_repo: &'a mut MutableRepo,
git_repo: Box<gix::Repository>,
git_ctx: GitSubprocessContext,
import_options: &'a GitImportOptions,
fetched: Vec<FetchedRefs>,
}
impl<'a> GitFetch<'a> {
pub fn new(
mut_repo: &'a mut MutableRepo,
subprocess_options: GitSubprocessOptions,
import_options: &'a GitImportOptions,
) -> Result<Self, UnexpectedGitBackendError> {
let git_backend = get_git_backend(mut_repo.store())?;
let git_repo = Box::new(git_backend.git_repo());
let git_ctx = GitSubprocessContext::from_git_backend(git_backend, subprocess_options);
Ok(GitFetch {
mut_repo,
git_repo,
git_ctx,
import_options,
fetched: vec![],
})
}
#[tracing::instrument(skip(self, callback))]
pub fn fetch(
&mut self,
remote_name: &RemoteName,
ExpandedFetchRefSpecs {
expr,
refspecs: mut remaining_refspecs,
negative_refspecs,
}: ExpandedFetchRefSpecs,
callback: &mut dyn GitSubprocessCallback,
depth: Option<NonZeroU32>,
fetch_tags_override: Option<FetchTagsOverride>,
) -> Result<(), GitFetchError> {
validate_remote_name(remote_name)?;
if self
.git_repo
.try_find_remote(remote_name.as_str())
.is_none()
{
return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
}
if remaining_refspecs.is_empty() {
return Ok(());
}
let mut branches_to_prune = Vec::new();
let updates = loop {
let status = self.git_ctx.spawn_fetch(
remote_name,
&remaining_refspecs,
&negative_refspecs,
callback,
depth,
fetch_tags_override,
)?;
let failing_refspec = match status {
GitFetchStatus::Updates(updates) => break updates,
GitFetchStatus::NoRemoteRef(failing_refspec) => failing_refspec,
};
tracing::debug!(failing_refspec, "failed to fetch ref");
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}",
remote_name = remote_name.as_str()
));
}
};
if !updates.rejected.is_empty() {
let names = updates.rejected.into_iter().map(|(name, _)| name).collect();
return Err(GitFetchError::RejectedUpdates(names));
}
self.git_ctx.spawn_branch_prune(&branches_to_prune)?;
self.fetched.push(FetchedRefs {
remote: remote_name.to_owned(),
bookmark_matcher: expr.bookmark.to_matcher(),
tag_matcher: expr.tag.to_matcher(),
});
Ok(())
}
#[tracing::instrument(skip(self))]
pub fn get_default_branch(
&self,
remote_name: &RemoteName,
) -> Result<Option<RefNameBuf>, GitFetchError> {
if self
.git_repo
.try_find_remote(remote_name.as_str())
.is_none()
{
return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
}
let default_branch = self.git_ctx.spawn_remote_show(remote_name)?;
tracing::debug!(?default_branch);
Ok(default_branch)
}
#[tracing::instrument(skip(self))]
pub async fn import_refs(&mut self) -> Result<GitImportStats, GitImportError> {
tracing::debug!("import_refs");
let all_remote_tags = true;
let refs_to_import = diff_refs_to_import(
self.mut_repo.view(),
&self.git_repo,
all_remote_tags,
|kind, symbol| match kind {
GitRefKind::Bookmark => self
.fetched
.iter()
.filter(|fetched| fetched.remote == symbol.remote)
.any(|fetched| fetched.bookmark_matcher.is_match(symbol.name.as_str())),
GitRefKind::Tag => {
symbol.remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
|| self
.fetched
.iter()
.filter(|fetched| fetched.remote == symbol.remote)
.any(|fetched| fetched.tag_matcher.is_match(symbol.name.as_str()))
}
},
)?;
let import_stats =
import_refs_inner(self.mut_repo, refs_to_import, self.import_options).await?;
self.fetched.clear();
Ok(import_stats)
}
}
#[derive(Error, Debug)]
pub enum GitPushError {
#[error("No git remote named '{}'", .0.as_symbol())]
NoSuchRemote(RemoteNameBuf),
#[error(transparent)]
RemoteName(#[from] GitRemoteNameError),
#[error(transparent)]
Subprocess(#[from] GitSubprocessError),
#[error(transparent)]
UnexpectedBackend(#[from] UnexpectedGitBackendError),
}
#[derive(Clone, Debug)]
pub struct GitPushRefTargets {
pub bookmarks: Vec<(RefNameBuf, Diff<Option<CommitId>>)>,
}
pub struct GitRefUpdate {
pub qualified_name: GitRefNameBuf,
pub targets: Diff<Option<CommitId>>,
}
#[derive(Clone, Debug, Default)]
pub struct GitPushOptions {
pub extra_args: Vec<String>,
pub remote_push_options: Vec<String>,
}
pub fn push_refs(
mut_repo: &mut MutableRepo,
subprocess_options: GitSubprocessOptions,
remote: &RemoteName,
targets: &GitPushRefTargets,
callback: &mut dyn GitSubprocessCallback,
options: &GitPushOptions,
) -> Result<GitPushStats, GitPushError> {
validate_remote_name(remote)?;
let ref_updates = targets
.bookmarks
.iter()
.map(|(name, update)| GitRefUpdate {
qualified_name: format!("refs/heads/{name}", name = name.as_str()).into(),
targets: update.clone(),
})
.collect_vec();
let push_stats = push_updates(
mut_repo,
subprocess_options,
remote,
&ref_updates,
callback,
options,
)?;
tracing::debug!(?push_stats);
let pushed: HashSet<&GitRefName> = push_stats.pushed.iter().map(AsRef::as_ref).collect();
let pushed_bookmark_updates = || {
iter::zip(&targets.bookmarks, &ref_updates)
.filter(|(_, ref_update)| pushed.contains(&*ref_update.qualified_name))
.map(|((name, update), _)| (name.as_ref(), update))
};
let unexported_bookmarks = {
let git_repo =
get_git_repo(mut_repo.store()).expect("backend type should have been tested");
let refs = build_pushed_bookmarks_to_export(remote, pushed_bookmark_updates());
export_refs_to_git(mut_repo, &git_repo, GitRefKind::Bookmark, refs)
};
debug_assert!(unexported_bookmarks.is_sorted_by_key(|(symbol, _)| symbol));
let is_exported_bookmark = |name: &RefName| {
unexported_bookmarks
.binary_search_by_key(&name, |(symbol, _)| &symbol.name)
.is_err()
};
for (name, update) in pushed_bookmark_updates().filter(|(name, _)| is_exported_bookmark(name)) {
let new_remote_ref = RemoteRef {
target: RefTarget::resolved(update.after.clone()),
state: RemoteRefState::Tracked,
};
mut_repo.set_remote_bookmark(name.to_remote_symbol(remote), new_remote_ref);
}
assert!(push_stats.unexported_bookmarks.is_empty());
let push_stats = GitPushStats {
pushed: push_stats.pushed,
rejected: push_stats.rejected,
remote_rejected: push_stats.remote_rejected,
unexported_bookmarks,
};
Ok(push_stats)
}
pub fn push_updates(
repo: &dyn Repo,
subprocess_options: GitSubprocessOptions,
remote_name: &RemoteName,
updates: &[GitRefUpdate],
callback: &mut dyn GitSubprocessCallback,
options: &GitPushOptions,
) -> Result<GitPushStats, GitPushError> {
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_ref(),
update.targets.before.as_ref(),
);
if let Some(new_target) = &update.targets.after {
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())?;
let git_repo = git_backend.git_repo();
let git_ctx = GitSubprocessContext::from_git_backend(git_backend, subprocess_options);
if git_repo.try_find_remote(remote_name.as_str()).is_none() {
return Err(GitPushError::NoSuchRemote(remote_name.to_owned()));
}
let refs_to_push: Vec<RefToPush> = refspecs
.iter()
.map(|full_refspec| RefToPush::new(full_refspec, &qualified_remote_refs_expected_locations))
.collect();
let mut push_stats = git_ctx.spawn_push(remote_name, &refs_to_push, callback, options)?;
push_stats.pushed.sort();
push_stats.rejected.sort();
push_stats.remote_rejected.sort();
Ok(push_stats)
}
fn build_pushed_bookmarks_to_export<'a>(
remote: &RemoteName,
pushed_updates: impl IntoIterator<Item = (&'a RefName, &'a Diff<Option<CommitId>>)>,
) -> RefsToExport {
let mut to_update = Vec::new();
let mut to_delete = Vec::new();
for (name, update) in pushed_updates {
let symbol = name.to_remote_symbol(remote);
match (update.before.as_ref(), update.after.as_ref()) {
(old, Some(new)) => {
let old_oid = old.map(|id| gix::ObjectId::from_bytes_or_panic(id.as_bytes()));
let new_oid = gix::ObjectId::from_bytes_or_panic(new.as_bytes());
to_update.push((symbol.to_owned(), (old_oid, new_oid)));
}
(Some(old), None) => {
let old_oid = gix::ObjectId::from_bytes_or_panic(old.as_bytes());
to_delete.push((symbol.to_owned(), old_oid));
}
(None, None) => panic!("old/new targets should differ"),
}
}
RefsToExport {
to_update,
to_delete,
failed: vec![],
}
}
#[derive(Copy, Clone, Debug)]
pub enum FetchTagsOverride {
AllTags,
NoTags,
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use super::*;
use crate::revset;
use crate::revset::RevsetDiagnostics;
#[test]
fn test_split_positive_negative_patterns() {
fn split(text: &str) -> (Vec<StringPattern>, Vec<StringPattern>) {
try_split(text).unwrap()
}
fn try_split(
text: &str,
) -> Result<(Vec<StringPattern>, Vec<StringPattern>), GitRefExpressionError> {
let mut diagnostics = RevsetDiagnostics::new();
let expr = revset::parse_string_expression(&mut diagnostics, text).unwrap();
let (positives, negatives) = split_into_positive_negative_patterns(&expr)?;
Ok((
positives.into_iter().cloned().collect(),
negatives.into_iter().cloned().collect(),
))
}
insta::assert_compact_debug_snapshot!(
split("a"),
@r#"([Exact("a")], [])"#);
insta::assert_compact_debug_snapshot!(
split("~a"),
@r#"([Substring("")], [Exact("a")])"#);
insta::assert_compact_debug_snapshot!(
split("~a~b"),
@r#"([Substring("")], [Exact("a"), Exact("b")])"#);
insta::assert_compact_debug_snapshot!(
split("~(a|b)"),
@r#"([Substring("")], [Exact("a"), Exact("b")])"#);
insta::assert_compact_debug_snapshot!(
split("a|b"),
@r#"([Exact("a"), Exact("b")], [])"#);
insta::assert_compact_debug_snapshot!(
split("(a|b)&~c"),
@r#"([Exact("a"), Exact("b")], [Exact("c")])"#);
insta::assert_compact_debug_snapshot!(
split("~a&b"),
@r#"([Exact("b")], [Exact("a")])"#);
insta::assert_compact_debug_snapshot!(
split("a&~b&~c"),
@r#"([Exact("a")], [Exact("b"), Exact("c")])"#);
insta::assert_compact_debug_snapshot!(
split("~a&b&~c"),
@r#"([Exact("b")], [Exact("a"), Exact("c")])"#);
insta::assert_compact_debug_snapshot!(
split("a&~(b|c)"),
@r#"([Exact("a")], [Exact("b"), Exact("c")])"#);
insta::assert_compact_debug_snapshot!(
split("((a|b)|c)&~(d|(e|f))"),
@r#"([Exact("a"), Exact("b"), Exact("c")], [Exact("d"), Exact("e"), Exact("f")])"#);
assert_matches!(
try_split("a&b"),
Err(GitRefExpressionError::PositiveIntersection)
);
assert_matches!(try_split("a|~b"), Err(GitRefExpressionError::NestedNotIn));
assert_matches!(
try_split("a&~(b&~c)"),
Err(GitRefExpressionError::NestedIntersection)
);
assert_matches!(
try_split("(a|b)&c"),
Err(GitRefExpressionError::PositiveIntersection)
);
assert_matches!(
try_split("(a&~b)&(~c&~d)"),
Err(GitRefExpressionError::PositiveIntersection)
);
assert_matches!(try_split("a&~~b"), Err(GitRefExpressionError::NestedNotIn));
assert_matches!(
try_split("a&~b|c&~d"),
Err(GitRefExpressionError::NestedIntersection)
);
insta::assert_compact_debug_snapshot!(
split("*"),
@r#"([Glob(GlobPattern("*"))], [])"#);
insta::assert_compact_debug_snapshot!(
split("~*"),
@"([], [])");
insta::assert_compact_debug_snapshot!(
split("a~*"),
@r#"([Exact("a")], [Glob(GlobPattern("*"))])"#);
insta::assert_compact_debug_snapshot!(
split("~(a|*)"),
@r#"([Substring("")], [Exact("a"), Glob(GlobPattern("*"))])"#);
}
}