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