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