Skip to main content

jj_lib/
git.rs

1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![expect(missing_docs)]
16
17use std::borrow::Borrow;
18use std::borrow::Cow;
19use std::collections::HashMap;
20use std::collections::HashSet;
21use std::default::Default;
22use std::ffi::OsString;
23use std::fs::File;
24use std::iter;
25use std::num::NonZeroU32;
26use std::path::Path;
27use std::path::PathBuf;
28use std::sync::Arc;
29
30use bstr::BStr;
31use bstr::BString;
32use futures::StreamExt as _;
33use futures::TryStreamExt as _;
34use futures::stream;
35use gix::refspec::Instruction;
36use itertools::Itertools as _;
37use thiserror::Error;
38
39use crate::backend::BackendError;
40use crate::backend::ChangeId;
41use crate::backend::CommitId;
42use crate::backend::TreeValue;
43use crate::commit::Commit;
44use crate::config::ConfigGetError;
45use crate::file_util::IoResultExt as _;
46use crate::file_util::PathError;
47use crate::git_backend::GitBackend;
48use crate::git_subprocess::GitFetchStatus;
49pub use crate::git_subprocess::GitProgress;
50pub use crate::git_subprocess::GitSidebandLineTerminator;
51pub use crate::git_subprocess::GitSubprocessCallback;
52use crate::git_subprocess::GitSubprocessContext;
53use crate::git_subprocess::GitSubprocessError;
54use crate::index::IndexError;
55use crate::matchers::EverythingMatcher;
56use crate::merge::Diff;
57use crate::merged_tree::MergedTree;
58use crate::merged_tree::TreeDiffEntry;
59use crate::object_id::ObjectId as _;
60use crate::op_store::RefTarget;
61use crate::op_store::RefTargetOptionExt as _;
62use crate::op_store::RemoteRef;
63use crate::op_store::RemoteRefState;
64use crate::ref_name::GitRefName;
65use crate::ref_name::GitRefNameBuf;
66use crate::ref_name::RefName;
67use crate::ref_name::RefNameBuf;
68use crate::ref_name::RemoteName;
69use crate::ref_name::RemoteNameBuf;
70use crate::ref_name::RemoteRefSymbol;
71use crate::ref_name::RemoteRefSymbolBuf;
72use crate::ref_name::WorkspaceName;
73use crate::repo::MutableRepo;
74use crate::repo::Repo;
75use crate::repo_path::RepoPath;
76use crate::revset::ResolvedRevsetExpression;
77use crate::revset::RevsetEvaluationError;
78use crate::revset::RevsetExpression;
79use crate::revset::RevsetStreamExt as _;
80use crate::settings::UserSettings;
81use crate::store::Store;
82use crate::str_util::StringExpression;
83use crate::str_util::StringMatcher;
84use crate::str_util::StringPattern;
85use crate::view::View;
86
87/// Reserved remote name for the backing Git repo.
88pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &RemoteName = RemoteName::new("git");
89/// Git ref prefix that would conflict with the reserved "git" remote.
90pub const RESERVED_REMOTE_REF_NAMESPACE: &str = "refs/remotes/git/";
91/// Git ref prefix where remote bookmarks are stored.
92const REMOTE_BOOKMARK_REF_NAMESPACE: &str = "refs/remotes/";
93/// Git ref prefix where remote tags will be temporarily fetched.
94const REMOTE_TAG_REF_NAMESPACE: &str = "refs/jj/remote-tags/";
95/// Ref name used as a placeholder to unset HEAD without a commit.
96const UNBORN_ROOT_REF_NAME: &str = "refs/jj/root";
97/// Dummy file to be added to the index to indicate that the user is editing a
98/// commit with a conflict that isn't represented in the Git index.
99const INDEX_DUMMY_CONFLICT_FILE: &str = ".jj-do-not-resolve-this-conflict";
100
101#[derive(Clone, Debug)]
102pub struct GitSettings {
103    pub abandon_unreachable_commits: bool,
104    pub executable_path: PathBuf,
105    pub record_synthetic_predecessors: bool,
106    pub write_change_id_header: bool,
107}
108
109impl GitSettings {
110    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
111        Ok(Self {
112            abandon_unreachable_commits: settings.get_bool("git.abandon-unreachable-commits")?,
113            executable_path: settings.get("git.executable-path")?,
114            record_synthetic_predecessors: settings
115                .get_bool("git.record-synthetic-predecessors")?,
116            write_change_id_header: settings.get("git.write-change-id-header")?,
117        })
118    }
119
120    pub fn to_subprocess_options(&self) -> GitSubprocessOptions {
121        GitSubprocessOptions {
122            executable_path: self.executable_path.clone(),
123            environment: HashMap::new(),
124        }
125    }
126}
127
128/// Configuration for a Git subprocess
129#[derive(Clone, Debug)]
130pub struct GitSubprocessOptions {
131    pub executable_path: PathBuf,
132    /// Used by consumers of jj-lib to set environment variables like
133    /// GIT_ASKPASS (for authentication callbacks) or GIT_TRACE (for debugging).
134    /// Setting per-subcommand environment variables avoids the need for unsafe
135    /// code and process-wide state.
136    pub environment: HashMap<OsString, OsString>,
137}
138
139impl GitSubprocessOptions {
140    pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
141        Ok(Self {
142            executable_path: settings.get("git.executable-path")?,
143            environment: HashMap::new(),
144        })
145    }
146}
147
148#[derive(Debug, Error)]
149pub enum GitRemoteNameError {
150    #[error(
151        "Git remote named '{name}' is reserved for local Git repository",
152        name = REMOTE_NAME_FOR_LOCAL_GIT_REPO.as_symbol()
153    )]
154    ReservedForLocalGitRepo,
155    #[error("Git remotes with slashes are incompatible with jj: {}", .0.as_symbol())]
156    WithSlash(RemoteNameBuf),
157    #[error("Invalid Git remote name")]
158    InvalidName(#[from] gix::remote::name::Error),
159}
160
161fn validate_remote_name(name: &RemoteName) -> Result<(), GitRemoteNameError> {
162    gix::remote::name::validated(name.as_str())?;
163    if name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
164        Err(GitRemoteNameError::ReservedForLocalGitRepo)
165    } else if name.as_str().contains('/') {
166        Err(GitRemoteNameError::WithSlash(name.to_owned()))
167    } else {
168        Ok(())
169    }
170}
171
172/// Converts [`CommitId`] of valid length to [`gix::oid`].
173fn oid_from_commit_id(id: &CommitId) -> &gix::oid {
174    gix::oid::from_bytes_unchecked(id.as_bytes())
175}
176
177/// Converts [`CommitId`] of valid length to [`gix::ObjectId`].
178fn owned_oid_from_commit_id(id: &CommitId) -> gix::ObjectId {
179    gix::ObjectId::from_bytes_or_panic(id.as_bytes())
180}
181
182/// Type of Git ref to be imported or exported.
183#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum GitRefKind {
185    Bookmark,
186    Tag,
187}
188
189/// Stats from a git push
190#[derive(Debug, Default)]
191pub struct GitPushStats {
192    /// reference accepted by the remote
193    pub pushed: Vec<GitRefNameBuf>,
194    /// rejected reference, due to lease failure, with an optional reason
195    pub rejected: Vec<(GitRefNameBuf, Option<String>)>,
196    /// reference rejected by the remote, with an optional reason
197    pub remote_rejected: Vec<(GitRefNameBuf, Option<String>)>,
198    /// remote bookmarks that couldn't be exported to local Git repo
199    pub unexported_bookmarks: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
200}
201
202impl GitPushStats {
203    pub fn all_ok(&self) -> bool {
204        self.rejected.is_empty()
205            && self.remote_rejected.is_empty()
206            && self.unexported_bookmarks.is_empty()
207    }
208
209    /// Returns true if there are at least one bookmark that was successfully
210    /// pushed to the remote and exported to the local Git repo.
211    pub fn some_exported(&self) -> bool {
212        self.pushed.len() > self.unexported_bookmarks.len()
213    }
214}
215
216/// Newtype to look up `HashMap` entry by key of shorter lifetime.
217///
218/// https://users.rust-lang.org/t/unexpected-lifetime-issue-with-hashmap-remove/113961/6
219#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
220struct RemoteRefKey<'a>(RemoteRefSymbol<'a>);
221
222impl<'a: 'b, 'b> Borrow<RemoteRefSymbol<'b>> for RemoteRefKey<'a> {
223    fn borrow(&self) -> &RemoteRefSymbol<'b> {
224        &self.0
225    }
226}
227
228/// Representation of a Git refspec
229///
230/// It is often the case that we need only parts of the refspec,
231/// Passing strings around and repeatedly parsing them is sub-optimal, confusing
232/// and error prone
233#[derive(Debug, Hash, PartialEq, Eq)]
234pub(crate) struct RefSpec {
235    forced: bool,
236    // Source and destination may be fully-qualified ref name, glob pattern, or
237    // object ID. The GitRefNameBuf type shouldn't be used.
238    source: Option<String>,
239    destination: String,
240}
241
242impl RefSpec {
243    fn forced(source: impl Into<String>, destination: impl Into<String>) -> Self {
244        Self {
245            forced: true,
246            source: Some(source.into()),
247            destination: destination.into(),
248        }
249    }
250
251    fn delete(destination: impl Into<String>) -> Self {
252        // We don't force push on branch deletion
253        Self {
254            forced: false,
255            source: None,
256            destination: destination.into(),
257        }
258    }
259
260    pub(crate) fn to_git_format(&self) -> String {
261        format!(
262            "{}{}",
263            if self.forced { "+" } else { "" },
264            self.to_git_format_not_forced()
265        )
266    }
267
268    /// Format git refspec without the leading force flag '+'
269    ///
270    /// When independently setting --force-with-lease, having the
271    /// leading flag overrides the lease, so we need to print it
272    /// without it
273    pub(crate) fn to_git_format_not_forced(&self) -> String {
274        if let Some(s) = &self.source {
275            format!("{}:{}", s, self.destination)
276        } else {
277            format!(":{}", self.destination)
278        }
279    }
280}
281
282/// Representation of a negative Git refspec
283#[derive(Debug)]
284#[repr(transparent)]
285pub(crate) struct NegativeRefSpec {
286    source: String,
287}
288
289impl NegativeRefSpec {
290    fn new(source: impl Into<String>) -> Self {
291        Self {
292            source: source.into(),
293        }
294    }
295
296    pub(crate) fn to_git_format(&self) -> String {
297        format!("^{}", self.source)
298    }
299}
300
301/// Helper struct that matches a refspec with its expected location in the
302/// remote it's being pushed to
303pub(crate) struct RefToPush<'a> {
304    pub(crate) refspec: &'a RefSpec,
305    pub(crate) expected_location: Option<&'a gix::oid>,
306}
307
308impl<'a> RefToPush<'a> {
309    fn new(
310        refspec: &'a RefSpec,
311        expected_locations: &'a HashMap<&GitRefName, Option<&gix::oid>>,
312    ) -> Self {
313        let expected_location = *expected_locations
314            .get(GitRefName::new(&refspec.destination))
315            .expect(
316                "The refspecs and the expected locations were both constructed from the same \
317                 source of truth. This means the lookup should always work.",
318            );
319
320        Self {
321            refspec,
322            expected_location,
323        }
324    }
325
326    pub(crate) fn to_git_lease(&self) -> String {
327        format!(
328            "{}:{}",
329            self.refspec.destination,
330            self.expected_location
331                .map(|x| x.to_string())
332                .as_deref()
333                .unwrap_or("")
334        )
335    }
336}
337
338/// Translates Git ref name to jj's `name@remote` symbol. Returns `None` if the
339/// ref cannot be represented in jj.
340pub fn parse_git_ref(full_name: &GitRefName) -> Option<(GitRefKind, RemoteRefSymbol<'_>)> {
341    if let Some(name) = full_name.as_str().strip_prefix("refs/heads/") {
342        // Git CLI says 'HEAD' is not a valid branch name
343        if name == "HEAD" {
344            return None;
345        }
346        let name = RefName::new(name);
347        let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
348        Some((GitRefKind::Bookmark, RemoteRefSymbol { name, remote }))
349    } else if let Some(remote_and_name) = full_name
350        .as_str()
351        .strip_prefix(REMOTE_BOOKMARK_REF_NAMESPACE)
352    {
353        let (remote, name) = remote_and_name.split_once('/')?;
354        // "refs/remotes/origin/HEAD" isn't a real remote-tracking branch
355        if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO || name == "HEAD" {
356            return None;
357        }
358        let name = RefName::new(name);
359        let remote = RemoteName::new(remote);
360        Some((GitRefKind::Bookmark, RemoteRefSymbol { name, remote }))
361    } else if let Some(name) = full_name.as_str().strip_prefix("refs/tags/") {
362        let name = RefName::new(name);
363        let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
364        Some((GitRefKind::Tag, RemoteRefSymbol { name, remote }))
365    } else {
366        None
367    }
368}
369
370fn parse_remote_tag_ref(full_name: &GitRefName) -> Option<(GitRefKind, RemoteRefSymbol<'_>)> {
371    let remote_and_name = full_name.as_str().strip_prefix(REMOTE_TAG_REF_NAMESPACE)?;
372    let (remote, name) = remote_and_name.split_once('/')?;
373    if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
374        return None;
375    }
376    let name = RefName::new(name);
377    let remote = RemoteName::new(remote);
378    Some((GitRefKind::Tag, RemoteRefSymbol { name, remote }))
379}
380
381fn to_git_ref_name(kind: GitRefKind, symbol: RemoteRefSymbol<'_>) -> Option<GitRefNameBuf> {
382    let RemoteRefSymbol { name, remote } = symbol;
383    let name = name.as_str();
384    let remote = remote.as_str();
385    if name.is_empty() || remote.is_empty() {
386        return None;
387    }
388    match kind {
389        GitRefKind::Bookmark => {
390            if name == "HEAD" {
391                return None;
392            }
393            if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
394                Some(format!("refs/heads/{name}").into())
395            } else {
396                Some(format!("{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{name}").into())
397            }
398        }
399        GitRefKind::Tag => {
400            // Only local tags are mapped. Remote tags don't exist in Git world.
401            (remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO).then(|| format!("refs/tags/{name}").into())
402        }
403    }
404}
405
406fn to_git_or_remote_tag_ref_name(symbol: RemoteRefSymbol<'_>) -> GitRefNameBuf {
407    let RemoteRefSymbol { name, remote } = symbol;
408    let name = name.as_str();
409    let remote = remote.as_str();
410    if remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
411        format!("refs/tags/{name}").into()
412    } else {
413        format!("{REMOTE_TAG_REF_NAMESPACE}{remote}/{name}").into()
414    }
415}
416
417#[derive(Debug, Error)]
418#[error("The repo is not backed by a Git repo")]
419pub struct UnexpectedGitBackendError;
420
421/// Returns the underlying `GitBackend` implementation.
422pub fn get_git_backend(store: &Store) -> Result<&GitBackend, UnexpectedGitBackendError> {
423    store.backend_impl().ok_or(UnexpectedGitBackendError)
424}
425
426/// Returns new thread-local instance to access to the underlying Git repo.
427pub fn get_git_repo(store: &Store) -> Result<gix::Repository, UnexpectedGitBackendError> {
428    get_git_backend(store).map(|backend| backend.git_repo())
429}
430
431/// Checks if `git_ref` points to a Git commit object, and returns its id.
432///
433/// If the ref points to the previously `known_commit_oid` (i.e. unchanged),
434/// this should be faster than `git_ref.into_fully_peeled_id()`.
435fn resolve_git_ref_to_commit_id(
436    git_ref: &gix::Reference,
437    known_commit_oid: Option<&gix::oid>,
438) -> Option<gix::ObjectId> {
439    let mut peeling_ref = Cow::Borrowed(git_ref);
440
441    // Try fast path if we have a candidate id which is known to be a commit object.
442    if let Some(known_oid) = known_commit_oid {
443        let raw_ref = &git_ref.inner;
444        if let Some(oid) = raw_ref.target.try_id()
445            && oid == known_oid
446        {
447            return Some(oid.to_owned());
448        }
449        if let Some(oid) = raw_ref.peeled
450            && oid == known_oid
451        {
452            // Perhaps an annotated tag stored in packed-refs file, and pointing to the
453            // already known target commit.
454            return Some(oid);
455        }
456        // A tag (according to ref name.) Try to peel one more level. This is slightly
457        // faster than recurse into into_fully_peeled_id(). If we recorded a tag oid, we
458        // could skip this at all.
459        if raw_ref.peeled.is_none() && git_ref.name().as_bstr().starts_with(b"refs/tags/") {
460            let maybe_tag = git_ref
461                .try_id()
462                .and_then(|id| id.object().ok())
463                .and_then(|object| object.try_into_tag().ok());
464            if let Some(oid) = maybe_tag.as_ref().and_then(|tag| tag.target_id().ok()) {
465                let oid = oid.detach();
466                if oid == known_oid {
467                    // An annotated tag pointing to the already known target commit.
468                    return Some(oid);
469                }
470                // Unknown id. Recurse from the current state. A tag may point to
471                // non-commit object.
472                peeling_ref.to_mut().inner.target = gix::refs::Target::Object(oid);
473            }
474        }
475    }
476
477    // Alternatively, we might want to inline the first half of the peeling
478    // loop. into_fully_peeled_id() looks up the target object to see if it's
479    // a tag or not, and we need to check if it's a commit object.
480    let peeled_id = peeling_ref.into_owned().into_fully_peeled_id().ok()?;
481    let is_commit = peeled_id
482        .object()
483        .is_ok_and(|object| object.kind.is_commit());
484    is_commit.then_some(peeled_id.detach())
485}
486
487#[derive(Error, Debug)]
488pub enum GitImportError {
489    #[error("Failed to read Git HEAD target commit {id}")]
490    MissingHeadTarget {
491        id: CommitId,
492        #[source]
493        err: BackendError,
494    },
495    #[error("Ancestor of Git ref {symbol} is missing")]
496    MissingRefAncestor {
497        symbol: RemoteRefSymbolBuf,
498        #[source]
499        err: BackendError,
500    },
501    #[error(transparent)]
502    Backend(#[from] BackendError),
503    #[error(transparent)]
504    Index(#[from] IndexError),
505    #[error(transparent)]
506    RevsetEvaluation(#[from] RevsetEvaluationError),
507    #[error(transparent)]
508    Git(Box<dyn std::error::Error + Send + Sync>),
509    #[error(transparent)]
510    UnexpectedBackend(#[from] UnexpectedGitBackendError),
511}
512
513impl GitImportError {
514    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
515        Self::Git(source.into())
516    }
517}
518
519/// Options for [`import_refs()`].
520#[derive(Debug)]
521pub struct GitImportOptions {
522    /// Whether to abandon commits that became unreachable in Git.
523    pub abandon_unreachable_commits: bool,
524    /// Whether to generate synthetic predecessors for imported commits.
525    pub record_synthetic_predecessors: bool,
526    /// Per-remote patterns whether to track bookmarks automatically.
527    pub remote_auto_track_bookmarks: HashMap<RemoteNameBuf, StringMatcher>,
528}
529
530#[derive(Clone, Debug, Eq, PartialEq)]
531pub struct GitImportRefUpdate {
532    pub symbol: RemoteRefSymbolBuf,
533    pub old_remote_ref: RemoteRef,
534    pub new_target: RefTarget,
535}
536
537impl GitImportRefUpdate {
538    pub fn new(
539        symbol: RemoteRefSymbolBuf,
540        old_remote_ref: RemoteRef,
541        new_target: RefTarget,
542    ) -> Self {
543        Self {
544            symbol,
545            old_remote_ref,
546            new_target,
547        }
548    }
549}
550
551/// Describes changes made by `import_refs()` or `fetch()`.
552#[derive(Clone, Debug, Eq, PartialEq, Default)]
553pub struct GitImportStats {
554    /// Commits that are no longer reachable nor rewritten to the new commits.
555    pub abandoned_commits: Vec<Commit>,
556    /// Commits that have been rewritten to the new commits.
557    pub rewritten_commit_ids: HashSet<CommitId>,
558    /// Remote bookmark updates to be merged in to the local bookmarks, sorted
559    /// by `symbol`.
560    pub changed_remote_bookmarks: Vec<GitImportRefUpdate>,
561    /// Remote tag updates to be merged in to the local tags, sorted by
562    /// `symbol`.
563    pub changed_remote_tags: Vec<GitImportRefUpdate>,
564    /// Git ref names that couldn't be imported, sorted by name.
565    ///
566    /// This list doesn't include refs that are supposed to be ignored, such as
567    /// refs pointing to non-commit objects.
568    pub failed_ref_names: Vec<BString>,
569}
570
571#[derive(Debug)]
572struct RefsToImport {
573    /// Git ref `(full_name, new_target)`s to be copied to the view, sorted by
574    /// `full_name`.
575    changed_git_refs: Vec<(GitRefNameBuf, RefTarget)>,
576    /// Remote bookmark updates to be merged in to the local bookmarks, sorted
577    /// by `symbol`.
578    changed_remote_bookmarks: Vec<GitImportRefUpdate>,
579    /// Remote tag updates to be merged in to the local tags, sorted by
580    /// `symbol`.
581    changed_remote_tags: Vec<GitImportRefUpdate>,
582    /// Git ref names that couldn't be imported, sorted by name.
583    failed_ref_names: Vec<BString>,
584}
585
586/// Reflect changes made in the underlying Git repo in the Jujutsu repo.
587///
588/// This function detects conflicts (if both Git and JJ modified a bookmark) and
589/// records them in JJ's view.
590pub async fn import_refs(
591    mut_repo: &mut MutableRepo,
592    options: &GitImportOptions,
593) -> Result<GitImportStats, GitImportError> {
594    import_some_refs(mut_repo, options, |_, _| true).await
595}
596
597/// Reflect changes made in the underlying Git repo in the Jujutsu repo.
598///
599/// Only bookmarks and tags whose remote symbol pass the filter will be
600/// considered for addition, update, or deletion.
601pub async fn import_some_refs(
602    mut_repo: &mut MutableRepo,
603    options: &GitImportOptions,
604    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
605) -> Result<GitImportStats, GitImportError> {
606    let git_repo = get_git_repo(mut_repo.store())?;
607
608    // Allocate views for new remotes configured externally. There may be
609    // remotes with no refs, but the user might still want to "track" absent
610    // remote refs.
611    for remote_name in iter_remote_names(&git_repo) {
612        mut_repo.ensure_remote(&remote_name);
613    }
614
615    // Exclude real remote tags, which should never be updated by Git.
616    let all_remote_tags = false;
617    let refs_to_import =
618        diff_refs_to_import(mut_repo.view(), &git_repo, all_remote_tags, git_ref_filter)?;
619    import_refs_inner(mut_repo, refs_to_import, options).await
620}
621
622async fn import_refs_inner(
623    mut_repo: &mut MutableRepo,
624    refs_to_import: RefsToImport,
625    options: &GitImportOptions,
626) -> Result<GitImportStats, GitImportError> {
627    let store = mut_repo.store();
628    let git_backend = get_git_backend(store).expect("backend type should have been tested");
629
630    let RefsToImport {
631        changed_git_refs,
632        changed_remote_bookmarks,
633        changed_remote_tags,
634        failed_ref_names,
635    } = refs_to_import;
636
637    let iter_changed_refs = || itertools::chain(&changed_remote_bookmarks, &changed_remote_tags);
638    // List of changed old/new ref heads, which may include duplicates.
639    let (old_referenced_heads, new_referenced_heads) = {
640        let mut old_heads = Vec::new();
641        let mut new_heads = Vec::new();
642        for update in iter_changed_refs() {
643            old_heads.extend(update.old_remote_ref.target.added_ids().cloned());
644            new_heads.extend(update.new_target.added_ids().cloned());
645        }
646        (old_heads, new_heads)
647    };
648    let old_visible_heads = mut_repo.view().heads().iter().cloned().collect_vec();
649
650    // Bulk-import all reachable Git commits to the backend to reduce overhead
651    // of table merging and ref updates.
652    //
653    // changed_git_refs aren't respected because changed_remote_bookmarks/tags
654    // should include all heads that will become reachable in jj.
655    let index = mut_repo.index();
656    let missing_head_ids: Vec<&CommitId> = stream::iter(&new_referenced_heads)
657        .map(async move |id| (id, index.has_id(id).await))
658        .buffered(mut_repo.store().concurrency())
659        .filter_map(async move |m| match m {
660            (id, Ok(false)) => Some(Ok(id)),
661            (_, Ok(true)) => None,
662            (_, Err(err)) => Some(Err(GitImportError::Index(err))),
663        })
664        .try_collect()
665        .await?;
666    let heads_imported = git_backend.import_head_commits(missing_head_ids).is_ok();
667
668    // Import new remote heads
669    let mut head_commits = Vec::new();
670    let get_commit = async |id: &CommitId, symbol: &RemoteRefSymbolBuf| {
671        let missing_ref_err = |err| GitImportError::MissingRefAncestor {
672            symbol: symbol.clone(),
673            err,
674        };
675        // If bulk-import failed, try again to find bad head or ref.
676        if !heads_imported && !index.has_id(id).await? {
677            git_backend
678                .import_head_commits([id])
679                .map_err(missing_ref_err)?;
680        }
681        store.get_commit_async(id).await.map_err(missing_ref_err)
682    };
683    // Uses iter_changed_refs() instead of new_referenced_heads to report error
684    // with ref name.
685    for update in iter_changed_refs() {
686        for id in update.new_target.added_ids() {
687            let commit = get_commit(id, &update.symbol).await?;
688            head_commits.push(commit);
689        }
690    }
691    // It's unlikely the imported commits were missing, but I/O-related error
692    // can still occur.
693    let imported_commits = mut_repo.index_commits(&head_commits).await?;
694    mut_repo.add_heads(&head_commits).await?;
695
696    // Apply the change that happened in git since last time we imported refs.
697    for (full_name, new_target) in changed_git_refs {
698        mut_repo.set_git_ref_target(&full_name, new_target);
699    }
700    for update in &changed_remote_bookmarks {
701        let symbol = update.symbol.as_ref();
702        let base_target = update.old_remote_ref.tracked_target();
703        let new_remote_ref = RemoteRef {
704            target: update.new_target.clone(),
705            state: if &update.old_remote_ref != RemoteRef::absent_ref() {
706                update.old_remote_ref.state
707            } else {
708                default_remote_ref_state_for(GitRefKind::Bookmark, symbol, options)
709            },
710        };
711        if new_remote_ref.is_tracked() {
712            mut_repo
713                .merge_local_bookmark(symbol.name, base_target, &new_remote_ref.target)
714                .await?;
715        }
716        // Remote-tracking branch is the last known state of the branch in the remote.
717        // It shouldn't diverge even if we had inconsistent view.
718        mut_repo.set_remote_bookmark(symbol, new_remote_ref);
719    }
720    for update in &changed_remote_tags {
721        let symbol = update.symbol.as_ref();
722        let base_target = update.old_remote_ref.tracked_target();
723        let new_remote_ref = RemoteRef {
724            target: update.new_target.clone(),
725            state: if &update.old_remote_ref != RemoteRef::absent_ref() {
726                update.old_remote_ref.state
727            } else {
728                default_remote_ref_state_for(GitRefKind::Tag, symbol, options)
729            },
730        };
731        if new_remote_ref.is_tracked() {
732            mut_repo
733                .merge_local_tag(symbol.name, base_target, &new_remote_ref.target)
734                .await?;
735        }
736        // Remote-tracking tag is the last known state of the tag in the remote.
737        // It shouldn't diverge even if we had inconsistent view.
738        mut_repo.set_remote_tag(symbol, new_remote_ref);
739    }
740
741    let any_old_referenced = !old_referenced_heads.is_empty();
742    let any_new_referenced = !new_referenced_heads.is_empty();
743    let old_visible_heads = RevsetExpression::commits(old_visible_heads);
744    let old_referenced_heads = RevsetExpression::commits(old_referenced_heads);
745    let new_referenced_heads = RevsetExpression::commits(new_referenced_heads);
746    let mut abandoned_commits = if options.abandon_unreachable_commits && any_old_referenced {
747        abandon_unreachable_commits(mut_repo, &old_referenced_heads).await?
748    } else {
749        vec![]
750    };
751    let rewritten_commit_ids = if options.record_synthetic_predecessors && any_new_referenced {
752        record_synthetic_predecessors(
753            mut_repo,
754            &old_visible_heads,
755            Diff::new(&old_referenced_heads, &new_referenced_heads),
756            &imported_commits,
757            // TODO: Maybe enable rewriting unconditionally? This should be more
758            // reliable than reachability-based heuristic.
759            options.abandon_unreachable_commits,
760        )
761        .await?
762    } else {
763        HashSet::new()
764    };
765    abandoned_commits.retain(|commit| !rewritten_commit_ids.contains(commit.id()));
766    let stats = GitImportStats {
767        abandoned_commits,
768        rewritten_commit_ids,
769        changed_remote_bookmarks,
770        changed_remote_tags,
771        failed_ref_names,
772    };
773    Ok(stats)
774}
775
776/// Finds commits that used to be reachable in git that no longer are reachable.
777/// Those commits will be recorded as abandoned in the `MutableRepo`.
778async fn abandon_unreachable_commits(
779    mut_repo: &mut MutableRepo,
780    hidable_git_heads: &Arc<ResolvedRevsetExpression>,
781) -> Result<Vec<Commit>, GitImportError> {
782    let pinned_expression = RevsetExpression::union_all(&[
783        // Local refs are usually visible, no need to filter out hidden
784        RevsetExpression::commits(pinned_commit_ids(mut_repo.view())),
785        RevsetExpression::commits(remotely_pinned_commit_ids(mut_repo.view()))
786            // Hidden remote refs should not contribute to pinning
787            .intersection(&RevsetExpression::visible_heads().ancestors()),
788        RevsetExpression::root(),
789    ]);
790    let abandoned_expression = pinned_expression
791        .range(hidable_git_heads)
792        // Don't include already-abandoned commits in GitImportStats
793        .intersection(&RevsetExpression::visible_heads().ancestors());
794    let abandoned_commits: Vec<_> = abandoned_expression
795        .evaluate(mut_repo)?
796        .stream()
797        .commits(mut_repo.store())
798        .try_collect()
799        .await?;
800    for commit in &abandoned_commits {
801        mut_repo.record_abandoned_commit(commit);
802    }
803    Ok(abandoned_commits)
804}
805
806/// Deduces predecessors of `old_visible_heads..new_referenced_heads` based on
807/// change IDs, records synthetic predecessors, and updates parent mappings.
808///
809/// The `imported_commits` should exclude any pre-existing commits, including
810/// those that were previously hidden.
811///
812/// Returns old commit IDs that have been mapped to the new commits.
813async fn record_synthetic_predecessors(
814    mut_repo: &mut MutableRepo,
815    old_visible_heads: &Arc<ResolvedRevsetExpression>,
816    Diff {
817        before: old_referenced_heads,
818        after: new_referenced_heads,
819    }: Diff<&Arc<ResolvedRevsetExpression>>,
820    imported_commits: &[Commit],
821    rewrite_commits: bool,
822) -> Result<HashSet<CommitId>, GitImportError> {
823    let build_change_to_commit_ids_map = async |expr: Arc<ResolvedRevsetExpression>| {
824        let mut change_to_commit_ids: HashMap<ChangeId, Vec<CommitId>> = HashMap::new();
825        let mut stream = expr.evaluate(mut_repo)?.commit_change_ids();
826        while let Some((commit_id, change_id)) = stream.try_next().await? {
827            let commit_ids = change_to_commit_ids.entry(change_id).or_default();
828            commit_ids.push(commit_id);
829        }
830        Ok::<_, GitImportError>(change_to_commit_ids)
831    };
832    let old_referenced_change_to_commit_ids =
833        build_change_to_commit_ids_map(new_referenced_heads.range(old_referenced_heads)).await?;
834    let new_referenced_change_to_commit_ids =
835        build_change_to_commit_ids_map(old_visible_heads.range(new_referenced_heads)).await?;
836    let imported_commit_ids: HashSet<_> = imported_commits.iter().map(Commit::id).collect();
837    let rewritable_commit_ids: HashSet<_> = if rewrite_commits {
838        // Similar to old_referenced_change_to_commit_ids, but doesn't include
839        // previously abandoned commits, which shouldn't be rewritten again.
840        new_referenced_heads
841            .range(old_referenced_heads)
842            .intersection(&old_visible_heads.ancestors())
843            .evaluate(mut_repo)?
844            .stream()
845            .try_collect()
846            .await?
847    } else {
848        HashSet::new()
849    };
850
851    let mut rewritten_commit_ids = HashSet::new();
852    for (change_id, new_commit_ids) in &new_referenced_change_to_commit_ids {
853        let predecessor_id: Option<CommitId>;
854        let rewrite_source_ids: &[CommitId];
855        if let Some(old_commit_ids) = old_referenced_change_to_commit_ids.get(change_id) {
856            // Pick the latest one if previously diverged. Divergence isn't
857            // usually resolved by "squashing" the commits.
858            predecessor_id = Some(old_commit_ids[0].clone());
859            rewrite_source_ids = old_commit_ids;
860        } else {
861            // Record as newly created commit
862            predecessor_id = None;
863            rewrite_source_ids = &[];
864        }
865        // Predecessors are recorded only for newly imported commits to prevent
866        // cycles in the evolution graph. While this restriction can be lifted
867        // later, note that the existing predecessor chain may be more detailed
868        // than "imported from Git" if the original commits were created locally.
869        for new_commit_id in new_commit_ids
870            .iter()
871            .filter(|&id| imported_commit_ids.contains(id))
872        {
873            mut_repo.set_predecessors(new_commit_id.clone(), predecessor_id.as_slice().to_vec());
874        }
875        let rewrite_source_ids = rewrite_source_ids
876            .iter()
877            .filter(|id| rewritable_commit_ids.contains(id));
878        if let [new_commit_id] = &**new_commit_ids {
879            for old_commit_id in rewrite_source_ids {
880                mut_repo.set_rewritten_commit(old_commit_id.clone(), new_commit_id.clone());
881                rewritten_commit_ids.insert(old_commit_id.clone());
882            }
883        } else {
884            for old_commit_id in rewrite_source_ids {
885                mut_repo
886                    .set_divergent_rewrite(old_commit_id.clone(), new_commit_ids.iter().cloned());
887                rewritten_commit_ids.insert(old_commit_id.clone());
888            }
889        }
890    }
891
892    Ok(rewritten_commit_ids)
893}
894
895/// Calculates diff of git refs to be imported.
896fn diff_refs_to_import(
897    view: &View,
898    git_repo: &gix::Repository,
899    all_remote_tags: bool,
900    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
901) -> Result<RefsToImport, GitImportError> {
902    let mut known_git_refs = view
903        .git_refs()
904        .iter()
905        .filter_map(|(full_name, target)| {
906            // TODO: or clean up invalid ref in case it was stored due to historical bug?
907            let (kind, symbol) =
908                parse_git_ref(full_name).expect("stored git ref should be parsable");
909            git_ref_filter(kind, symbol).then_some((full_name.as_ref(), target))
910        })
911        .collect();
912    let mut known_remote_bookmarks = view
913        .all_remote_bookmarks()
914        .filter(|&(symbol, _)| git_ref_filter(GitRefKind::Bookmark, symbol))
915        .map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
916        .collect();
917    let mut known_remote_tags = if all_remote_tags {
918        view.all_remote_tags()
919            .filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
920            .map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
921            .collect()
922    } else {
923        let remote = REMOTE_NAME_FOR_LOCAL_GIT_REPO;
924        view.remote_tags(remote)
925            .map(|(name, remote_ref)| (name.to_remote_symbol(remote), remote_ref))
926            .filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
927            .map(|(symbol, remote_ref)| (RemoteRefKey(symbol), remote_ref))
928            .collect()
929    };
930
931    // TODO: Refactor (all_remote_tags, git_ref_filter) in a way that
932    // uninteresting refs don't have to be scanned. For example, if the caller
933    // imports bookmark changes from a specific remote, we only need to walk
934    // refs/remotes/{remote}/.
935    let mut changed_git_refs = Vec::new();
936    let mut changed_remote_bookmarks = Vec::new();
937    let mut changed_remote_tags = Vec::new();
938    let mut failed_ref_names = Vec::new();
939    let actual = git_repo.references().map_err(GitImportError::from_git)?;
940    collect_changed_refs_to_import(
941        actual.local_branches().map_err(GitImportError::from_git)?,
942        &mut known_git_refs,
943        &mut known_remote_bookmarks,
944        &mut changed_git_refs,
945        &mut changed_remote_bookmarks,
946        &mut failed_ref_names,
947        &git_ref_filter,
948    )?;
949    collect_changed_refs_to_import(
950        actual.remote_branches().map_err(GitImportError::from_git)?,
951        &mut known_git_refs,
952        &mut known_remote_bookmarks,
953        &mut changed_git_refs,
954        &mut changed_remote_bookmarks,
955        &mut failed_ref_names,
956        &git_ref_filter,
957    )?;
958    collect_changed_refs_to_import(
959        actual.tags().map_err(GitImportError::from_git)?,
960        &mut known_git_refs,
961        &mut known_remote_tags,
962        &mut changed_git_refs,
963        &mut changed_remote_tags,
964        &mut failed_ref_names,
965        &git_ref_filter,
966    )?;
967    if all_remote_tags {
968        collect_changed_remote_tags_to_import(
969            actual
970                .prefixed(REMOTE_TAG_REF_NAMESPACE)
971                .map_err(GitImportError::from_git)?,
972            &mut known_remote_tags,
973            &mut changed_remote_tags,
974            &mut failed_ref_names,
975            &git_ref_filter,
976        )?;
977    }
978    for full_name in known_git_refs.into_keys() {
979        changed_git_refs.push((full_name.to_owned(), RefTarget::absent()));
980    }
981    for (RemoteRefKey(symbol), old) in known_remote_bookmarks {
982        if old.is_present() {
983            changed_remote_bookmarks.push(GitImportRefUpdate::new(
984                symbol.to_owned(),
985                old.clone(),
986                RefTarget::absent(),
987            ));
988        }
989    }
990    for (RemoteRefKey(symbol), old) in known_remote_tags {
991        if old.is_present() {
992            changed_remote_tags.push(GitImportRefUpdate::new(
993                symbol.to_owned(),
994                old.clone(),
995                RefTarget::absent(),
996            ));
997        }
998    }
999
1000    // Stabilize merge order and output.
1001    changed_git_refs.sort_unstable_by(|(name1, _), (name2, _)| name1.cmp(name2));
1002    changed_remote_bookmarks
1003        .sort_unstable_by(|update1, update2| update1.symbol.cmp(&update2.symbol));
1004    changed_remote_tags.sort_unstable_by(|update1, update2| update1.symbol.cmp(&update2.symbol));
1005    failed_ref_names.sort_unstable();
1006    Ok(RefsToImport {
1007        changed_git_refs,
1008        changed_remote_bookmarks,
1009        changed_remote_tags,
1010        failed_ref_names,
1011    })
1012}
1013
1014fn collect_changed_refs_to_import(
1015    actual_git_refs: gix::reference::iter::Iter,
1016    known_git_refs: &mut HashMap<&GitRefName, &RefTarget>,
1017    known_remote_refs: &mut HashMap<RemoteRefKey<'_>, &RemoteRef>,
1018    changed_git_refs: &mut Vec<(GitRefNameBuf, RefTarget)>,
1019    changed_remote_refs: &mut Vec<GitImportRefUpdate>,
1020    failed_ref_names: &mut Vec<BString>,
1021    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
1022) -> Result<(), GitImportError> {
1023    for git_ref in actual_git_refs {
1024        let git_ref = git_ref.map_err(GitImportError::from_git)?;
1025        let full_name_bytes = git_ref.name().as_bstr();
1026        let Ok(full_name) = str::from_utf8(full_name_bytes) else {
1027            // Non-utf8 refs cannot be imported.
1028            failed_ref_names.push(full_name_bytes.to_owned());
1029            continue;
1030        };
1031        if full_name.starts_with(RESERVED_REMOTE_REF_NAMESPACE) {
1032            failed_ref_names.push(full_name_bytes.to_owned());
1033            continue;
1034        }
1035        let full_name = GitRefName::new(full_name);
1036        let Some((kind, symbol)) = parse_git_ref(full_name) else {
1037            // Skip special refs such as refs/remotes/*/HEAD.
1038            continue;
1039        };
1040        if !git_ref_filter(kind, symbol) {
1041            continue;
1042        }
1043        let old_git_target = known_git_refs.get(full_name).copied().flatten();
1044        let old_git_oid = old_git_target.as_normal().map(oid_from_commit_id);
1045        let Some(oid) = resolve_git_ref_to_commit_id(&git_ref, old_git_oid) else {
1046            // Skip (or remove existing) invalid refs.
1047            continue;
1048        };
1049        let new_target = RefTarget::normal(CommitId::from_bytes(oid.as_bytes()));
1050        known_git_refs.remove(full_name);
1051        if new_target != *old_git_target {
1052            changed_git_refs.push((full_name.to_owned(), new_target.clone()));
1053        }
1054        // TODO: Make it configurable which remotes are publishing and update public
1055        // heads here.
1056        let old_remote_ref = known_remote_refs
1057            .remove(&symbol)
1058            .unwrap_or_else(|| RemoteRef::absent_ref());
1059        if new_target != old_remote_ref.target {
1060            changed_remote_refs.push(GitImportRefUpdate::new(
1061                symbol.to_owned(),
1062                old_remote_ref.clone(),
1063                new_target,
1064            ));
1065        }
1066    }
1067    Ok(())
1068}
1069
1070/// Similar to [`collect_changed_refs_to_import()`], but doesn't track Git ref
1071/// changes. Remote tags should be managed solely by jj.
1072fn collect_changed_remote_tags_to_import(
1073    actual_git_refs: gix::reference::iter::Iter,
1074    known_remote_refs: &mut HashMap<RemoteRefKey<'_>, &RemoteRef>,
1075    changed_remote_refs: &mut Vec<GitImportRefUpdate>,
1076    failed_ref_names: &mut Vec<BString>,
1077    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
1078) -> Result<(), GitImportError> {
1079    for git_ref in actual_git_refs {
1080        let git_ref = git_ref.map_err(GitImportError::from_git)?;
1081        let full_name_bytes = git_ref.name().as_bstr();
1082        let Ok(full_name) = str::from_utf8(full_name_bytes) else {
1083            // Non-utf8 refs cannot be imported.
1084            failed_ref_names.push(full_name_bytes.to_owned());
1085            continue;
1086        };
1087        let full_name = GitRefName::new(full_name);
1088        let Some((kind, symbol)) = parse_remote_tag_ref(full_name) else {
1089            // Skip invalid ref names.
1090            continue;
1091        };
1092        if !git_ref_filter(kind, symbol) {
1093            continue;
1094        }
1095        let old_remote_ref = known_remote_refs
1096            .get(&symbol)
1097            .copied()
1098            .unwrap_or_else(|| RemoteRef::absent_ref());
1099        let old_git_oid = old_remote_ref.target.as_normal().map(oid_from_commit_id);
1100        let Some(oid) = resolve_git_ref_to_commit_id(&git_ref, old_git_oid) else {
1101            // Skip (or remove existing) invalid refs.
1102            continue;
1103        };
1104        let new_target = RefTarget::normal(CommitId::from_bytes(oid.as_bytes()));
1105        known_remote_refs.remove(&symbol);
1106        if new_target != old_remote_ref.target {
1107            changed_remote_refs.push(GitImportRefUpdate::new(
1108                symbol.to_owned(),
1109                old_remote_ref.clone(),
1110                new_target,
1111            ));
1112        }
1113    }
1114    Ok(())
1115}
1116
1117fn default_remote_ref_state_for(
1118    kind: GitRefKind,
1119    symbol: RemoteRefSymbol<'_>,
1120    options: &GitImportOptions,
1121) -> RemoteRefState {
1122    match kind {
1123        GitRefKind::Bookmark => {
1124            if symbol.remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
1125                || options
1126                    .remote_auto_track_bookmarks
1127                    .get(symbol.remote)
1128                    .is_some_and(|matcher| matcher.is_match(symbol.name.as_str()))
1129            {
1130                RemoteRefState::Tracked
1131            } else {
1132                RemoteRefState::New
1133            }
1134        }
1135        // TODO: add option to not track tags by default?
1136        GitRefKind::Tag => RemoteRefState::Tracked,
1137    }
1138}
1139
1140/// Commits referenced by local branches or tags.
1141///
1142/// On `import_refs()`, this is similar to collecting commits referenced by
1143/// `view.git_refs()`. Main difference is that local branches can be moved by
1144/// tracking remotes, and such mutation isn't applied to `view.git_refs()` yet.
1145fn pinned_commit_ids(view: &View) -> Vec<CommitId> {
1146    itertools::chain(view.local_bookmarks(), view.local_tags())
1147        .flat_map(|(_, target)| target.added_ids())
1148        .cloned()
1149        .collect()
1150}
1151
1152/// Commits referenced by untracked remote bookmarks/tags including hidden ones.
1153///
1154/// Tracked remote refs aren't included because they should have been merged
1155/// into the local counterparts, and the changes pulled from one remote should
1156/// propagate to the other remotes on later push. OTOH, untracked remote refs
1157/// are considered independent refs.
1158fn remotely_pinned_commit_ids(view: &View) -> Vec<CommitId> {
1159    itertools::chain(view.all_remote_bookmarks(), view.all_remote_tags())
1160        .filter(|(_, remote_ref)| !remote_ref.is_tracked())
1161        .map(|(_, remote_ref)| &remote_ref.target)
1162        .flat_map(|target| target.added_ids())
1163        .cloned()
1164        .collect()
1165}
1166
1167/// Imports HEAD from the underlying Git repo.
1168///
1169/// Unlike `import_refs()`, the old HEAD branch is not abandoned because HEAD
1170/// move doesn't always mean the old HEAD branch has been rewritten.
1171///
1172/// Unlike `reset_head()`, this function doesn't move the working-copy commit to
1173/// the child of the new HEAD revision.
1174pub async fn import_head(
1175    mut_repo: &mut MutableRepo,
1176    workspace_name: &WorkspaceName,
1177    workspace_root: &Path,
1178) -> Result<(), GitImportError> {
1179    let store = mut_repo.store();
1180    let git_backend = get_git_backend(store)?;
1181    let git_repo = git_backend
1182        .open_git_repo_at_workdir(workspace_root)
1183        .map_err(GitImportError::from_git)?;
1184
1185    let old_git_head = mut_repo.view().git_head(workspace_name);
1186    let new_git_head_id = if let Ok(oid) = git_repo.head_id() {
1187        Some(CommitId::from_bytes(oid.as_bytes()))
1188    } else {
1189        None
1190    };
1191    if old_git_head.as_resolved() == Some(&new_git_head_id) {
1192        return Ok(());
1193    }
1194
1195    // Import new head
1196    if let Some(head_id) = &new_git_head_id {
1197        let index = mut_repo.index();
1198        if !index.has_id(head_id).await? {
1199            git_backend.import_head_commits([head_id]).map_err(|err| {
1200                GitImportError::MissingHeadTarget {
1201                    id: head_id.clone(),
1202                    err,
1203                }
1204            })?;
1205        }
1206        // It's unlikely the imported commits were missing, but I/O-related
1207        // error can still occur.
1208        let commit = store.get_commit_async(head_id).await?;
1209        mut_repo.add_head(&commit).await?;
1210    }
1211
1212    mut_repo.set_git_head_target(workspace_name, RefTarget::resolved(new_git_head_id));
1213    Ok(())
1214}
1215
1216/// Imports the HEAD commit without updating the view.
1217pub async fn import_head_commit(
1218    mut_repo: &mut MutableRepo,
1219) -> Result<Option<Commit>, GitImportError> {
1220    let store = mut_repo.store();
1221    let git_backend = get_git_backend(store)?;
1222    let git_repo = git_backend.git_repo();
1223
1224    let Ok(oid) = git_repo.head_id() else {
1225        return Ok(None);
1226    };
1227    let head_id = CommitId::from_bytes(oid.as_bytes());
1228
1229    let index = mut_repo.index();
1230    if !index.has_id(&head_id).await? {
1231        git_backend.import_head_commits([&head_id]).map_err(|err| {
1232            GitImportError::MissingHeadTarget {
1233                id: head_id.clone(),
1234                err,
1235            }
1236        })?;
1237    }
1238    // It's unlikely the imported commits were missing, but I/O-related error
1239    // can still occur.
1240    let commit = store.get_commit_async(&head_id).await?;
1241    mut_repo.add_head(&commit).await?;
1242    Ok(Some(commit))
1243}
1244
1245#[derive(Error, Debug)]
1246pub enum GitExportError {
1247    #[error(transparent)]
1248    Git(Box<dyn std::error::Error + Send + Sync>),
1249    #[error(transparent)]
1250    UnexpectedBackend(#[from] UnexpectedGitBackendError),
1251}
1252
1253impl GitExportError {
1254    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
1255        Self::Git(source.into())
1256    }
1257}
1258
1259/// The reason we failed to export a ref to Git.
1260#[derive(Debug, Error)]
1261pub enum FailedRefExportReason {
1262    /// The name is not allowed in Git.
1263    #[error("Name is not allowed in Git")]
1264    InvalidGitName,
1265    /// The ref was in a conflicted state from the last import. A re-import
1266    /// should fix it.
1267    #[error("Ref was in a conflicted state from the last import")]
1268    ConflictedOldState,
1269    /// The ref points to the root commit, which Git doesn't have.
1270    #[error("Ref cannot point to the root commit in Git")]
1271    OnRootCommit,
1272    /// We wanted to delete it, but it had been modified in Git.
1273    #[error("Deleted ref had been modified in Git")]
1274    DeletedInJjModifiedInGit,
1275    /// We wanted to add it, but Git had added it with a different target
1276    #[error("Added ref had been added with a different target in Git")]
1277    AddedInJjAddedInGit,
1278    /// We wanted to modify it, but Git had deleted it
1279    #[error("Modified ref had been deleted in Git")]
1280    ModifiedInJjDeletedInGit,
1281    /// Failed to delete the ref from the Git repo
1282    #[error("Failed to delete")]
1283    FailedToDelete(#[source] Box<dyn std::error::Error + Send + Sync>),
1284    /// Failed to set the ref in the Git repo
1285    #[error("Failed to set")]
1286    FailedToSet(#[source] Box<dyn std::error::Error + Send + Sync>),
1287}
1288
1289/// Describes changes made by [`export_refs()`].
1290#[derive(Debug)]
1291pub struct GitExportStats {
1292    /// Remote bookmarks that couldn't be exported, sorted by `symbol`.
1293    pub failed_bookmarks: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
1294    /// Remote tags that couldn't be exported, sorted by `symbol`.
1295    ///
1296    /// Since Git doesn't have remote tags, this list only contains `@git` tags.
1297    pub failed_tags: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
1298}
1299
1300#[derive(Debug)]
1301struct AllRefsToExport {
1302    bookmarks: RefsToExport,
1303    tags: RefsToExport,
1304}
1305
1306#[derive(Debug)]
1307struct RefsToExport {
1308    /// Remote `(symbol, (old_oid, new_oid))`s to update, sorted by `symbol`.
1309    to_update: Vec<(RemoteRefSymbolBuf, (Option<gix::ObjectId>, gix::ObjectId))>,
1310    /// Remote `(symbol, old_oid)`s to delete, sorted by `symbol`.
1311    ///
1312    /// Deletion has to be exported first to avoid conflict with new refs on
1313    /// file-system.
1314    to_delete: Vec<(RemoteRefSymbolBuf, gix::ObjectId)>,
1315    /// Remote refs that couldn't be exported, sorted by `symbol`.
1316    failed: Vec<(RemoteRefSymbolBuf, FailedRefExportReason)>,
1317}
1318
1319/// Export changes to bookmarks and tags made in the Jujutsu repo compared to
1320/// our last seen view of the Git repo in `mut_repo.view().git_refs()`.
1321///
1322/// We ignore changed refs that are conflicted (were also changed in the Git
1323/// repo compared to our last remembered view of the Git repo). These will be
1324/// marked conflicted by the next `jj git import`.
1325///
1326/// New/updated tags are exported as Git lightweight tags.
1327pub fn export_refs(mut_repo: &mut MutableRepo) -> Result<GitExportStats, GitExportError> {
1328    export_some_refs(mut_repo, |_, _| true)
1329}
1330
1331pub fn export_some_refs(
1332    mut_repo: &mut MutableRepo,
1333    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
1334) -> Result<GitExportStats, GitExportError> {
1335    fn get<'a, V>(map: &'a [(RemoteRefSymbolBuf, V)], key: RemoteRefSymbol<'_>) -> Option<&'a V> {
1336        debug_assert!(map.is_sorted_by_key(|(k, _)| k));
1337        let index = map.binary_search_by_key(&key, |(k, _)| k.as_ref()).ok()?;
1338        let (_, value) = &map[index];
1339        Some(value)
1340    }
1341
1342    let AllRefsToExport { bookmarks, tags } = diff_refs_to_export(
1343        mut_repo.view(),
1344        mut_repo.store().root_commit_id(),
1345        &git_ref_filter,
1346    );
1347
1348    let check_and_detach_head = |git_repo: &gix::Repository| -> Result<(), GitExportError> {
1349        let Ok(head_ref) = git_repo.find_reference("HEAD") else {
1350            return Ok(());
1351        };
1352        let target_name = head_ref.target().try_name().map(|name| name.to_owned());
1353        if let Some((kind, symbol)) = target_name
1354            .as_ref()
1355            .and_then(|name| str::from_utf8(name.as_bstr()).ok())
1356            .and_then(|name| parse_git_ref(name.as_ref()))
1357        {
1358            let old_target = head_ref.inner.target.clone();
1359            let current_oid = match head_ref.into_fully_peeled_id() {
1360                Ok(id) => Some(id.detach()),
1361                Err(gix::reference::peel::Error::ToId(
1362                    gix::refs::peel::to_id::Error::FollowToObject(
1363                        gix::refs::peel::to_object::Error::Follow(
1364                            gix::refs::file::find::existing::Error::NotFound { .. },
1365                        ),
1366                    ),
1367                )) => None, // Unborn ref should be considered absent
1368                Err(err) => return Err(GitExportError::from_git(err)),
1369            };
1370            let refs = match kind {
1371                GitRefKind::Bookmark => &bookmarks,
1372                GitRefKind::Tag => &tags,
1373            };
1374            let new_oid = if let Some((_old_oid, new_oid)) = get(&refs.to_update, symbol) {
1375                Some(new_oid)
1376            } else if get(&refs.to_delete, symbol).is_some() {
1377                None
1378            } else {
1379                current_oid.as_ref()
1380            };
1381            if new_oid != current_oid.as_ref() {
1382                update_git_head(
1383                    git_repo,
1384                    gix::refs::transaction::PreviousValue::MustExistAndMatch(old_target),
1385                    current_oid,
1386                )
1387                .map_err(GitExportError::from_git)?;
1388            }
1389        }
1390        Ok(())
1391    };
1392
1393    let git_repo = get_git_repo(mut_repo.store())?;
1394
1395    check_and_detach_head(&git_repo)?;
1396    for worktree in git_repo.worktrees().map_err(GitExportError::from_git)? {
1397        if let Ok(worktree_repo) = worktree.into_repo_with_possibly_inaccessible_worktree() {
1398            check_and_detach_head(&worktree_repo)?;
1399        }
1400    }
1401
1402    let failed_bookmarks = export_refs_to_git(mut_repo, &git_repo, GitRefKind::Bookmark, bookmarks);
1403    let failed_tags = export_refs_to_git(mut_repo, &git_repo, GitRefKind::Tag, tags);
1404
1405    copy_exportable_local_bookmarks_to_remote_view(
1406        mut_repo,
1407        REMOTE_NAME_FOR_LOCAL_GIT_REPO,
1408        |name| {
1409            let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
1410            git_ref_filter(GitRefKind::Bookmark, symbol) && get(&failed_bookmarks, symbol).is_none()
1411        },
1412    );
1413    copy_exportable_local_tags_to_remote_view(mut_repo, REMOTE_NAME_FOR_LOCAL_GIT_REPO, |name| {
1414        let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
1415        git_ref_filter(GitRefKind::Tag, symbol) && get(&failed_tags, symbol).is_none()
1416    });
1417
1418    Ok(GitExportStats {
1419        failed_bookmarks,
1420        failed_tags,
1421    })
1422}
1423
1424fn export_refs_to_git(
1425    mut_repo: &mut MutableRepo,
1426    git_repo: &gix::Repository,
1427    kind: GitRefKind,
1428    refs: RefsToExport,
1429) -> Vec<(RemoteRefSymbolBuf, FailedRefExportReason)> {
1430    let mut failed = refs.failed;
1431    for (symbol, old_oid) in refs.to_delete {
1432        let Some(git_ref_name) = to_git_ref_name(kind, symbol.as_ref()) else {
1433            failed.push((symbol, FailedRefExportReason::InvalidGitName));
1434            continue;
1435        };
1436        if let Err(reason) = delete_git_ref(git_repo, &git_ref_name, &old_oid) {
1437            failed.push((symbol, reason));
1438        } else {
1439            let new_target = RefTarget::absent();
1440            mut_repo.set_git_ref_target(&git_ref_name, new_target);
1441        }
1442    }
1443    for (symbol, (old_commit_oid, new_commit_oid)) in refs.to_update {
1444        let Some(git_ref_name) = to_git_ref_name(kind, symbol.as_ref()) else {
1445            failed.push((symbol, FailedRefExportReason::InvalidGitName));
1446            continue;
1447        };
1448        let new_ref_oid = match kind {
1449            GitRefKind::Bookmark => None,
1450            // Copy existing tag ref, which may point to annotated tag object.
1451            GitRefKind::Tag => {
1452                let remote_matcher = StringMatcher::all();
1453                find_git_tag_oid_to_copy(
1454                    mut_repo.view(),
1455                    git_repo,
1456                    &symbol.name,
1457                    &remote_matcher,
1458                    &new_commit_oid,
1459                )
1460            }
1461        };
1462        if let Err(reason) = update_git_ref(
1463            git_repo,
1464            &git_ref_name,
1465            old_commit_oid,
1466            new_commit_oid,
1467            new_ref_oid,
1468        ) {
1469            failed.push((symbol, reason));
1470        } else {
1471            let new_target = RefTarget::normal(CommitId::from_bytes(new_commit_oid.as_bytes()));
1472            mut_repo.set_git_ref_target(&git_ref_name, new_target);
1473        }
1474    }
1475
1476    // Stabilize output, allow binary search.
1477    failed.sort_unstable_by(|(name1, _), (name2, _)| name1.cmp(name2));
1478    failed
1479}
1480
1481fn copy_exportable_local_bookmarks_to_remote_view(
1482    mut_repo: &mut MutableRepo,
1483    remote: &RemoteName,
1484    name_filter: impl Fn(&RefName) -> bool,
1485) {
1486    let new_local_bookmarks = mut_repo
1487        .view()
1488        .local_remote_bookmarks(remote)
1489        .filter_map(|(name, targets)| {
1490            // TODO: filter out untracked bookmarks (if we add support for untracked @git
1491            // bookmarks)
1492            let old_target = &targets.remote_ref.target;
1493            let new_target = targets.local_target;
1494            (!new_target.has_conflict() && old_target != new_target).then_some((name, new_target))
1495        })
1496        .filter(|&(name, _)| name_filter(name))
1497        .map(|(name, new_target)| (name.to_owned(), new_target.clone()))
1498        .collect_vec();
1499    for (name, new_target) in new_local_bookmarks {
1500        let new_remote_ref = RemoteRef {
1501            target: new_target,
1502            state: RemoteRefState::Tracked,
1503        };
1504        mut_repo.set_remote_bookmark(name.to_remote_symbol(remote), new_remote_ref);
1505    }
1506}
1507
1508fn copy_exportable_local_tags_to_remote_view(
1509    mut_repo: &mut MutableRepo,
1510    remote: &RemoteName,
1511    name_filter: impl Fn(&RefName) -> bool,
1512) {
1513    let new_local_tags = mut_repo
1514        .view()
1515        .local_remote_tags(remote)
1516        .filter_map(|(name, targets)| {
1517            // TODO: filter out untracked tags (if we add support for untracked @git tags)
1518            let old_target = &targets.remote_ref.target;
1519            let new_target = targets.local_target;
1520            (!new_target.has_conflict() && old_target != new_target).then_some((name, new_target))
1521        })
1522        .filter(|&(name, _)| name_filter(name))
1523        .map(|(name, new_target)| (name.to_owned(), new_target.clone()))
1524        .collect_vec();
1525    for (name, new_target) in new_local_tags {
1526        let new_remote_ref = RemoteRef {
1527            target: new_target,
1528            state: RemoteRefState::Tracked,
1529        };
1530        mut_repo.set_remote_tag(name.to_remote_symbol(remote), new_remote_ref);
1531    }
1532}
1533
1534/// Calculates diff of bookmarks and tags to be exported.
1535fn diff_refs_to_export(
1536    view: &View,
1537    root_commit_id: &CommitId,
1538    git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
1539) -> AllRefsToExport {
1540    // Local targets will be copied to the "git" remote if successfully exported. So
1541    // the local refs are considered to be the new "git" remote refs.
1542    let mut all_bookmark_targets: HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)> =
1543        itertools::chain(
1544            view.local_bookmarks().map(|(name, target)| {
1545                let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
1546                (symbol, target)
1547            }),
1548            view.all_remote_bookmarks()
1549                .filter(|&(symbol, _)| symbol.remote != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
1550                .map(|(symbol, remote_ref)| (symbol, &remote_ref.target)),
1551        )
1552        .filter(|&(symbol, _)| git_ref_filter(GitRefKind::Bookmark, symbol))
1553        .map(|(symbol, new_target)| (symbol, (RefTarget::absent_ref(), new_target)))
1554        .collect();
1555    // Remote tags aren't included because Git has no such concept.
1556    let mut all_tag_targets: HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)> = view
1557        .local_tags()
1558        .map(|(name, target)| {
1559            let symbol = name.to_remote_symbol(REMOTE_NAME_FOR_LOCAL_GIT_REPO);
1560            (symbol, target)
1561        })
1562        .filter(|&(symbol, _)| git_ref_filter(GitRefKind::Tag, symbol))
1563        .map(|(symbol, new_target)| (symbol, (RefTarget::absent_ref(), new_target)))
1564        .collect();
1565    let known_git_refs = view
1566        .git_refs()
1567        .iter()
1568        .map(|(full_name, target)| {
1569            let (kind, symbol) =
1570                parse_git_ref(full_name).expect("stored git ref should be parsable");
1571            ((kind, symbol), target)
1572        })
1573        // There are two situations where remote refs get out of sync:
1574        // 1. `jj bookmark forget --include-remotes`
1575        // 2. `jj op revert`/`restore` in colocated repo
1576        .filter(|&((kind, symbol), _)| git_ref_filter(kind, symbol));
1577    for ((kind, symbol), target) in known_git_refs {
1578        let ref_targets = match kind {
1579            GitRefKind::Bookmark => &mut all_bookmark_targets,
1580            GitRefKind::Tag => &mut all_tag_targets,
1581        };
1582        ref_targets
1583            .entry(symbol)
1584            .and_modify(|(old_target, _)| *old_target = target)
1585            .or_insert((target, RefTarget::absent_ref()));
1586    }
1587
1588    let root_commit_target = RefTarget::normal(root_commit_id.clone());
1589    let bookmarks = collect_changed_refs_to_export(&all_bookmark_targets, &root_commit_target);
1590    let tags = collect_changed_refs_to_export(&all_tag_targets, &root_commit_target);
1591    AllRefsToExport { bookmarks, tags }
1592}
1593
1594fn collect_changed_refs_to_export(
1595    old_new_ref_targets: &HashMap<RemoteRefSymbol, (&RefTarget, &RefTarget)>,
1596    root_commit_target: &RefTarget,
1597) -> RefsToExport {
1598    let mut to_update = Vec::new();
1599    let mut to_delete = Vec::new();
1600    let mut failed = Vec::new();
1601    for (&symbol, &(old_target, new_target)) in old_new_ref_targets {
1602        if new_target == old_target {
1603            continue;
1604        }
1605        if new_target == root_commit_target {
1606            // Git doesn't have a root commit
1607            failed.push((symbol.to_owned(), FailedRefExportReason::OnRootCommit));
1608            continue;
1609        }
1610        let old_oid = if let Some(id) = old_target.as_normal() {
1611            Some(owned_oid_from_commit_id(id))
1612        } else if old_target.has_conflict() {
1613            // The old git ref should only be a conflict if there were concurrent import
1614            // operations while the value changed. Don't overwrite these values.
1615            failed.push((symbol.to_owned(), FailedRefExportReason::ConflictedOldState));
1616            continue;
1617        } else {
1618            assert!(old_target.is_absent());
1619            None
1620        };
1621        if let Some(id) = new_target.as_normal() {
1622            let new_oid = owned_oid_from_commit_id(id);
1623            to_update.push((symbol.to_owned(), (old_oid, new_oid)));
1624        } else if new_target.has_conflict() {
1625            // Skip conflicts and leave the old value in git_refs
1626            continue;
1627        } else {
1628            assert!(new_target.is_absent());
1629            to_delete.push((symbol.to_owned(), old_oid.unwrap()));
1630        }
1631    }
1632
1633    // Stabilize export order and output, allow binary search.
1634    to_update.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
1635    to_delete.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
1636    failed.sort_unstable_by(|(sym1, _), (sym2, _)| sym1.cmp(sym2));
1637    RefsToExport {
1638        to_update,
1639        to_delete,
1640        failed,
1641    }
1642}
1643
1644/// Looks up tracked remote tag refs and returns the ref target object ID if
1645/// peeled to the given commit ID.
1646fn find_git_tag_oid_to_copy(
1647    view: &View,
1648    git_repo: &gix::Repository,
1649    name: &RefName,
1650    remote_matcher: &StringMatcher,
1651    commit_oid: &gix::oid,
1652) -> Option<gix::ObjectId> {
1653    // Filter candidates by tag name and known commit id first
1654    view.remote_tags_matching(&StringMatcher::exact(name), remote_matcher)
1655        .filter(|(_, remote_ref)| {
1656            let maybe_id = remote_ref.tracked_target().as_normal();
1657            maybe_id.is_some_and(|id| id.as_bytes() == commit_oid.as_bytes())
1658        })
1659        // Query existing Git ref and tag object
1660        .filter_map(|(symbol, _)| {
1661            let git_ref_name = to_git_or_remote_tag_ref_name(symbol);
1662            git_repo.find_reference(git_ref_name.as_str()).ok()
1663        })
1664        // This usually holds because remote tags are managed by jj, but jj's
1665        // view may be updated independently by undo/redo commands.
1666        .filter(|git_ref| {
1667            resolve_git_ref_to_commit_id(git_ref, Some(commit_oid)).as_deref() == Some(commit_oid)
1668        })
1669        .find_map(|git_ref| git_ref.inner.target.try_into_id().ok())
1670}
1671
1672fn delete_git_ref(
1673    git_repo: &gix::Repository,
1674    git_ref_name: &GitRefName,
1675    old_oid: &gix::oid,
1676) -> Result<(), FailedRefExportReason> {
1677    let Some(git_ref) = git_repo
1678        .try_find_reference(git_ref_name.as_str())
1679        .map_err(|err| FailedRefExportReason::FailedToDelete(err.into()))?
1680    else {
1681        // The ref is already deleted
1682        return Ok(());
1683    };
1684    if resolve_git_ref_to_commit_id(&git_ref, Some(old_oid)).as_deref() == Some(old_oid) {
1685        // The ref has not been updated by git, so go ahead and delete it
1686        git_ref
1687            .delete()
1688            .map_err(|err| FailedRefExportReason::FailedToDelete(err.into()))
1689    } else {
1690        // The ref was updated by git
1691        Err(FailedRefExportReason::DeletedInJjModifiedInGit)
1692    }
1693}
1694
1695/// Creates new ref pointing to `new_ref_oid` or (peeled) `new_commit_oid`.
1696fn create_git_ref(
1697    git_repo: &gix::Repository,
1698    git_ref_name: &GitRefName,
1699    new_commit_oid: gix::ObjectId,
1700    new_ref_oid: Option<gix::ObjectId>,
1701) -> Result<(), FailedRefExportReason> {
1702    let new_oid = new_ref_oid.unwrap_or(new_commit_oid);
1703    let constraint = gix::refs::transaction::PreviousValue::MustNotExist;
1704    let Err(set_err) =
1705        git_repo.reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
1706    else {
1707        // The ref was added in jj but still doesn't exist in git
1708        return Ok(());
1709    };
1710    let Some(git_ref) = git_repo
1711        .try_find_reference(git_ref_name.as_str())
1712        .map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?
1713    else {
1714        return Err(FailedRefExportReason::FailedToSet(set_err.into()));
1715    };
1716    // The ref was added in jj and in git. We're good if and only if git
1717    // pointed it to our desired target.
1718    if resolve_git_ref_to_commit_id(&git_ref, None) == Some(new_commit_oid) {
1719        Ok(())
1720    } else {
1721        Err(FailedRefExportReason::AddedInJjAddedInGit)
1722    }
1723}
1724
1725/// Updates existing ref to point to `new_ref_oid` or (peeled) `new_commit_oid`.
1726fn move_git_ref(
1727    git_repo: &gix::Repository,
1728    git_ref_name: &GitRefName,
1729    old_commit_oid: gix::ObjectId,
1730    new_commit_oid: gix::ObjectId,
1731    new_ref_oid: Option<gix::ObjectId>,
1732) -> Result<(), FailedRefExportReason> {
1733    let new_oid = new_ref_oid.unwrap_or(new_commit_oid);
1734    let constraint =
1735        gix::refs::transaction::PreviousValue::MustExistAndMatch(old_commit_oid.into());
1736    let Err(set_err) =
1737        git_repo.reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
1738    else {
1739        // Successfully updated from old_oid to new_oid (unchanged in git)
1740        return Ok(());
1741    };
1742    // The reference was probably updated in git
1743    let Some(git_ref) = git_repo
1744        .try_find_reference(git_ref_name.as_str())
1745        .map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?
1746    else {
1747        // The reference was deleted in git and moved in jj
1748        return Err(FailedRefExportReason::ModifiedInJjDeletedInGit);
1749    };
1750    // We still consider this a success if it was updated to our desired target
1751    let git_commit_oid = resolve_git_ref_to_commit_id(&git_ref, Some(&old_commit_oid));
1752    if git_commit_oid == Some(new_commit_oid) {
1753        Ok(())
1754    } else if git_commit_oid == Some(old_commit_oid) {
1755        // The reference would point to annotated tag, try again
1756        let constraint =
1757            gix::refs::transaction::PreviousValue::MustExistAndMatch(git_ref.inner.target);
1758        git_repo
1759            .reference(git_ref_name.as_str(), new_oid, constraint, "export from jj")
1760            .map_err(|err| FailedRefExportReason::FailedToSet(err.into()))?;
1761        Ok(())
1762    } else {
1763        Err(FailedRefExportReason::FailedToSet(set_err.into()))
1764    }
1765}
1766
1767fn update_git_ref(
1768    git_repo: &gix::Repository,
1769    git_ref_name: &GitRefName,
1770    old_commit_oid: Option<gix::ObjectId>,
1771    new_commit_oid: gix::ObjectId,
1772    new_ref_oid: Option<gix::ObjectId>,
1773) -> Result<(), FailedRefExportReason> {
1774    match old_commit_oid {
1775        None => create_git_ref(git_repo, git_ref_name, new_commit_oid, new_ref_oid),
1776        Some(old_oid) => move_git_ref(git_repo, git_ref_name, old_oid, new_commit_oid, new_ref_oid),
1777    }
1778}
1779
1780/// Ensures Git HEAD is detached and pointing to the `new_oid`. If `new_oid`
1781/// is `None` (meaning absent), dummy placeholder ref will be set.
1782fn update_git_head(
1783    git_repo: &gix::Repository,
1784    expected_ref: gix::refs::transaction::PreviousValue,
1785    new_oid: Option<gix::ObjectId>,
1786) -> Result<(), gix::reference::edit::Error> {
1787    let mut ref_edits = Vec::new();
1788    let new_target = if let Some(oid) = new_oid {
1789        gix::refs::Target::Object(oid)
1790    } else {
1791        // Can't detach HEAD without a commit. Use placeholder ref to nullify
1792        // the HEAD. The placeholder ref isn't a normal branch ref. Git CLI
1793        // appears to deal with that, and can move the placeholder ref. So we
1794        // need to ensure that the ref doesn't exist.
1795        ref_edits.push(gix::refs::transaction::RefEdit {
1796            change: gix::refs::transaction::Change::Delete {
1797                expected: gix::refs::transaction::PreviousValue::Any,
1798                log: gix::refs::transaction::RefLog::AndReference,
1799            },
1800            name: UNBORN_ROOT_REF_NAME.try_into().unwrap(),
1801            deref: false,
1802        });
1803        gix::refs::Target::Symbolic(UNBORN_ROOT_REF_NAME.try_into().unwrap())
1804    };
1805    ref_edits.push(gix::refs::transaction::RefEdit {
1806        change: gix::refs::transaction::Change::Update {
1807            log: gix::refs::transaction::LogChange {
1808                message: "export from jj".into(),
1809                ..Default::default()
1810            },
1811            expected: expected_ref,
1812            new: new_target,
1813        },
1814        name: "HEAD".try_into().unwrap(),
1815        deref: false,
1816    });
1817    git_repo.edit_references(ref_edits)?;
1818    Ok(())
1819}
1820
1821#[derive(Debug, Error)]
1822pub enum GitResetHeadError {
1823    #[error(transparent)]
1824    Backend(#[from] BackendError),
1825    #[error(transparent)]
1826    Git(Box<dyn std::error::Error + Send + Sync>),
1827    #[error("Failed to update Git HEAD ref")]
1828    UpdateHeadRef(#[source] Box<gix::reference::edit::Error>),
1829    #[error(transparent)]
1830    UnexpectedBackend(#[from] UnexpectedGitBackendError),
1831}
1832
1833impl GitResetHeadError {
1834    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
1835        Self::Git(source.into())
1836    }
1837}
1838
1839/// Sets Git HEAD to the parent of the given working-copy commit and resets
1840/// the Git index.
1841pub async fn reset_head(
1842    mut_repo: &mut MutableRepo,
1843    workspace_name: &WorkspaceName,
1844    workspace_root: &Path,
1845    wc_commit: &Commit,
1846) -> Result<(), GitResetHeadError> {
1847    let git_backend = get_git_backend(mut_repo.store())?;
1848    let git_repo = git_backend
1849        .open_git_repo_at_workdir(workspace_root)
1850        .map_err(GitResetHeadError::from_git)?;
1851
1852    let first_parent_id = &wc_commit.parent_ids()[0];
1853    let new_head_target = if first_parent_id != mut_repo.store().root_commit_id() {
1854        RefTarget::normal(first_parent_id.clone())
1855    } else {
1856        RefTarget::absent()
1857    };
1858
1859    // If the first parent of the working copy has changed, reset the Git HEAD.
1860    let old_head_target = mut_repo.git_head(workspace_name);
1861    if *old_head_target != new_head_target {
1862        let expected_ref = if let Some(id) = old_head_target.as_normal() {
1863            // We have to check the actual HEAD state because we don't record a
1864            // symbolic ref as such.
1865            let actual_head = git_repo.head().map_err(GitResetHeadError::from_git)?;
1866            if actual_head.is_detached() {
1867                let id = owned_oid_from_commit_id(id);
1868                gix::refs::transaction::PreviousValue::MustExistAndMatch(id.into())
1869            } else {
1870                // Just overwrite symbolic ref, which is unusual. Alternatively,
1871                // maybe we can test the target ref by issuing noop edit.
1872                gix::refs::transaction::PreviousValue::MustExist
1873            }
1874        } else {
1875            // Just overwrite if unborn (or conflict), which is also unusual.
1876            gix::refs::transaction::PreviousValue::MustExist
1877        };
1878        let new_oid = new_head_target.as_normal().map(owned_oid_from_commit_id);
1879        update_git_head(&git_repo, expected_ref, new_oid)
1880            .map_err(|err| GitResetHeadError::UpdateHeadRef(err.into()))?;
1881        mut_repo.set_git_head_target(workspace_name, new_head_target);
1882    }
1883
1884    // If there is an ongoing operation (merge, rebase, etc.), we need to clean it
1885    // up.
1886    if git_repo.state().is_some() {
1887        clear_operation_state(&git_repo)?;
1888    }
1889
1890    reset_index(mut_repo, &git_repo, wc_commit).await
1891}
1892
1893// TODO: Polish and upstream this to `gix`.
1894fn clear_operation_state(git_repo: &gix::Repository) -> Result<(), GitResetHeadError> {
1895    // Based on the files `git2::Repository::cleanup_state` deletes; when
1896    // upstreaming this logic should probably become more elaborate to match
1897    // `git(1)` behavior.
1898    const STATE_FILE_NAMES: &[&str] = &[
1899        "MERGE_HEAD",
1900        "MERGE_MODE",
1901        "MERGE_MSG",
1902        "REVERT_HEAD",
1903        "CHERRY_PICK_HEAD",
1904        "BISECT_LOG",
1905    ];
1906    const STATE_DIR_NAMES: &[&str] = &["rebase-merge", "rebase-apply", "sequencer"];
1907    let handle_err = |err: PathError| match err.source.kind() {
1908        std::io::ErrorKind::NotFound => Ok(()),
1909        _ => Err(GitResetHeadError::from_git(err)),
1910    };
1911    for file_name in STATE_FILE_NAMES {
1912        let path = git_repo.path().join(file_name);
1913        std::fs::remove_file(&path)
1914            .context(&path)
1915            .or_else(handle_err)?;
1916    }
1917    for dir_name in STATE_DIR_NAMES {
1918        let path = git_repo.path().join(dir_name);
1919        std::fs::remove_dir_all(&path)
1920            .context(&path)
1921            .or_else(handle_err)?;
1922    }
1923    Ok(())
1924}
1925
1926async fn reset_index(
1927    repo: &dyn Repo,
1928    git_repo: &gix::Repository,
1929    wc_commit: &Commit,
1930) -> Result<(), GitResetHeadError> {
1931    let parent_tree = wc_commit.parent_tree(repo).await?;
1932    // Use the merged parent tree as the Git index, allowing `git diff` to show the
1933    // same changes as `jj diff`. If the merged parent tree has conflicts, then the
1934    // Git index will also be conflicted.
1935    let mut index = if let Some(tree_id) = parent_tree.tree_ids().as_resolved() {
1936        if tree_id == repo.store().empty_tree_id() {
1937            // If the tree is empty, gix can fail to load the object (since Git doesn't
1938            // require the empty tree to actually be present in the object database), so we
1939            // just use an empty index directly.
1940            gix::index::File::from_state(
1941                gix::index::State::new(git_repo.object_hash()),
1942                git_repo.index_path(),
1943            )
1944        } else {
1945            // If the parent tree is resolved, we can use gix's `index_from_tree` method.
1946            // This is more efficient than iterating over the tree and adding each entry.
1947            git_repo
1948                .index_from_tree(&gix::ObjectId::from_bytes_or_panic(tree_id.as_bytes()))
1949                .map_err(GitResetHeadError::from_git)?
1950        }
1951    } else {
1952        build_index_from_merged_tree(git_repo, &parent_tree)?
1953    };
1954
1955    let wc_tree = wc_commit.tree();
1956    update_intent_to_add_impl(git_repo, &mut index, &parent_tree, &wc_tree).await?;
1957
1958    // Match entries in the new index with entries in the old index, and copy stat
1959    // information if the entry didn't change.
1960    if let Some(old_index) = git_repo.try_index().map_err(GitResetHeadError::from_git)? {
1961        index
1962            .entries_mut_with_paths()
1963            .merge_join_by(old_index.entries(), |(entry, path), old_entry| {
1964                gix::index::Entry::cmp_filepaths(path, old_entry.path(&old_index))
1965                    .then_with(|| entry.stage().cmp(&old_entry.stage()))
1966            })
1967            .filter_map(|merged| merged.both())
1968            .map(|((entry, _), old_entry)| (entry, old_entry))
1969            .filter(|(entry, old_entry)| entry.id == old_entry.id && entry.mode == old_entry.mode)
1970            .for_each(|(entry, old_entry)| entry.stat = old_entry.stat);
1971    }
1972
1973    debug_assert!(index.verify_entries().is_ok());
1974
1975    index
1976        .write(gix::index::write::Options::default())
1977        .map_err(GitResetHeadError::from_git)
1978}
1979
1980fn build_index_from_merged_tree(
1981    git_repo: &gix::Repository,
1982    merged_tree: &MergedTree,
1983) -> Result<gix::index::File, GitResetHeadError> {
1984    let mut index = gix::index::File::from_state(
1985        gix::index::State::new(git_repo.object_hash()),
1986        git_repo.index_path(),
1987    );
1988
1989    let mut push_index_entry =
1990        |path: &RepoPath, maybe_entry: &Option<TreeValue>, stage: gix::index::entry::Stage| {
1991            let Some(entry) = maybe_entry else {
1992                return;
1993            };
1994
1995            let (id, mode) = match entry {
1996                TreeValue::File {
1997                    id,
1998                    executable,
1999                    copy_id: _,
2000                } => {
2001                    if *executable {
2002                        (id.as_bytes(), gix::index::entry::Mode::FILE_EXECUTABLE)
2003                    } else {
2004                        (id.as_bytes(), gix::index::entry::Mode::FILE)
2005                    }
2006                }
2007                TreeValue::Symlink(id) => (id.as_bytes(), gix::index::entry::Mode::SYMLINK),
2008                TreeValue::Tree(_) => {
2009                    // This case is only possible if there is a file-directory conflict, since
2010                    // `MergedTree::entries` handles the recursion otherwise. We only materialize a
2011                    // file in the working copy for file-directory conflicts, so we don't add the
2012                    // tree to the index here either.
2013                    return;
2014                }
2015                TreeValue::GitSubmodule(id) => (id.as_bytes(), gix::index::entry::Mode::COMMIT),
2016            };
2017
2018            let path = BStr::new(path.as_internal_file_string());
2019
2020            // It is safe to push the entry because we ensure that we only add each path to
2021            // a stage once, and we sort the entries after we finish adding them.
2022            index.dangerously_push_entry(
2023                gix::index::entry::Stat::default(),
2024                gix::ObjectId::from_bytes_or_panic(id),
2025                gix::index::entry::Flags::from_stage(stage),
2026                mode,
2027                path,
2028            );
2029        };
2030
2031    let mut has_many_sided_conflict = false;
2032
2033    for (path, entry) in merged_tree.entries() {
2034        let entry = entry?;
2035        if let Some(resolved) = entry.as_resolved() {
2036            push_index_entry(&path, resolved, gix::index::entry::Stage::Unconflicted);
2037            continue;
2038        }
2039
2040        let conflict = entry.simplify();
2041        if let [left, base, right] = conflict.as_slice() {
2042            // 2-sided conflicts can be represented in the Git index
2043            push_index_entry(&path, left, gix::index::entry::Stage::Ours);
2044            push_index_entry(&path, base, gix::index::entry::Stage::Base);
2045            push_index_entry(&path, right, gix::index::entry::Stage::Theirs);
2046        } else {
2047            // We can't represent many-sided conflicts in the Git index, so just add the
2048            // first side as staged. This is preferable to adding the first 2 sides as a
2049            // conflict, since some tools rely on being able to resolve conflicts using the
2050            // index, which could lead to an incorrect conflict resolution if the index
2051            // didn't contain all of the conflict sides. Instead, we add a dummy conflict of
2052            // a file named ".jj-do-not-resolve-this-conflict" to prevent the user from
2053            // accidentally committing the conflict markers.
2054            has_many_sided_conflict = true;
2055            push_index_entry(
2056                &path,
2057                conflict.first(),
2058                gix::index::entry::Stage::Unconflicted,
2059            );
2060        }
2061    }
2062
2063    // Required after `dangerously_push_entry` for correctness. We use do a lookup
2064    // in the index after this, so it must be sorted before we do the lookup.
2065    index.sort_entries();
2066
2067    // If the conflict had an unrepresentable conflict and the dummy file path isn't
2068    // already added in the index, add a dummy file as a conflict.
2069    if has_many_sided_conflict
2070        && index
2071            .entry_index_by_path(INDEX_DUMMY_CONFLICT_FILE.into())
2072            .is_err()
2073    {
2074        let file_blob = git_repo
2075            .write_blob(
2076                b"The working copy commit contains conflicts which cannot be resolved using Git.\n",
2077            )
2078            .map_err(GitResetHeadError::from_git)?;
2079        index.dangerously_push_entry(
2080            gix::index::entry::Stat::default(),
2081            file_blob.detach(),
2082            gix::index::entry::Flags::from_stage(gix::index::entry::Stage::Ours),
2083            gix::index::entry::Mode::FILE,
2084            INDEX_DUMMY_CONFLICT_FILE.into(),
2085        );
2086        // We need to sort again for correctness before writing the index file since we
2087        // added a new entry.
2088        index.sort_entries();
2089    }
2090
2091    Ok(index)
2092}
2093
2094/// Diff `old_tree` to `new_tree` and mark added files as intent-to-add in the
2095/// Git index. Also removes current intent-to-add entries in the index if they
2096/// were removed in the diff.
2097///
2098/// Should be called when the diff between the working-copy commit and its
2099/// parent(s) has changed.
2100pub async fn update_intent_to_add(
2101    repo: &dyn Repo,
2102    workspace_root: &Path,
2103    old_tree: &MergedTree,
2104    new_tree: &MergedTree,
2105) -> Result<(), GitResetHeadError> {
2106    let git_backend = get_git_backend(repo.store())?;
2107    let git_repo = git_backend
2108        .open_git_repo_at_workdir(workspace_root)
2109        .map_err(GitResetHeadError::from_git)?;
2110    let mut index = git_repo
2111        .index_or_empty()
2112        .map_err(GitResetHeadError::from_git)?;
2113    let mut_index = Arc::make_mut(&mut index);
2114    update_intent_to_add_impl(&git_repo, mut_index, old_tree, new_tree).await?;
2115    debug_assert!(mut_index.verify_entries().is_ok());
2116    mut_index
2117        .write(gix::index::write::Options::default())
2118        .map_err(GitResetHeadError::from_git)?;
2119
2120    Ok(())
2121}
2122
2123async fn update_intent_to_add_impl(
2124    git_repo: &gix::Repository,
2125    index: &mut gix::index::File,
2126    old_tree: &MergedTree,
2127    new_tree: &MergedTree,
2128) -> Result<(), GitResetHeadError> {
2129    let mut diff_stream = old_tree.diff_stream(new_tree, &EverythingMatcher);
2130    let mut added_paths = vec![];
2131    let mut removed_paths = HashSet::new();
2132    while let Some(TreeDiffEntry { path, values }) = diff_stream.next().await {
2133        let values = values?;
2134        if values.before.is_absent() {
2135            let executable = match values.after.as_normal() {
2136                Some(TreeValue::File {
2137                    id: _,
2138                    executable,
2139                    copy_id: _,
2140                }) => *executable,
2141                Some(TreeValue::Symlink(_)) => false,
2142                _ => {
2143                    continue;
2144                }
2145            };
2146            if index
2147                .entry_index_by_path(BStr::new(path.as_internal_file_string()))
2148                .is_err()
2149            {
2150                added_paths.push((BString::from(path.into_internal_string()), executable));
2151            }
2152        } else if values.after.is_absent() {
2153            removed_paths.insert(BString::from(path.into_internal_string()));
2154        }
2155    }
2156
2157    if added_paths.is_empty() && removed_paths.is_empty() {
2158        return Ok(());
2159    }
2160
2161    if !added_paths.is_empty() {
2162        // We need to write the empty blob, otherwise `jj util gc` will report an error.
2163        let empty_blob = git_repo
2164            .write_blob(b"")
2165            .map_err(GitResetHeadError::from_git)?
2166            .detach();
2167        for (path, executable) in added_paths {
2168            // We have checked that the index doesn't have this entry
2169            index.dangerously_push_entry(
2170                gix::index::entry::Stat::default(),
2171                empty_blob,
2172                gix::index::entry::Flags::INTENT_TO_ADD | gix::index::entry::Flags::EXTENDED,
2173                if executable {
2174                    gix::index::entry::Mode::FILE_EXECUTABLE
2175                } else {
2176                    gix::index::entry::Mode::FILE
2177                },
2178                path.as_ref(),
2179            );
2180        }
2181    }
2182    if !removed_paths.is_empty() {
2183        index.remove_entries(|_size, path, entry| {
2184            entry
2185                .flags
2186                .contains(gix::index::entry::Flags::INTENT_TO_ADD)
2187                && removed_paths.contains(path)
2188        });
2189    }
2190
2191    index.sort_entries();
2192
2193    // The entry mutations above (`dangerously_push_entry`, `remove_entries`,
2194    // `sort_entries`) do not invalidate the cache-tree (TREE) extension loaded
2195    // from disk, and `gix::index::File::write()` re-emits it as-is. Its own
2196    // docs prescribe this discard: "Until the tree-cache is updated on write
2197    // (see gitoxide#2421), remove it with `State::remove_tree()` before writing
2198    // whenever entries were changed." Otherwise the written index carries a
2199    // stale, under-counted cache-tree; an external `git add` that later
2200    // rebuilds it trusts those child counts and emits a doubled subtree entry,
2201    // which `git fsck` reports as duplicateEntries.
2202    index.remove_tree();
2203
2204    Ok(())
2205}
2206
2207#[derive(Debug, Error)]
2208pub enum GitRemoteManagementError {
2209    #[error("No git remote named '{}'", .0.as_symbol())]
2210    NoSuchRemote(RemoteNameBuf),
2211    #[error("Git remote named '{}' already exists", .0.as_symbol())]
2212    RemoteAlreadyExists(RemoteNameBuf),
2213    #[error(transparent)]
2214    RemoteName(#[from] GitRemoteNameError),
2215    #[error("Git remote named '{}' has nonstandard configuration", .0.as_symbol())]
2216    NonstandardConfiguration(RemoteNameBuf),
2217    #[error("Error saving Git configuration")]
2218    GitConfigSaveError(#[source] std::io::Error),
2219    #[error("Unexpected Git error when managing remotes")]
2220    InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
2221    #[error(transparent)]
2222    UnexpectedBackend(#[from] UnexpectedGitBackendError),
2223    #[error(transparent)]
2224    RefExpansionError(#[from] GitRefExpansionError),
2225}
2226
2227impl GitRemoteManagementError {
2228    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
2229        Self::InternalGitError(source.into())
2230    }
2231}
2232
2233fn default_fetch_refspec(remote: &RemoteName) -> String {
2234    format!(
2235        "+refs/heads/*:{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/*",
2236        remote = remote.as_str()
2237    )
2238}
2239
2240fn add_ref(
2241    name: gix::refs::FullName,
2242    target: gix::refs::Target,
2243    message: BString,
2244) -> gix::refs::transaction::RefEdit {
2245    gix::refs::transaction::RefEdit {
2246        change: gix::refs::transaction::Change::Update {
2247            log: gix::refs::transaction::LogChange {
2248                mode: gix::refs::transaction::RefLog::AndReference,
2249                force_create_reflog: false,
2250                message,
2251            },
2252            expected: gix::refs::transaction::PreviousValue::MustNotExist,
2253            new: target,
2254        },
2255        name,
2256        deref: false,
2257    }
2258}
2259
2260fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
2261    gix::refs::transaction::RefEdit {
2262        change: gix::refs::transaction::Change::Delete {
2263            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
2264                reference.target().into_owned(),
2265            ),
2266            log: gix::refs::transaction::RefLog::AndReference,
2267        },
2268        name: reference.name().to_owned(),
2269        deref: false,
2270    }
2271}
2272
2273/// Save an edited [`gix::config::File`] to its original location on disk.
2274///
2275/// Note that the resulting configuration changes are *not* persisted to the
2276/// originating [`gix::Repository`]! The repository must be reloaded with the
2277/// new configuration if necessary.
2278pub fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
2279    let mut config_file = File::create(
2280        config
2281            .meta()
2282            .path
2283            .as_ref()
2284            .expect("Git repository to have a config file"),
2285    )?;
2286    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
2287}
2288
2289fn save_remote(
2290    config: &mut gix::config::File,
2291    remote_name: &RemoteName,
2292    remote: &mut gix::Remote,
2293) -> Result<(), GitRemoteManagementError> {
2294    // Work around the gitoxide remote management bug
2295    // <https://github.com/GitoxideLabs/gitoxide/issues/1951> by adding
2296    // an empty section.
2297    //
2298    // Note that this will produce useless empty sections if we ever
2299    // support remote configuration keys other than `fetch` and `url`.
2300    config
2301        .new_section("remote", remote_name.as_str())
2302        .map_err(GitRemoteManagementError::from_git)?;
2303    remote
2304        .save_as_to(remote_name.as_str(), config)
2305        .map_err(GitRemoteManagementError::from_git)?;
2306    Ok(())
2307}
2308
2309fn git_config_branch_section_ids_by_remote(
2310    config: &gix::config::File,
2311    remote_name: &RemoteName,
2312) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
2313    config
2314        .sections_by_name("branch")
2315        .into_iter()
2316        .flatten()
2317        .filter_map(|section| {
2318            let remote_values = section.values("remote");
2319            let push_remote_values = section.values("pushRemote");
2320            if !remote_values
2321                .iter()
2322                .chain(push_remote_values.iter())
2323                .any(|branch_remote_name| branch_remote_name == remote_name.as_str())
2324            {
2325                return None;
2326            }
2327            // https://github.com/jj-vcs/jj/issues/6984#issuecomment-3073761797
2328            let is_supported_key = |name: &str| -> bool {
2329                name.eq_ignore_ascii_case("remote")
2330                    || name.eq_ignore_ascii_case("merge")
2331                    || name.eq_ignore_ascii_case("rebase")
2332            };
2333            if remote_values.len() > 1
2334                || push_remote_values.len() > 1
2335                || !section.value_names().all(|name| is_supported_key(&name))
2336            {
2337                return Some(Err(GitRemoteManagementError::NonstandardConfiguration(
2338                    remote_name.to_owned(),
2339                )));
2340            }
2341            Some(Ok(section.id()))
2342        })
2343        .collect()
2344}
2345
2346fn rename_remote_in_git_branch_config_sections(
2347    config: &mut gix::config::File,
2348    old_remote_name: &RemoteName,
2349    new_remote_name: &RemoteName,
2350) -> Result<(), GitRemoteManagementError> {
2351    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
2352        config
2353            .section_mut_by_id(id)
2354            .expect("found section to exist")
2355            .set("remote", new_remote_name.as_str())
2356            .expect("'remote' to be a valid value name");
2357    }
2358    Ok(())
2359}
2360
2361fn remove_remote_git_branch_config_sections(
2362    config: &mut gix::config::File,
2363    remote_name: &RemoteName,
2364) -> Result<(), GitRemoteManagementError> {
2365    for id in git_config_branch_section_ids_by_remote(config, remote_name)? {
2366        config
2367            .remove_section_by_id(id)
2368            .expect("removed section to exist");
2369    }
2370    Ok(())
2371}
2372
2373fn remove_remote_git_config_sections(
2374    config: &mut gix::config::File,
2375    remote_name: &RemoteName,
2376) -> Result<(), GitRemoteManagementError> {
2377    let section_ids_to_remove: Vec<_> = config
2378        .sections_by_name("remote")
2379        .into_iter()
2380        .flatten()
2381        .filter(|section| {
2382            section.header().subsection_name() == Some(BStr::new(remote_name.as_str()))
2383        })
2384        .map(|section| {
2385            if section.value_names().any(|name| {
2386                !name.eq_ignore_ascii_case("url")
2387                    && !name.eq_ignore_ascii_case("fetch")
2388                    && !name.eq_ignore_ascii_case("tagOpt")
2389            }) {
2390                return Err(GitRemoteManagementError::NonstandardConfiguration(
2391                    remote_name.to_owned(),
2392                ));
2393            }
2394            Ok(section.id())
2395        })
2396        .try_collect()?;
2397    for id in section_ids_to_remove {
2398        config
2399            .remove_section_by_id(id)
2400            .expect("removed section to exist");
2401    }
2402    Ok(())
2403}
2404
2405/// Returns a sorted list of configured remote names.
2406pub fn get_all_remote_names(
2407    store: &Store,
2408) -> Result<Vec<RemoteNameBuf>, UnexpectedGitBackendError> {
2409    let git_repo = get_git_repo(store)?;
2410    Ok(iter_remote_names(&git_repo).collect())
2411}
2412
2413fn iter_remote_names(git_repo: &gix::Repository) -> impl Iterator<Item = RemoteNameBuf> {
2414    git_repo
2415        .remote_names()
2416        .into_iter()
2417        // ignore non-UTF-8 remote names which we don't support
2418        .filter_map(|name| String::from_utf8(name.into()).ok())
2419        .map(RemoteNameBuf::from)
2420        // exclude empty [remote "<name>"] section
2421        .filter(|name| try_find_active_remote_inner(git_repo, name).is_some())
2422}
2423
2424/// Finds a configured remote with the given `name`. Returns `None` if it
2425/// doesn't exist or has no fetch or push URLs.
2426pub fn try_find_active_remote<'a>(
2427    git_repo: &'a gix::Repository,
2428    name: &RemoteName,
2429) -> Result<Option<gix::Remote<'a>>, GitRemoteManagementError> {
2430    try_find_active_remote_inner(git_repo, name)
2431        .transpose()
2432        .map_err(GitRemoteManagementError::from_git)
2433}
2434
2435fn try_find_active_remote_inner<'a>(
2436    git_repo: &'a gix::Repository,
2437    name: &RemoteName,
2438) -> Option<Result<gix::Remote<'a>, gix::remote::find::Error>> {
2439    // Since gix v0.86.0, try_find_remote() no longer filters out remotes
2440    // without configured URLs.
2441    git_repo
2442        .try_find_remote(name.as_str())
2443        .filter(|result| match result {
2444            Ok(remote) => {
2445                remote.url(gix::remote::Direction::Fetch).is_some()
2446                    || remote.url(gix::remote::Direction::Push).is_some()
2447            }
2448            Err(_) => true,
2449        })
2450}
2451
2452pub fn add_remote(
2453    mut_repo: &mut MutableRepo,
2454    remote_name: &RemoteName,
2455    url: &str,
2456    push_url: Option<&str>,
2457) -> Result<(), GitRemoteManagementError> {
2458    let git_repo = get_git_repo(mut_repo.store())?;
2459
2460    validate_remote_name(remote_name)?;
2461
2462    if try_find_active_remote_inner(&git_repo, remote_name).is_some() {
2463        return Err(GitRemoteManagementError::RemoteAlreadyExists(
2464            remote_name.to_owned(),
2465        ));
2466    }
2467
2468    let mut remote = git_repo
2469        .remote_at(url)
2470        .map_err(GitRemoteManagementError::from_git)?
2471        .with_refspecs(
2472            [default_fetch_refspec(remote_name).as_bytes()],
2473            gix::remote::Direction::Fetch,
2474        )
2475        .expect("default refspec to be valid");
2476
2477    if let Some(push_url) = push_url {
2478        remote = remote
2479            .with_push_url(push_url)
2480            .map_err(GitRemoteManagementError::from_git)?;
2481    }
2482
2483    let mut config = git_repo.config_snapshot().clone();
2484    save_remote(&mut config, remote_name, &mut remote)?;
2485    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
2486
2487    mut_repo.ensure_remote(remote_name);
2488
2489    Ok(())
2490}
2491
2492pub fn remove_remote(
2493    mut_repo: &mut MutableRepo,
2494    remote_name: &RemoteName,
2495) -> Result<(), GitRemoteManagementError> {
2496    let mut git_repo = get_git_repo(mut_repo.store())?;
2497
2498    if try_find_active_remote_inner(&git_repo, remote_name).is_none() {
2499        return Err(GitRemoteManagementError::NoSuchRemote(
2500            remote_name.to_owned(),
2501        ));
2502    }
2503
2504    let mut config = git_repo.config_snapshot().clone();
2505    remove_remote_git_branch_config_sections(&mut config, remote_name)?;
2506    remove_remote_git_config_sections(&mut config, remote_name)?;
2507    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
2508
2509    remove_remote_git_refs(&mut git_repo, remote_name)
2510        .map_err(GitRemoteManagementError::from_git)?;
2511
2512    if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
2513        remove_remote_refs(mut_repo, remote_name);
2514    }
2515
2516    Ok(())
2517}
2518
2519fn remove_remote_git_refs(
2520    git_repo: &mut gix::Repository,
2521    remote: &RemoteName,
2522) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
2523    let bookmark_prefix = format!(
2524        "{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
2525        remote = remote.as_str()
2526    );
2527    let tag_prefix = format!(
2528        "{REMOTE_TAG_REF_NAMESPACE}{remote}/",
2529        remote = remote.as_str()
2530    );
2531    let edits: Vec<_> = itertools::chain(
2532        git_repo
2533            .references()?
2534            .prefixed(bookmark_prefix.as_str())?
2535            .map_ok(remove_ref),
2536        git_repo
2537            .references()?
2538            .prefixed(tag_prefix.as_str())?
2539            .map_ok(remove_ref),
2540    )
2541    .try_collect()?;
2542    git_repo.edit_references(edits)?;
2543    Ok(())
2544}
2545
2546fn remove_remote_refs(mut_repo: &mut MutableRepo, remote: &RemoteName) {
2547    mut_repo.remove_remote(remote);
2548    let prefix = format!(
2549        "{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
2550        remote = remote.as_str()
2551    );
2552    let git_refs_to_delete = mut_repo
2553        .view()
2554        .git_refs()
2555        .keys()
2556        .filter(|&r| r.as_str().starts_with(&prefix))
2557        .cloned()
2558        .collect_vec();
2559    for git_ref in git_refs_to_delete {
2560        mut_repo.set_git_ref_target(&git_ref, RefTarget::absent());
2561    }
2562}
2563
2564pub fn rename_remote(
2565    mut_repo: &mut MutableRepo,
2566    old_remote_name: &RemoteName,
2567    new_remote_name: &RemoteName,
2568) -> Result<(), GitRemoteManagementError> {
2569    let mut git_repo = get_git_repo(mut_repo.store())?;
2570
2571    validate_remote_name(new_remote_name)?;
2572
2573    let mut remote = try_find_active_remote(&git_repo, old_remote_name)?
2574        .ok_or_else(|| GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned()))?;
2575
2576    if try_find_active_remote_inner(&git_repo, new_remote_name).is_some() {
2577        return Err(GitRemoteManagementError::RemoteAlreadyExists(
2578            new_remote_name.to_owned(),
2579        ));
2580    }
2581
2582    match (
2583        remote.refspecs(gix::remote::Direction::Fetch),
2584        remote.refspecs(gix::remote::Direction::Push),
2585    ) {
2586        ([refspec], [])
2587            if refspec.to_ref().to_bstring()
2588                == default_fetch_refspec(old_remote_name).as_bytes() => {}
2589        _ => {
2590            return Err(GitRemoteManagementError::NonstandardConfiguration(
2591                old_remote_name.to_owned(),
2592            ));
2593        }
2594    }
2595
2596    remote
2597        .replace_refspecs(
2598            [default_fetch_refspec(new_remote_name).as_bytes()],
2599            gix::remote::Direction::Fetch,
2600        )
2601        .expect("default refspec to be valid");
2602
2603    let mut config = git_repo.config_snapshot().clone();
2604    save_remote(&mut config, new_remote_name, &mut remote)?;
2605    rename_remote_in_git_branch_config_sections(&mut config, old_remote_name, new_remote_name)?;
2606    remove_remote_git_config_sections(&mut config, old_remote_name)?;
2607    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
2608
2609    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
2610        .map_err(GitRemoteManagementError::from_git)?;
2611
2612    if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
2613        rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
2614    }
2615
2616    Ok(())
2617}
2618
2619fn rename_remote_git_refs(
2620    git_repo: &mut gix::Repository,
2621    old_remote_name: &RemoteName,
2622    new_remote_name: &RemoteName,
2623) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
2624    let to_prefixes = |namespace: &str| {
2625        (
2626            format!("{namespace}{remote}/", remote = old_remote_name.as_str()),
2627            format!("{namespace}{remote}/", remote = new_remote_name.as_str()),
2628        )
2629    };
2630    let to_rename_edits = {
2631        let ref_log_message = BString::from(format!(
2632            "renamed remote {old_remote_name} to {new_remote_name}",
2633            old_remote_name = old_remote_name.as_symbol(),
2634            new_remote_name = new_remote_name.as_symbol(),
2635        ));
2636        move |old_prefix: &str, new_prefix: &str, old_ref: gix::Reference| {
2637            let new_name = BString::new(
2638                [
2639                    new_prefix.as_bytes(),
2640                    &old_ref.name().as_bstr()[old_prefix.len()..],
2641                ]
2642                .concat(),
2643            );
2644            [
2645                add_ref(
2646                    new_name.try_into().expect("new ref name to be valid"),
2647                    old_ref.target().into_owned(),
2648                    ref_log_message.clone(),
2649                ),
2650                remove_ref(old_ref),
2651            ]
2652        }
2653    };
2654
2655    let (old_bookmark_prefix, new_bookmark_prefix) = to_prefixes(REMOTE_BOOKMARK_REF_NAMESPACE);
2656    let (old_tag_prefix, new_tag_prefix) = to_prefixes(REMOTE_TAG_REF_NAMESPACE);
2657    let edits: Vec<_> = itertools::chain(
2658        git_repo
2659            .references()?
2660            .prefixed(old_bookmark_prefix.as_str())?
2661            .map_ok(|old_ref| to_rename_edits(&old_bookmark_prefix, &new_bookmark_prefix, old_ref)),
2662        git_repo
2663            .references()?
2664            .prefixed(old_tag_prefix.as_str())?
2665            .map_ok(|old_ref| to_rename_edits(&old_tag_prefix, &new_tag_prefix, old_ref)),
2666    )
2667    .flatten_ok()
2668    .try_collect()?;
2669    git_repo.edit_references(edits)?;
2670    Ok(())
2671}
2672
2673/// Sets the new URLs on the remote. If a URL of given kind is not provided, it
2674/// is not changed. I.e. it is not possible to remove a fetch/push URL from a
2675/// remote using this method.
2676pub fn set_remote_urls(
2677    store: &Store,
2678    remote_name: &RemoteName,
2679    new_url: Option<&str>,
2680    new_push_url: Option<&str>,
2681) -> Result<(), GitRemoteManagementError> {
2682    // quick sanity check
2683    if new_url.is_none() && new_push_url.is_none() {
2684        return Ok(());
2685    }
2686
2687    let git_repo = get_git_repo(store)?;
2688
2689    validate_remote_name(remote_name)?;
2690
2691    let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name.as_str()) else {
2692        return Err(GitRemoteManagementError::NoSuchRemote(
2693            remote_name.to_owned(),
2694        ));
2695    };
2696    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
2697
2698    if let Some(url) = new_url {
2699        remote = remote
2700            .with_url(url)
2701            .map_err(GitRemoteManagementError::from_git)?;
2702    }
2703
2704    if let Some(url) = new_push_url {
2705        remote = remote
2706            .with_push_url(url)
2707            .map_err(GitRemoteManagementError::from_git)?;
2708    }
2709
2710    let mut config = git_repo.config_snapshot().clone();
2711    save_remote(&mut config, remote_name, &mut remote)?;
2712    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
2713
2714    Ok(())
2715}
2716
2717fn rename_remote_refs(
2718    mut_repo: &mut MutableRepo,
2719    old_remote_name: &RemoteName,
2720    new_remote_name: &RemoteName,
2721) {
2722    mut_repo.rename_remote(old_remote_name.as_ref(), new_remote_name.as_ref());
2723    let prefix = format!(
2724        "{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/",
2725        remote = old_remote_name.as_str()
2726    );
2727    let git_refs = mut_repo
2728        .view()
2729        .git_refs()
2730        .iter()
2731        .filter_map(|(old, target)| {
2732            old.as_str().strip_prefix(&prefix).map(|p| {
2733                let new: GitRefNameBuf = format!(
2734                    "{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{p}",
2735                    remote = new_remote_name.as_str()
2736                )
2737                .into();
2738                (old.clone(), new, target.clone())
2739            })
2740        })
2741        .collect_vec();
2742    for (old, new, target) in git_refs {
2743        mut_repo.set_git_ref_target(&old, RefTarget::absent());
2744        mut_repo.set_git_ref_target(&new, target);
2745    }
2746}
2747
2748const INVALID_REFSPEC_CHARS: [char; 5] = [':', '^', '?', '[', ']'];
2749
2750#[derive(Error, Debug)]
2751pub enum GitFetchError {
2752    #[error("No git remote named '{}'", .0.as_symbol())]
2753    NoSuchRemote(RemoteNameBuf),
2754    #[error(transparent)]
2755    RemoteName(#[from] GitRemoteNameError),
2756    #[error("Failed to update refs: {}", .0.iter().map(|n| n.as_symbol()).join(", "))]
2757    RejectedUpdates(Vec<GitRefNameBuf>),
2758    #[error(transparent)]
2759    Subprocess(#[from] GitSubprocessError),
2760}
2761
2762#[derive(Error, Debug)]
2763pub enum GitDefaultRefspecError {
2764    #[error("No git remote named '{}'", .0.as_symbol())]
2765    NoSuchRemote(RemoteNameBuf),
2766    #[error("Invalid configuration for remote `{}`", .0.as_symbol())]
2767    InvalidRemoteConfiguration(RemoteNameBuf, #[source] Box<gix::remote::find::Error>),
2768}
2769
2770struct FetchedRefs {
2771    remote: RemoteNameBuf,
2772    bookmark_matcher: StringMatcher,
2773    tag_matcher: StringMatcher,
2774}
2775
2776/// Name patterns that will be transformed to Git refspecs.
2777#[derive(Clone, Debug)]
2778pub struct GitFetchRefExpression {
2779    /// Matches bookmark or branch names.
2780    pub bookmark: StringExpression,
2781    /// Matches tag names.
2782    ///
2783    /// Tags matching this expression will be fetched as "remote tags" and
2784    /// merged with tracking local tags. This is different from `git fetch`,
2785    /// which would directly update local tags.
2786    pub tag: StringExpression,
2787}
2788
2789/// Represents the refspecs to fetch from a remote
2790#[derive(Debug)]
2791pub struct ExpandedFetchRefSpecs {
2792    /// Matches (positive) `refspecs`, but not `negative_refspecs`.
2793    expr: GitFetchRefExpression,
2794    refspecs: Vec<RefSpec>,
2795    negative_refspecs: Vec<NegativeRefSpec>,
2796}
2797
2798#[derive(Error, Debug)]
2799pub enum GitRefExpansionError {
2800    #[error(transparent)]
2801    Expression(#[from] GitRefExpressionError),
2802    #[error(
2803        "Invalid branch pattern provided. When fetching, branch names and globs may not contain the characters `{chars}`",
2804        chars = INVALID_REFSPEC_CHARS.iter().join("`, `")
2805    )]
2806    InvalidBranchPattern(StringPattern),
2807}
2808
2809/// Expand a list of branch string patterns to refspecs to fetch
2810pub fn expand_fetch_refspecs(
2811    remote: &RemoteName,
2812    expr: GitFetchRefExpression,
2813) -> Result<ExpandedFetchRefSpecs, GitRefExpansionError> {
2814    let (positive_bookmarks, negative_bookmarks) =
2815        split_into_positive_negative_patterns(&expr.bookmark)?;
2816    let (positive_tags, negative_tags) = split_into_positive_negative_patterns(&expr.tag)?;
2817
2818    let refspecs = itertools::chain(
2819        positive_bookmarks
2820            .iter()
2821            .map(|&pattern| pattern_to_refspec_glob(pattern))
2822            .map_ok(|glob| {
2823                RefSpec::forced(
2824                    format!("refs/heads/{glob}"),
2825                    format!(
2826                        "{REMOTE_BOOKMARK_REF_NAMESPACE}{remote}/{glob}",
2827                        remote = remote.as_str()
2828                    ),
2829                )
2830            }),
2831        positive_tags
2832            .iter()
2833            .map(|&pattern| pattern_to_refspec_glob(pattern))
2834            .map_ok(|glob| {
2835                RefSpec::forced(
2836                    format!("refs/tags/{glob}"),
2837                    format!(
2838                        "{REMOTE_TAG_REF_NAMESPACE}{remote}/{glob}",
2839                        remote = remote.as_str()
2840                    ),
2841                )
2842            }),
2843    )
2844    .try_collect()?;
2845
2846    let negative_refspecs = itertools::chain(
2847        negative_bookmarks
2848            .iter()
2849            .map(|&pattern| pattern_to_refspec_glob(pattern))
2850            .map_ok(|glob| NegativeRefSpec::new(format!("refs/heads/{glob}"))),
2851        negative_tags
2852            .iter()
2853            .map(|&pattern| pattern_to_refspec_glob(pattern))
2854            .map_ok(|glob| NegativeRefSpec::new(format!("refs/tags/{glob}"))),
2855    )
2856    .try_collect()?;
2857
2858    Ok(ExpandedFetchRefSpecs {
2859        expr,
2860        refspecs,
2861        negative_refspecs,
2862    })
2863}
2864
2865fn pattern_to_refspec_glob(pattern: &StringPattern) -> Result<Cow<'_, str>, GitRefExpansionError> {
2866    pattern
2867        .to_glob()
2868        // This triggered by non-glob `*`s in addition to INVALID_REFSPEC_CHARS
2869        // because `to_glob()` escapes such `*`s as `[*]`.
2870        .filter(|glob| !glob.contains(INVALID_REFSPEC_CHARS))
2871        .ok_or_else(|| GitRefExpansionError::InvalidBranchPattern(pattern.clone()))
2872}
2873
2874#[derive(Debug, Error)]
2875pub enum GitRefExpressionError {
2876    #[error("Cannot use `~` in sub expression")]
2877    NestedNotIn,
2878    #[error("Cannot use `&` in sub expression")]
2879    NestedIntersection,
2880    #[error("Cannot use `&` for positive expressions")]
2881    PositiveIntersection,
2882}
2883
2884/// Splits string matcher expression into Git-compatible `(positive | ...) &
2885/// ~(negative | ...)` form.
2886fn split_into_positive_negative_patterns(
2887    expr: &StringExpression,
2888) -> Result<(Vec<&StringPattern>, Vec<&StringPattern>), GitRefExpressionError> {
2889    static ALL: StringPattern = StringPattern::all();
2890
2891    // Outer expression is considered an intersection of
2892    // - zero or one union of positive expressions
2893    // - zero or more unions of negative expressions
2894    // e.g.
2895    // - `a`                (1+)
2896    // - `~a&~b`            (1-, 1-)
2897    // - `(a|b)&~(c|d)&~e`  (2+, 2-, 1-)
2898    //
2899    // No negation nor intersection is allowed under union or not-in nodes.
2900    // - `a|~b`             (incompatible with Git refspecs)
2901    // - `~(~a&~b)`         (equivalent to `a|b`, but unsupported)
2902    // - `(a&~b)&(~c&~d)`   (equivalent to `a&~b&~c&~d`, but unsupported)
2903
2904    fn visit_positive<'a>(
2905        expr: &'a StringExpression,
2906        positives: &mut Vec<&'a StringPattern>,
2907        negatives: &mut Vec<&'a StringPattern>,
2908    ) -> Result<(), GitRefExpressionError> {
2909        match expr {
2910            StringExpression::Pattern(pattern) => {
2911                positives.push(pattern);
2912                Ok(())
2913            }
2914            StringExpression::NotIn(complement) => {
2915                positives.push(&ALL);
2916                visit_negative(complement, negatives)
2917            }
2918            StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, positives),
2919            StringExpression::Intersection(expr1, expr2) => {
2920                match (expr1.as_ref(), expr2.as_ref()) {
2921                    (other, StringExpression::NotIn(complement))
2922                    | (StringExpression::NotIn(complement), other) => {
2923                        visit_positive(other, positives, negatives)?;
2924                        visit_negative(complement, negatives)
2925                    }
2926                    _ => Err(GitRefExpressionError::PositiveIntersection),
2927                }
2928            }
2929        }
2930    }
2931
2932    fn visit_negative<'a>(
2933        expr: &'a StringExpression,
2934        negatives: &mut Vec<&'a StringPattern>,
2935    ) -> Result<(), GitRefExpressionError> {
2936        match expr {
2937            StringExpression::Pattern(pattern) => {
2938                negatives.push(pattern);
2939                Ok(())
2940            }
2941            StringExpression::NotIn(_) => Err(GitRefExpressionError::NestedNotIn),
2942            StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, negatives),
2943            StringExpression::Intersection(_, _) => Err(GitRefExpressionError::NestedIntersection),
2944        }
2945    }
2946
2947    fn visit_union<'a>(
2948        expr1: &'a StringExpression,
2949        expr2: &'a StringExpression,
2950        patterns: &mut Vec<&'a StringPattern>,
2951    ) -> Result<(), GitRefExpressionError> {
2952        visit_union_sub(expr1, patterns)?;
2953        visit_union_sub(expr2, patterns)
2954    }
2955
2956    fn visit_union_sub<'a>(
2957        expr: &'a StringExpression,
2958        patterns: &mut Vec<&'a StringPattern>,
2959    ) -> Result<(), GitRefExpressionError> {
2960        match expr {
2961            StringExpression::Pattern(pattern) => {
2962                patterns.push(pattern);
2963                Ok(())
2964            }
2965            StringExpression::NotIn(_) => Err(GitRefExpressionError::NestedNotIn),
2966            StringExpression::Union(expr1, expr2) => visit_union(expr1, expr2, patterns),
2967            StringExpression::Intersection(_, _) => Err(GitRefExpressionError::NestedIntersection),
2968        }
2969    }
2970
2971    let mut positives = Vec::new();
2972    let mut negatives = Vec::new();
2973    visit_positive(expr, &mut positives, &mut negatives)?;
2974    // Don't generate uninteresting patterns for `~*` (= none). `x~*`, `~(x|*)`,
2975    // etc. aren't special-cased because `x` may be Git-incompatible pattern.
2976    if positives.iter().all(|pattern| pattern.is_all())
2977        && !negatives.is_empty()
2978        && negatives.iter().all(|pattern| pattern.is_all())
2979    {
2980        Ok((vec![], vec![]))
2981    } else {
2982        Ok((positives, negatives))
2983    }
2984}
2985
2986/// A list of fetch refspecs configured within a remote that were ignored during
2987/// an expansion. Callers should consider displaying these in the UI as
2988/// appropriate.
2989#[derive(Debug)]
2990#[must_use = "warnings should be surfaced in the UI"]
2991pub struct IgnoredRefspecs(pub Vec<IgnoredRefspec>);
2992
2993/// A fetch refspec configured within a remote that was ignored during
2994/// expansion.
2995#[derive(Debug)]
2996pub struct IgnoredRefspec {
2997    /// The ignored refspec
2998    pub refspec: BString,
2999    /// The reason why it was ignored
3000    pub reason: &'static str,
3001}
3002
3003#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3004enum FetchRefSpecKind {
3005    Positive,
3006    Negative,
3007}
3008
3009/// Loads the remote's fetch branch or bookmark patterns from Git config.
3010pub fn load_default_fetch_bookmarks(
3011    remote_name: &RemoteName,
3012    git_repo: &gix::Repository,
3013) -> Result<(IgnoredRefspecs, StringExpression), GitDefaultRefspecError> {
3014    let remote = try_find_active_remote_inner(git_repo, remote_name)
3015        .ok_or_else(|| GitDefaultRefspecError::NoSuchRemote(remote_name.to_owned()))?
3016        .map_err(|e| {
3017            GitDefaultRefspecError::InvalidRemoteConfiguration(remote_name.to_owned(), Box::new(e))
3018        })?;
3019
3020    let remote_refspecs = remote.refspecs(gix::remote::Direction::Fetch);
3021    let mut ignored_refspecs = Vec::with_capacity(remote_refspecs.len());
3022    let mut positive_bookmarks = Vec::with_capacity(remote_refspecs.len());
3023    let mut negative_bookmarks = Vec::new();
3024    for refspec in remote_refspecs {
3025        let refspec = refspec.to_ref();
3026        match parse_fetch_refspec(remote_name, refspec) {
3027            Ok((FetchRefSpecKind::Positive, bookmark)) => {
3028                positive_bookmarks.push(StringExpression::pattern(bookmark));
3029            }
3030            Ok((FetchRefSpecKind::Negative, bookmark)) => {
3031                negative_bookmarks.push(StringExpression::pattern(bookmark));
3032            }
3033            Err(reason) => {
3034                let refspec = refspec.to_bstring();
3035                ignored_refspecs.push(IgnoredRefspec { refspec, reason });
3036            }
3037        }
3038    }
3039
3040    let mut bookmark_expr = StringExpression::union_all(positive_bookmarks);
3041    // Avoid double negation `~~*` when no negative patterns are provided.
3042    if !negative_bookmarks.is_empty() {
3043        bookmark_expr =
3044            bookmark_expr.intersection(StringExpression::union_all(negative_bookmarks).negated());
3045    }
3046
3047    Ok((IgnoredRefspecs(ignored_refspecs), bookmark_expr))
3048}
3049
3050fn parse_fetch_refspec(
3051    remote_name: &RemoteName,
3052    refspec: gix::refspec::RefSpecRef<'_>,
3053) -> Result<(FetchRefSpecKind, StringPattern), &'static str> {
3054    let ensure_utf8 = |s| str::from_utf8(s).map_err(|_| "invalid UTF-8");
3055
3056    let (src, positive_dst) = match refspec.instruction() {
3057        Instruction::Push(_) => panic!("push refspec should be filtered out by caller"),
3058        Instruction::Fetch(fetch) => match fetch {
3059            gix::refspec::instruction::Fetch::Only { src: _ } => {
3060                return Err("fetch-only refspecs are not supported");
3061            }
3062            gix::refspec::instruction::Fetch::AndUpdate {
3063                src,
3064                dst,
3065                allow_non_fast_forward,
3066            } => {
3067                if !allow_non_fast_forward {
3068                    return Err("non-forced refspecs are not supported");
3069                }
3070                (ensure_utf8(src)?, Some(ensure_utf8(dst)?))
3071            }
3072            gix::refspec::instruction::Fetch::Exclude { src } => (ensure_utf8(src)?, None),
3073        },
3074    };
3075
3076    let src_branch = src
3077        .strip_prefix("refs/heads/")
3078        .ok_or("only refs/heads/ is supported for refspec sources")?;
3079    let branch = StringPattern::glob(src_branch).map_err(|_| "invalid pattern")?;
3080
3081    if let Some(dst) = positive_dst {
3082        let dst_without_prefix = dst
3083            .strip_prefix(REMOTE_BOOKMARK_REF_NAMESPACE)
3084            .ok_or("only refs/remotes/ is supported for fetch destinations")?;
3085        let dst_branch = dst_without_prefix
3086            .strip_prefix(remote_name.as_str())
3087            .and_then(|d| d.strip_prefix("/"))
3088            .ok_or("remote renaming not supported")?;
3089        if src_branch != dst_branch {
3090            return Err("renaming is not supported");
3091        }
3092        Ok((FetchRefSpecKind::Positive, branch))
3093    } else {
3094        Ok((FetchRefSpecKind::Negative, branch))
3095    }
3096}
3097
3098/// Helper struct to execute multiple `git fetch` operations
3099pub struct GitFetch<'a> {
3100    mut_repo: &'a mut MutableRepo,
3101    git_repo: Box<gix::Repository>,
3102    git_ctx: GitSubprocessContext,
3103    import_options: &'a GitImportOptions,
3104    fetched: Vec<FetchedRefs>,
3105}
3106
3107impl<'a> GitFetch<'a> {
3108    pub fn new(
3109        mut_repo: &'a mut MutableRepo,
3110        subprocess_options: GitSubprocessOptions,
3111        import_options: &'a GitImportOptions,
3112    ) -> Result<Self, UnexpectedGitBackendError> {
3113        let git_backend = get_git_backend(mut_repo.store())?;
3114        let git_repo = Box::new(git_backend.git_repo());
3115        let git_ctx = GitSubprocessContext::from_git_backend(git_backend, subprocess_options);
3116        Ok(GitFetch {
3117            mut_repo,
3118            git_repo,
3119            git_ctx,
3120            import_options,
3121            fetched: vec![],
3122        })
3123    }
3124
3125    /// Perform a `git fetch` on the local git repo, updating the
3126    /// remote-tracking branches in the git repo.
3127    ///
3128    /// Keeps track of the {branch_names, remote_name} pair the refs can be
3129    /// subsequently imported into the `jj` repo by calling `import_refs()`.
3130    #[tracing::instrument(skip(self, callback))]
3131    pub fn fetch(
3132        &mut self,
3133        remote_name: &RemoteName,
3134        ExpandedFetchRefSpecs {
3135            expr,
3136            refspecs: mut remaining_refspecs,
3137            negative_refspecs,
3138        }: ExpandedFetchRefSpecs,
3139        callback: &mut dyn GitSubprocessCallback,
3140        depth: Option<NonZeroU32>,
3141    ) -> Result<(), GitFetchError> {
3142        validate_remote_name(remote_name)?;
3143
3144        // check the remote exists
3145        if try_find_active_remote_inner(&self.git_repo, remote_name).is_none() {
3146            return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
3147        }
3148
3149        if remaining_refspecs.is_empty() {
3150            // Don't fall back to the base refspecs.
3151            return Ok(());
3152        }
3153
3154        let mut branches_to_prune = Vec::new();
3155        // git unfortunately errors out if one of the many refspecs is not found
3156        //
3157        // our approach is to filter out failures and retry,
3158        // until either all have failed or an attempt has succeeded
3159        //
3160        // even more unfortunately, git errors out one refspec at a time,
3161        // meaning that the below cycle runs in O(#failed refspecs)
3162        let updates = loop {
3163            let status = self.git_ctx.spawn_fetch(
3164                remote_name,
3165                &remaining_refspecs,
3166                &negative_refspecs,
3167                callback,
3168                depth,
3169            )?;
3170            let failing_refspec = match status {
3171                GitFetchStatus::Updates(updates) => break updates,
3172                GitFetchStatus::NoRemoteRef(failing_refspec) => failing_refspec,
3173            };
3174            tracing::debug!(failing_refspec, "failed to fetch ref");
3175            remaining_refspecs.retain(|r| r.source.as_ref() != Some(&failing_refspec));
3176
3177            if let Some(branch_name) = failing_refspec.strip_prefix("refs/heads/") {
3178                branches_to_prune.push(format!(
3179                    "{remote_name}/{branch_name}",
3180                    remote_name = remote_name.as_str()
3181                ));
3182            }
3183        };
3184
3185        // Since remote refs are "force" updated, there should usually be no
3186        // rejected refs. One exception is implicit tag updates.
3187        if !updates.rejected.is_empty() {
3188            let names = updates.rejected.into_iter().map(|(name, _)| name).collect();
3189            return Err(GitFetchError::RejectedUpdates(names));
3190        }
3191
3192        // Even if git fetch has --prune, if a branch is not found it will not be
3193        // pruned on fetch
3194        self.git_ctx.spawn_branch_prune(&branches_to_prune)?;
3195
3196        self.fetched.push(FetchedRefs {
3197            remote: remote_name.to_owned(),
3198            bookmark_matcher: expr.bookmark.to_matcher(),
3199            tag_matcher: expr.tag.to_matcher(),
3200        });
3201        Ok(())
3202    }
3203
3204    /// Queries remote for the default branch name.
3205    #[tracing::instrument(skip(self))]
3206    pub fn get_default_branch(
3207        &self,
3208        remote_name: &RemoteName,
3209    ) -> Result<Option<RefNameBuf>, GitFetchError> {
3210        if try_find_active_remote_inner(&self.git_repo, remote_name).is_none() {
3211            return Err(GitFetchError::NoSuchRemote(remote_name.to_owned()));
3212        }
3213        let default_branch = self.git_ctx.spawn_remote_show(remote_name)?;
3214        tracing::debug!(?default_branch);
3215        Ok(default_branch)
3216    }
3217
3218    /// Import the previously fetched remote-tracking branches and tags into the
3219    /// jj repo and update jj's local bookmarks and tags.
3220    ///
3221    /// Clears all yet-to-be-imported {branch/tag_names, remote_name} pairs
3222    /// after the import. If `fetch()` has not been called since the last time
3223    /// `import_refs()` was called then this will be a no-op.
3224    #[tracing::instrument(skip(self))]
3225    pub async fn import_refs(&mut self) -> Result<GitImportStats, GitImportError> {
3226        tracing::debug!("import_refs");
3227        let all_remote_tags = true;
3228        let refs_to_import = diff_refs_to_import(
3229            self.mut_repo.view(),
3230            &self.git_repo,
3231            all_remote_tags,
3232            |kind, symbol| match kind {
3233                GitRefKind::Bookmark => self
3234                    .fetched
3235                    .iter()
3236                    .filter(|fetched| fetched.remote == symbol.remote)
3237                    .any(|fetched| fetched.bookmark_matcher.is_match(symbol.name.as_str())),
3238                GitRefKind::Tag => self
3239                    .fetched
3240                    .iter()
3241                    .filter(|fetched| fetched.remote == symbol.remote)
3242                    .any(|fetched| fetched.tag_matcher.is_match(symbol.name.as_str())),
3243            },
3244        )?;
3245        let import_stats =
3246            import_refs_inner(self.mut_repo, refs_to_import, self.import_options).await?;
3247
3248        self.fetched.clear();
3249
3250        Ok(import_stats)
3251    }
3252}
3253
3254#[derive(Error, Debug)]
3255pub enum GitPushError {
3256    #[error("No git remote named '{}'", .0.as_symbol())]
3257    NoSuchRemote(RemoteNameBuf),
3258    #[error(transparent)]
3259    RemoteName(#[from] GitRemoteNameError),
3260    #[error(transparent)]
3261    Subprocess(#[from] GitSubprocessError),
3262    #[error(transparent)]
3263    UnexpectedBackend(#[from] UnexpectedGitBackendError),
3264}
3265
3266#[derive(Clone, Debug, Default)]
3267pub struct GitPushRefTargets {
3268    /// Bookmark or branch `(name, [expected_target, new_target])`s to push.
3269    pub bookmarks: Vec<(RefNameBuf, Diff<Option<CommitId>>)>,
3270    /// Tag `(name, [expected_target, new_target])`s to push.
3271    pub tags: Vec<(RefNameBuf, Diff<Option<CommitId>>)>,
3272}
3273
3274pub struct GitRefUpdate {
3275    pub qualified_name: GitRefNameBuf,
3276    /// Expected position on the remote and new position to push.
3277    ///
3278    /// The expected position is sourced from the local remote-tracking branch.
3279    /// This should be `None` if we expect the ref to not exist on the remote.
3280    pub targets: Diff<Option<gix::ObjectId>>,
3281}
3282
3283/// Miscellaneous options for Git push command.
3284#[derive(Clone, Debug, Default)]
3285pub struct GitPushOptions {
3286    /// `--push-option` arguments.
3287    pub remote_push_options: Vec<String>,
3288}
3289
3290/// Pushes the specified refs and updates the repo view accordingly.
3291pub fn push_refs(
3292    mut_repo: &mut MutableRepo,
3293    subprocess_options: GitSubprocessOptions,
3294    remote: &RemoteName,
3295    targets: &GitPushRefTargets,
3296    callback: &mut dyn GitSubprocessCallback,
3297    options: &GitPushOptions,
3298) -> Result<GitPushStats, GitPushError> {
3299    validate_remote_name(remote)?;
3300
3301    let git_repo = get_git_repo(mut_repo.store())?;
3302    let to_tag_target = |name: &RefName, remote: &RemoteName, id: &CommitId| {
3303        let remote_matcher = StringMatcher::exact(remote);
3304        let oid = owned_oid_from_commit_id(id);
3305        find_git_tag_oid_to_copy(mut_repo.view(), &git_repo, name, &remote_matcher, &oid)
3306            .unwrap_or(oid)
3307    };
3308    let ref_updates = itertools::chain(
3309        targets.bookmarks.iter().map(|(name, update)| GitRefUpdate {
3310            qualified_name: format!("refs/heads/{name}", name = name.as_str()).into(),
3311            targets: update
3312                .as_ref()
3313                .map(|id| id.as_ref().map(owned_oid_from_commit_id)),
3314        }),
3315        targets.tags.iter().map(|(name, update)| GitRefUpdate {
3316            qualified_name: format!("refs/tags/{name}", name = name.as_str()).into(),
3317            targets: Diff {
3318                before: update
3319                    .before
3320                    .as_ref()
3321                    .map(|id| to_tag_target(name, remote, id)),
3322                after: update
3323                    .after
3324                    .as_ref()
3325                    .map(|id| to_tag_target(name, REMOTE_NAME_FOR_LOCAL_GIT_REPO, id)),
3326            },
3327        }),
3328    )
3329    .collect_vec();
3330
3331    let push_stats = push_updates(
3332        mut_repo,
3333        subprocess_options,
3334        remote,
3335        &ref_updates,
3336        callback,
3337        options,
3338    )?;
3339    tracing::debug!(?push_stats);
3340
3341    let pushed: HashSet<&GitRefName> = push_stats.pushed.iter().map(AsRef::as_ref).collect();
3342    let pushed_bookmark_updates = || {
3343        iter::zip(&targets.bookmarks, &ref_updates[..targets.bookmarks.len()])
3344            .filter(|(_, ref_update)| pushed.contains(&*ref_update.qualified_name))
3345            .map(|((name, update), _)| (&**name, update))
3346    };
3347    let pushed_tag_updates = || {
3348        iter::zip(&targets.tags, &ref_updates[targets.bookmarks.len()..])
3349            .filter(|(_, ref_update)| pushed.contains(&*ref_update.qualified_name))
3350            .map(|((name, update), ref_update)| (&**name, update, ref_update))
3351    };
3352
3353    // The remote refs in Git should usually be updated by `git push`. In that
3354    // case, this only updates our record about the last exported state.
3355    let unexported_bookmarks = {
3356        let refs = build_pushed_bookmarks_to_export(remote, pushed_bookmark_updates());
3357        export_refs_to_git(mut_repo, &git_repo, GitRefKind::Bookmark, refs)
3358    };
3359    // Update remote tags so we can look up annotated tag oid without fetching.
3360    // Since remote tags should never be imported without fetching from the
3361    // remote, update failure isn't a hard error.
3362    for (name, _, ref_update) in pushed_tag_updates() {
3363        let symbol = name.to_remote_symbol(remote);
3364        let edit = to_remote_tag_ref_update(symbol, ref_update.targets.after);
3365        if let Err(err) = git_repo.edit_reference(edit) {
3366            tracing::warn!(?symbol, ?err, "failed to update remote tag ref");
3367        }
3368    }
3369
3370    debug_assert!(unexported_bookmarks.is_sorted_by_key(|(symbol, _)| symbol));
3371    let is_exported_bookmark = |name: &RefName| {
3372        unexported_bookmarks
3373            .binary_search_by_key(&name, |(symbol, _)| &symbol.name)
3374            .is_err()
3375    };
3376    for (name, update) in pushed_bookmark_updates().filter(|(name, _)| is_exported_bookmark(name)) {
3377        let new_remote_ref = RemoteRef {
3378            target: RefTarget::resolved(update.after.clone()),
3379            state: RemoteRefState::Tracked,
3380        };
3381        mut_repo.set_remote_bookmark(name.to_remote_symbol(remote), new_remote_ref);
3382    }
3383    for (name, update, _) in pushed_tag_updates() {
3384        let new_remote_ref = RemoteRef {
3385            target: RefTarget::resolved(update.after.clone()),
3386            state: RemoteRefState::Tracked,
3387        };
3388        mut_repo.set_remote_tag(name.to_remote_symbol(remote), new_remote_ref);
3389    }
3390
3391    // TODO: Maybe we can add new stats type which stores RemoteRefSymbol in
3392    // place of GitRefName, and remove unexported_bookmarks from the original
3393    // stats type. This will help find pushed bookmarks that failed to export.
3394    assert!(push_stats.unexported_bookmarks.is_empty());
3395    let push_stats = GitPushStats {
3396        pushed: push_stats.pushed,
3397        rejected: push_stats.rejected,
3398        remote_rejected: push_stats.remote_rejected,
3399        unexported_bookmarks,
3400    };
3401    Ok(push_stats)
3402}
3403
3404/// Pushes the specified Git refs without updating the repo view.
3405pub fn push_updates(
3406    repo: &dyn Repo,
3407    subprocess_options: GitSubprocessOptions,
3408    remote_name: &RemoteName,
3409    updates: &[GitRefUpdate],
3410    callback: &mut dyn GitSubprocessCallback,
3411    options: &GitPushOptions,
3412) -> Result<GitPushStats, GitPushError> {
3413    let mut qualified_remote_refs_expected_locations = HashMap::new();
3414    let mut refspecs = vec![];
3415    for update in updates {
3416        qualified_remote_refs_expected_locations.insert(
3417            update.qualified_name.as_ref(),
3418            update.targets.before.as_deref(),
3419        );
3420        if let Some(new_target) = &update.targets.after {
3421            // We always force-push. We use the push_negotiation callback in
3422            // `push_refs` to check that the refs did not unexpectedly move on
3423            // the remote.
3424            refspecs.push(RefSpec::forced(
3425                new_target.to_string(),
3426                &update.qualified_name,
3427            ));
3428        } else {
3429            // Prefixing this with `+` to force-push or not should make no
3430            // difference. The push negotiation happens regardless, and wouldn't
3431            // allow creating a branch if it's not a fast-forward.
3432            refspecs.push(RefSpec::delete(&update.qualified_name));
3433        }
3434    }
3435
3436    let git_backend = get_git_backend(repo.store())?;
3437    let git_repo = git_backend.git_repo();
3438    let git_ctx = GitSubprocessContext::from_git_backend(git_backend, subprocess_options);
3439
3440    // check the remote exists
3441    if try_find_active_remote_inner(&git_repo, remote_name).is_none() {
3442        return Err(GitPushError::NoSuchRemote(remote_name.to_owned()));
3443    }
3444
3445    let refs_to_push: Vec<RefToPush> = refspecs
3446        .iter()
3447        .map(|full_refspec| RefToPush::new(full_refspec, &qualified_remote_refs_expected_locations))
3448        .collect();
3449
3450    let mut push_stats = git_ctx.spawn_push(remote_name, &refs_to_push, callback, options)?;
3451    push_stats.pushed.sort();
3452    push_stats.rejected.sort();
3453    push_stats.remote_rejected.sort();
3454    Ok(push_stats)
3455}
3456
3457/// Builds diff of remote bookmarks corresponding to the given `pushed_updates`.
3458fn build_pushed_bookmarks_to_export<'a>(
3459    remote: &RemoteName,
3460    pushed_updates: impl IntoIterator<Item = (&'a RefName, &'a Diff<Option<CommitId>>)>,
3461) -> RefsToExport {
3462    let mut to_update = Vec::new();
3463    let mut to_delete = Vec::new();
3464    for (name, update) in pushed_updates {
3465        let symbol = name.to_remote_symbol(remote);
3466        match (update.before.as_ref(), update.after.as_ref()) {
3467            (old, Some(new)) => {
3468                let old_oid = old.map(owned_oid_from_commit_id);
3469                let new_oid = owned_oid_from_commit_id(new);
3470                to_update.push((symbol.to_owned(), (old_oid, new_oid)));
3471            }
3472            (Some(old), None) => {
3473                let old_oid = owned_oid_from_commit_id(old);
3474                to_delete.push((symbol.to_owned(), old_oid));
3475            }
3476            (None, None) => panic!("old/new targets should differ"),
3477        }
3478    }
3479
3480    RefsToExport {
3481        to_update,
3482        to_delete,
3483        failed: vec![],
3484    }
3485}
3486
3487/// Constructs `RefEdit` to update pushed remote tag ref.
3488fn to_remote_tag_ref_update(
3489    symbol: RemoteRefSymbol<'_>,
3490    new_oid: Option<gix::ObjectId>,
3491) -> gix::refs::transaction::RefEdit {
3492    // No constraint on existing ref because remote tag ref shouldn't be moved
3493    // externally, and should always point to the actual remote ref.
3494    let expected = gix::refs::transaction::PreviousValue::Any;
3495    let change = match new_oid {
3496        Some(oid) => gix::refs::transaction::Change::Update {
3497            log: gix::refs::transaction::LogChange::default(),
3498            expected,
3499            new: oid.into(),
3500        },
3501        None => gix::refs::transaction::Change::Delete {
3502            expected,
3503            log: gix::refs::transaction::RefLog::AndReference,
3504        },
3505    };
3506    let name = format!(
3507        "{REMOTE_TAG_REF_NAMESPACE}{remote}/{name}",
3508        remote = symbol.remote.as_str(),
3509        name = symbol.name.as_str()
3510    );
3511    gix::refs::transaction::RefEdit {
3512        change,
3513        name: name.try_into().expect("pushed ref name should be valid"),
3514        deref: false,
3515    }
3516}
3517
3518#[cfg(test)]
3519mod tests {
3520    use assert_matches::assert_matches;
3521
3522    use super::*;
3523    use crate::revset;
3524    use crate::revset::RevsetDiagnostics;
3525
3526    #[test]
3527    fn test_split_positive_negative_patterns() {
3528        fn split(text: &str) -> (Vec<StringPattern>, Vec<StringPattern>) {
3529            try_split(text).unwrap()
3530        }
3531
3532        fn try_split(
3533            text: &str,
3534        ) -> Result<(Vec<StringPattern>, Vec<StringPattern>), GitRefExpressionError> {
3535            let mut diagnostics = RevsetDiagnostics::new();
3536            let expr = revset::parse_string_expression(&mut diagnostics, text).unwrap();
3537            let (positives, negatives) = split_into_positive_negative_patterns(&expr)?;
3538            Ok((
3539                positives.into_iter().cloned().collect(),
3540                negatives.into_iter().cloned().collect(),
3541            ))
3542        }
3543
3544        insta::assert_compact_debug_snapshot!(
3545            split("a"),
3546            @r#"([Exact("a")], [])"#);
3547        insta::assert_compact_debug_snapshot!(
3548            split("~a"),
3549            @r#"([Substring("")], [Exact("a")])"#);
3550        insta::assert_compact_debug_snapshot!(
3551            split("~a~b"),
3552            @r#"([Substring("")], [Exact("a"), Exact("b")])"#);
3553        insta::assert_compact_debug_snapshot!(
3554            split("~(a|b)"),
3555            @r#"([Substring("")], [Exact("a"), Exact("b")])"#);
3556        insta::assert_compact_debug_snapshot!(
3557            split("a|b"),
3558            @r#"([Exact("a"), Exact("b")], [])"#);
3559        insta::assert_compact_debug_snapshot!(
3560            split("(a|b)&~c"),
3561            @r#"([Exact("a"), Exact("b")], [Exact("c")])"#);
3562        insta::assert_compact_debug_snapshot!(
3563            split("~a&b"),
3564            @r#"([Exact("b")], [Exact("a")])"#);
3565        insta::assert_compact_debug_snapshot!(
3566            split("a&~b&~c"),
3567            @r#"([Exact("a")], [Exact("b"), Exact("c")])"#);
3568        insta::assert_compact_debug_snapshot!(
3569            split("~a&b&~c"),
3570            @r#"([Exact("b")], [Exact("a"), Exact("c")])"#);
3571        insta::assert_compact_debug_snapshot!(
3572            split("a&~(b|c)"),
3573            @r#"([Exact("a")], [Exact("b"), Exact("c")])"#);
3574        insta::assert_compact_debug_snapshot!(
3575            split("((a|b)|c)&~(d|(e|f))"),
3576            @r#"([Exact("a"), Exact("b"), Exact("c")], [Exact("d"), Exact("e"), Exact("f")])"#);
3577        assert_matches!(
3578            try_split("a&b"),
3579            Err(GitRefExpressionError::PositiveIntersection)
3580        );
3581        assert_matches!(try_split("a|~b"), Err(GitRefExpressionError::NestedNotIn));
3582        assert_matches!(
3583            try_split("a&~(b&~c)"),
3584            Err(GitRefExpressionError::NestedIntersection)
3585        );
3586        assert_matches!(
3587            try_split("(a|b)&c"),
3588            Err(GitRefExpressionError::PositiveIntersection)
3589        );
3590        assert_matches!(
3591            try_split("(a&~b)&(~c&~d)"),
3592            Err(GitRefExpressionError::PositiveIntersection)
3593        );
3594        assert_matches!(try_split("a&~~b"), Err(GitRefExpressionError::NestedNotIn));
3595        assert_matches!(
3596            try_split("a&~b|c&~d"),
3597            Err(GitRefExpressionError::NestedIntersection)
3598        );
3599
3600        // `~*` should generate empty patterns. `a~*` and `~(a|*)` don't because
3601        // `a` may be incompatible with Git refspecs.
3602        insta::assert_compact_debug_snapshot!(
3603            split("*"),
3604            @r#"([Glob(GlobPattern("*"))], [])"#);
3605        insta::assert_compact_debug_snapshot!(
3606            split("~*"),
3607            @"([], [])");
3608        insta::assert_compact_debug_snapshot!(
3609            split("a~*"),
3610            @r#"([Exact("a")], [Glob(GlobPattern("*"))])"#);
3611        insta::assert_compact_debug_snapshot!(
3612            split("~(a|*)"),
3613            @r#"([Substring("")], [Exact("a"), Glob(GlobPattern("*"))])"#);
3614    }
3615}