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