Skip to main content

grit_lib/porcelain/
pull.rs

1//! Fetch from a remote and integrate the configured upstream into the current branch.
2//!
3//! [`pull`] runs fetch, resolves the branch upstream via `resolve_pull_integration_base`, then
4//! fast-forwards, merges, rebases local commits onto the upstream tip, or refuses
5//! when `--ff-only` / `pull.ff=only` forbids a merge commit. Rebase conflicts stop a
6//! resumable rebase session ([`start_rebase`]) with Git-compatible `.git/rebase-merge/`
7//! state, finished with [`continue_rebase`](crate::history::continue_rebase),
8//! [`skip_rebase`](crate::history::skip_rebase) or [`abort_rebase`](crate::history::abort_rebase).
9//! A caller that fetched the upstream itself passes it as [`PullOptions::upstream`].
10
11use crate::branch_tracking::resolve_pull_integration_base;
12use crate::change_id::{logical_change_id_for_commit, ChangeId};
13use crate::config::environment::Environment;
14use crate::config::ident_resolve::IdentRole;
15use crate::config::parse_git_config_maybe_bool;
16use crate::config::ConfigSet;
17use crate::error::{Error, OverwriteReason};
18use crate::fetch_request::{configured_remote_for_pull, resolve_remote_argument};
19use crate::merge_base::{count_symmetric_ahead_behind, is_ancestor};
20use crate::objects::ObjectId;
21use crate::operation::Conflict;
22use crate::porcelain::checkout::checkout_between_trees;
23use crate::porcelain::checkout_reflog::checkout_reflog_identity;
24use crate::porcelain::merge::{
25    ensure_branch_tip, integrate_branch_with_options, predict_merge, update_checked_out_branch,
26    IntegrateBranchOptions, IntegrateBranchOutcome, MergePrediction, MergePredictionOptions,
27};
28use crate::porcelain::stash::{
29    apply_operation_autostash, push_operation_autostash, record_merge_autostash,
30    restore_operation_autostash,
31};
32use crate::porcelain::switch::checkout_tree_transition_allow_local_reset;
33use crate::porcelain::worktree_guard::prepare_tree_checkout;
34use crate::progress::{Cancel, ProgressSink};
35use crate::remote::fetch::Progress;
36use crate::remote::transfer::{resolve_fetch_prune, FetchOptions, TagMode, TransferStats};
37use crate::remote::{HttpClientFactory, RemoteError};
38use crate::repo::Repository;
39use crate::revwalk::history::{
40    plan_rebase_check, rebase_request_after_fetch, start_rebase, ApplyOptions, EmptyPolicy,
41    HistoryError, PreRebase, RebaseCheckPlan, RebaseOntoOptions, RebaseOutcome, RebaseSessionError,
42    RefScope, RewriteOptions, StartRebaseOptions,
43};
44use crate::state::{resolve_head_for_repository, HeadState};
45use thiserror::Error;
46
47const INITIAL_PULL_REFLOG: &str = "initial pull";
48
49/// Resolved integration behaviour after config and CLI overrides.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51struct PullIntegration {
52    mode: PullMode,
53    allow_fast_forward: bool,
54}
55
56/// How [`pull`] integrates upstream commits when a fast-forward is not possible.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum PullMode {
59    /// Refuse when diverged history would require a merge commit.
60    FfOnly,
61    /// Merge (or fast-forward when possible).
62    Merge,
63    /// Rebase local commits onto the upstream tip via the history engine, in one line like
64    /// `git pull --rebase`: merge commits on the branch are dropped.
65    Rebase,
66}
67
68/// Options for [`pull`].
69#[derive(Clone, Default)]
70pub struct PullOptions {
71    /// Remote name override; when `None`, uses [`configured_remote_for_pull`].
72    pub remote: Option<String>,
73    /// Remote branch or refspec to integrate (`pull origin feature`); also see [`Self::fetch`].
74    pub branch: Option<String>,
75    /// When true, integration uses [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) after fetch.
76    pub explicit_fetch: bool,
77    /// Stash dirty worktree/index changes around the integration (`pull --autostash`).
78    pub autostash: bool,
79    /// Fetch and report whether a fast-forward is possible without changing refs or the worktree.
80    pub check: bool,
81    /// CLI override for integration mode; when `None`, [`resolve_pull_mode`] applies config.
82    pub mode: Option<PullMode>,
83    /// Fetch options (tags, prune, depth, environment, …).
84    pub fetch: FetchOptions,
85    /// An upstream the caller already fetched: [`pull`] integrates it and fetches nothing.
86    ///
87    /// Snapshot checkouts set this: they fetch the server's snapshot themselves, from the URL
88    /// the checkout records, and then bring the current branch onto it.
89    pub upstream: Option<FetchedUpstream>,
90    /// Report a rebase that stops on a conflict as [`PullResult::RebaseStopped`], with
91    /// [`PullOutcome::oid`] at the branch's pre-pull tip, instead of
92    /// [`PullResult::RebaseConflicts`]. Both leave the same resumable rebase session; snapshot
93    /// checkouts set this so their refresh can tell its own stop apart. Ignored when an
94    /// autostash was made.
95    pub stop_on_conflict: bool,
96}
97
98/// An upstream commit [`pull`] integrates without fetching ([`PullOptions::upstream`]).
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct FetchedUpstream {
101    /// What to call it in results and reflog messages, such as `origin/main`.
102    pub label: String,
103    /// The commit to integrate.
104    pub oid: ObjectId,
105    /// A commit of the current branch whose changes [`Self::oid`] already has, though not as
106    /// an ancestor: work a server merged into the branch [`Self::oid`] is a snapshot of. The
107    /// branch's commits up to it are never replayed. A rebase replays only the commits above
108    /// it onto [`Self::oid`] (`git rebase --onto <oid> <landed>`), and a branch with nothing
109    /// above it moves to [`Self::oid`] in any mode, keeping uncommitted changes as
110    /// `git reset --keep` does ([`PullResult::Landed`]). Ignored unless it is in the
111    /// branch's history.
112    pub landed: Option<ObjectId>,
113    /// Commits of the current branch above [`Self::landed`] whose changes [`Self::oid`] already
114    /// has as other commits, which a patch-id can't match (a copy made on another base,
115    /// recognized by its change id): a rebase leaves them out, replaying the next commit onto
116    /// the one before.
117    pub copies: Vec<ObjectId>,
118}
119
120/// Dry-run result for [`PullOptions::check`].
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct PullCheck {
123    /// Commits on the current branch not reachable from the upstream tip.
124    pub ahead: usize,
125    /// Commits on the upstream tip not reachable from the current branch.
126    pub behind: usize,
127    /// Whether the branch could fast-forward to the upstream tip.
128    pub fast_forward_possible: bool,
129    /// Integration result if `--check` were not set.
130    pub would: PullResult,
131}
132
133/// High-level result of a successful [`pull`].
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum PullResult {
136    /// The branch already contained the upstream tip.
137    UpToDate,
138    /// The branch fast-forwarded to the upstream tip.
139    FastForward,
140    /// A merge commit integrated the upstream tip.
141    Merged,
142    /// Diverged history and ff-only mode refused to merge.
143    RefusedNotFf,
144    /// A merge was attempted but stopped with conflicts.
145    Conflicts,
146    /// An unborn branch adopted the upstream as its first commit.
147    SetUpstream,
148    /// Local commits were replayed onto the upstream tip.
149    Rebased,
150    /// Replaying local commits onto the upstream tip stopped on conflicts.
151    ///
152    /// A resumable rebase session is in progress (`.git/rebase-merge/`, conflict markers and
153    /// unmerged index entries, as `git pull --rebase` leaves them); the branch ref still points
154    /// at its pre-pull tip. Finish it with [`continue_rebase`](crate::history::continue_rebase)
155    /// after staging the resolution, or [`skip_rebase`](crate::history::skip_rebase) /
156    /// [`abort_rebase`](crate::history::abort_rebase). With [`PullOptions::check`] this
157    /// predicts the stop without touching anything.
158    RebaseConflicts,
159    /// Integration succeeded but re-applying `--autostash` left conflicts (stash kept).
160    AutostashConflict,
161    /// [`PullResult::RebaseConflicts`] for a pull that set [`PullOptions::stop_on_conflict`]:
162    /// the conflicted files are in the worktree and index, `HEAD` is detached on the commits
163    /// replayed so far, and the branch still points at its old tip until the rebase is
164    /// continued (or is put back by aborting it).
165    RebaseStopped,
166    /// Every commit of the branch was already in the upstream, though not as an ancestor
167    /// ([`FetchedUpstream::landed`]): the branch moved to the upstream tip instead of
168    /// replaying them, keeping uncommitted and untracked changes as `git reset --keep` does.
169    Landed,
170}
171
172/// Structured outcome of [`pull`].
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct PullOutcome {
175    /// Integration result.
176    pub result: PullResult,
177    /// Remote-tracking label integrated (for example `origin/main`).
178    pub label: String,
179    /// Branch tip after a successful integration, when applicable.
180    pub oid: Option<ObjectId>,
181    /// Stored `change-id` on [`Self::oid`] when present.
182    pub change_id: Option<ChangeId>,
183    /// For [`PullResult::SetUpstream`], the upstream label that was adopted.
184    pub upstream: Option<String>,
185    /// Conflicted paths when [`Self::result`] is [`PullResult::Conflicts`] or
186    /// [`PullResult::RebaseConflicts`].
187    pub conflicts: Option<Vec<Conflict>>,
188    /// For [`PullResult::RebaseConflicts`], the local commit that could not be replayed.
189    pub stopped_commit: Option<ObjectId>,
190    /// Autostash commit left on `refs/stash` because it could not be re-applied: the pull
191    /// stopped on rebase conflicts ([`PullResult::RebaseConflicts`]) or re-applying it
192    /// conflicted ([`PullResult::AutostashConflict`]).
193    pub autostash: Option<ObjectId>,
194    /// Commits on the branch not reachable from upstream when refused as not ff.
195    pub ahead: Option<usize>,
196    /// Commits on upstream not reachable from the branch when refused as not ff.
197    pub behind: Option<usize>,
198    /// Populated when [`PullOptions::check`] is set; the repository is unchanged.
199    pub check: Option<PullCheck>,
200    /// Objects and pack bytes the fetch step received (zero when nothing was
201    /// transferred or the upstream is local, `remote = .`).
202    pub transfer: TransferStats,
203}
204
205impl PullOutcome {
206    fn new(result: PullResult, label: &str) -> Self {
207        Self {
208            result,
209            label: label.to_owned(),
210            oid: None,
211            change_id: None,
212            upstream: None,
213            conflicts: None,
214            stopped_commit: None,
215            autostash: None,
216            ahead: None,
217            behind: None,
218            check: None,
219            transfer: TransferStats::default(),
220        }
221    }
222
223    fn at_commit(
224        result: PullResult,
225        label: &str,
226        repo: &Repository,
227        oid: ObjectId,
228    ) -> std::result::Result<Self, Error> {
229        Ok(Self {
230            oid: Some(oid),
231            change_id: logical_change_id_for_commit(repo, oid)?,
232            ..Self::new(result, label)
233        })
234    }
235}
236
237/// Errors from [`pull`].
238#[derive(Debug, Error)]
239pub enum PullError {
240    /// `HEAD` points at a detached commit.
241    #[error("HEAD is detached; pull needs a branch")]
242    DetachedHead,
243    /// `HEAD` could not be interpreted.
244    #[error("HEAD is in an unknown state")]
245    InvalidHead,
246    /// No upstream is configured or the tracking ref did not resolve after fetch.
247    #[error("no upstream configured for branch {branch}")]
248    NoUpstream {
249        /// Local branch short name.
250        branch: String,
251    },
252    /// `pull.rebase=interactive` (or branch rebase) is not supported.
253    #[error("interactive rebase is not supported; use grit history instead")]
254    RebaseInteractive,
255    /// Starting the rebase session failed (for example a rebase is already in progress).
256    #[error(transparent)]
257    Rebase(RebaseSessionError),
258    /// Remote resolution or transport failure.
259    #[error(transparent)]
260    Remote(#[from] RemoteError),
261    /// Other repository failures.
262    #[error(transparent)]
263    Repository(#[from] Error),
264}
265
266impl PullError {
267    /// Whether the remote could not be read at all ([`Error::RemoteUnreadable`]).
268    #[must_use]
269    pub fn is_remote_unreadable(&self) -> bool {
270        match self {
271            Self::Remote(e) => e.is_remote_unreadable(),
272            Self::Repository(e) => e.is_remote_unreadable(),
273            Self::DetachedHead
274            | Self::InvalidHead
275            | Self::NoUpstream { .. }
276            | Self::RebaseInteractive
277            | Self::Rebase(_) => false,
278        }
279    }
280
281    /// Whether the pull was cancelled ([`Error::Cancelled`]).
282    #[must_use]
283    pub fn is_cancelled(&self) -> bool {
284        match self {
285            Self::Remote(e) => e.is_cancelled(),
286            Self::Repository(e) => e.is_cancelled(),
287            Self::Rebase(e) => e.is_cancelled(),
288            Self::DetachedHead
289            | Self::InvalidHead
290            | Self::NoUpstream { .. }
291            | Self::RebaseInteractive => false,
292        }
293    }
294}
295
296/// Resolve pull integration mode from config with optional CLI override.
297///
298/// Precedence matches Git: explicit `--ff-only` / `--no-rebase` / `--rebase`, then
299/// `pull.ff=only`, then `branch.<name>.rebase` / `pull.rebase`, then remaining `pull.ff`.
300///
301/// # Errors
302///
303/// Returns [`PullError::RebaseInteractive`] when rebase config requests interactive mode.
304pub fn resolve_pull_mode(
305    config: &ConfigSet,
306    branch: Option<&str>,
307    cli: Option<PullMode>,
308) -> std::result::Result<PullMode, PullError> {
309    Ok(resolve_pull_integration(config, branch, cli)?.mode)
310}
311
312fn resolve_pull_integration(
313    config: &ConfigSet,
314    branch: Option<&str>,
315    cli: Option<PullMode>,
316) -> std::result::Result<PullIntegration, PullError> {
317    if cli == Some(PullMode::FfOnly) {
318        return Ok(PullIntegration {
319            mode: PullMode::FfOnly,
320            allow_fast_forward: true,
321        });
322    }
323    if cli == Some(PullMode::Merge) {
324        return Ok(PullIntegration {
325            mode: PullMode::Merge,
326            allow_fast_forward: true,
327        });
328    }
329    if cli == Some(PullMode::Rebase) {
330        return Ok(PullIntegration {
331            mode: PullMode::Rebase,
332            allow_fast_forward: true,
333        });
334    }
335    if let Some(raw) = config.get("pull.ff") {
336        let value = raw.trim();
337        if value.eq_ignore_ascii_case("only") {
338            return Ok(PullIntegration {
339                mode: PullMode::FfOnly,
340                allow_fast_forward: true,
341            });
342        }
343    }
344    if pull_rebase_enabled(config, branch)? {
345        return Ok(PullIntegration {
346            mode: PullMode::Rebase,
347            allow_fast_forward: true,
348        });
349    }
350    if let Some(raw) = config.get("pull.ff") {
351        let value = raw.trim();
352        if parse_git_config_maybe_bool(value) == Some(false) {
353            return Ok(PullIntegration {
354                mode: PullMode::Merge,
355                allow_fast_forward: false,
356            });
357        }
358    }
359    Ok(PullIntegration {
360        mode: PullMode::Merge,
361        allow_fast_forward: true,
362    })
363}
364
365impl PullOptions {
366    /// Build pull options for the current branch from config and CLI mode flags.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`PullError::DetachedHead`] or [`PullError::InvalidHead`] when `HEAD`
371    /// is not on a branch.
372    pub fn for_working_branch(
373        repo: &Repository,
374        environment: Environment,
375        cli_mode: Option<PullMode>,
376    ) -> std::result::Result<Self, PullError> {
377        if let Some(op) = crate::state::operation_blocking_switch(repo.git_dir())
378            .map_err(PullError::Repository)?
379        {
380            return Err(PullError::Repository(Error::OperationInProgress(op)));
381        }
382        let short_name = match resolve_head_for_repository(repo).map_err(PullError::Repository)? {
383            HeadState::Branch { short_name, .. } => short_name,
384            HeadState::Detached { .. } => return Err(PullError::DetachedHead),
385            HeadState::Invalid => return Err(PullError::InvalidHead),
386        };
387        let config = repo.config().map_err(PullError::Repository)?;
388        let remote_name = configured_remote_for_pull(config.as_ref(), Some(&short_name));
389        let prune = resolve_fetch_prune(config.as_ref(), &remote_name, None)
390            .map_err(PullError::Repository)?;
391        Ok(Self {
392            remote: None,
393            branch: None,
394            explicit_fetch: false,
395            autostash: false,
396            check: false,
397            mode: cli_mode,
398            fetch: {
399                let mut opts = FetchOptions::new();
400                opts.tags = TagMode::Following;
401                opts.thread_policy = repo.thread_policy();
402                opts.environment = environment;
403                opts.prune = prune;
404                opts.remote_name = Some(remote_name);
405                opts.reflog_action = "pull";
406                opts
407            },
408            upstream: None,
409            stop_on_conflict: false,
410        })
411    }
412}
413
414fn pull_rebase_enabled(
415    config: &ConfigSet,
416    branch: Option<&str>,
417) -> std::result::Result<bool, PullError> {
418    if let Some(name) = branch {
419        if let Some(raw) = config.get(&format!("branch.{name}.rebase")) {
420            return parse_pull_rebase_config(raw.as_str());
421        }
422    }
423    if let Some(raw) = config.get("pull.rebase") {
424        return parse_pull_rebase_config(raw.as_str());
425    }
426    Ok(false)
427}
428
429/// Whether `--autostash` is active for this pull (CLI flag or config).
430#[must_use]
431pub fn resolve_pull_autostash(config: &ConfigSet, mode: PullMode, cli_autostash: bool) -> bool {
432    if cli_autostash {
433        return true;
434    }
435    let key = match mode {
436        PullMode::Rebase => "rebase.autoStash",
437        PullMode::FfOnly | PullMode::Merge => "merge.autoStash",
438    };
439    config
440        .get(key)
441        .and_then(|raw| parse_git_config_maybe_bool(raw.trim()))
442        .unwrap_or(false)
443}
444
445fn parse_pull_rebase_config(raw: &str) -> std::result::Result<bool, PullError> {
446    let value = raw.trim();
447    if value.eq_ignore_ascii_case("interactive") {
448        return Err(PullError::RebaseInteractive);
449    }
450    if value.eq_ignore_ascii_case("false") || value == "0" {
451        return Ok(false);
452    }
453    if let Some(parsed) = parse_git_config_maybe_bool(value) {
454        return Ok(parsed);
455    }
456    Ok(true)
457}
458
459/// Fetch from a remote and integrate its upstream into the current branch.
460///
461/// With [`PullOptions::upstream`] set, nothing is fetched and that commit is integrated.
462///
463/// # Errors
464///
465/// Returns [`PullError`] for detached HEAD, missing upstream, interactive rebase config,
466/// or transport/repository failures.
467pub fn pull<P>(
468    repo: &Repository,
469    options: &PullOptions,
470    progress: &mut P,
471    cancel: &dyn crate::progress::Cancel,
472    http_factory: Option<&dyn HttpClientFactory>,
473) -> std::result::Result<PullOutcome, PullError>
474where
475    P: Progress,
476{
477    if let Some(op) = crate::state::operation_blocking_switch(repo.git_dir())? {
478        return Err(PullError::Repository(Error::OperationInProgress(op)));
479    }
480    let (refname, short_name, head_oid) = match resolve_head_for_repository(repo)? {
481        HeadState::Branch {
482            refname,
483            short_name,
484            oid,
485        } => (refname, short_name, oid),
486        HeadState::Detached { .. } => return Err(PullError::DetachedHead),
487        HeadState::Invalid => return Err(PullError::InvalidHead),
488    };
489
490    let config = repo.config().map_err(PullError::Repository)?;
491    let remote_name = options
492        .remote
493        .clone()
494        .unwrap_or_else(|| configured_remote_for_pull(config.as_ref(), Some(&short_name)));
495
496    let integration = resolve_pull_integration(config.as_ref(), Some(&short_name), options.mode)?;
497    let (explicit_fetch, branch_refspecs) = pull_fetch_refspecs(options);
498    let mut fetch_opts = options.fetch.clone();
499    fetch_opts.refspecs = branch_refspecs;
500    if fetch_opts.remote_name.is_none() {
501        fetch_opts.remote_name = Some(remote_name.clone());
502    }
503    if options.check {
504        fetch_opts.dry_run = false;
505    }
506    let fetch_dry_run = fetch_opts.dry_run;
507
508    let autostash = resolve_pull_autostash(config.as_ref(), integration.mode, options.autostash)
509        && !options.check
510        && !fetch_dry_run;
511
512    let merge_in_progress =
513        || crate::state::read_merge_heads(repo.git_dir()).is_ok_and(|heads| !heads.is_empty());
514    let merge_was_in_progress = merge_in_progress();
515    let stash_oid = if autostash && head_oid.is_some() {
516        push_operation_autostash(repo, &checkout_reflog_identity(repo))
517            .map_err(PullError::Repository)?
518    } else {
519        None
520    };
521
522    let stop_on_conflict = options.stop_on_conflict && stash_oid.is_none();
523    let mut transfer = TransferStats::default();
524    let core: std::result::Result<PullOutcome, PullError> = (|| {
525        if options.upstream.is_none() && remote_name != "." {
526            let remote = resolve_remote_argument(config.as_ref(), &remote_name)
527                .map_err(PullError::Remote)?;
528            let fetch_outcome = remote
529                .fetch(repo, fetch_opts, progress, cancel, http_factory)
530                .map_err(PullError::Remote)?;
531            if fetch_outcome.has_rejections() {
532                return Err(PullError::Repository(Error::Message(
533                    "some ref updates from the remote were rejected".into(),
534                )));
535            }
536            transfer = fetch_outcome.transfer;
537        }
538
539        if fetch_dry_run {
540            return Ok(PullOutcome {
541                oid: head_oid,
542                change_id: head_oid
543                    .and_then(|oid| logical_change_id_for_commit(repo, oid).ok())
544                    .flatten(),
545                ..PullOutcome::new(PullResult::UpToDate, &short_name)
546            });
547        }
548
549        let (label, upstream_oid, upstream_full_ref) = match &options.upstream {
550            Some(fetched) => (
551                fetched.label.clone(),
552                fetched.oid,
553                resolve_fork_point_upstream_ref(repo, &fetched.label),
554            ),
555            None => {
556                let upstream =
557                    resolve_pull_integration_base(repo, &short_name, &remote_name, explicit_fetch)
558                        .ok_or(PullError::NoUpstream {
559                            branch: short_name.clone(),
560                        })?;
561                (upstream.name, upstream.oid, upstream.full_ref)
562            }
563        };
564
565        let landed = options.upstream.as_ref().and_then(|u| u.landed);
566        if options.check {
567            return pull_check_outcome(
568                repo,
569                head_oid,
570                upstream_oid,
571                &label,
572                &upstream_full_ref,
573                integration,
574                landed,
575            );
576        }
577
578        match head_oid {
579            None => adopt_unborn_upstream(repo, &refname, &short_name, &label, upstream_oid),
580            Some(head_oid) => integrate_fetched_upstream(
581                repo,
582                &refname,
583                head_oid,
584                Upstream {
585                    oid: upstream_oid,
586                    label: &label,
587                    full_ref: &upstream_full_ref,
588                    landed,
589                    copies: options
590                        .upstream
591                        .as_ref()
592                        .map_or(&[], |u| u.copies.as_slice()),
593                },
594                integration,
595                stop_on_conflict,
596                progress,
597                cancel,
598            ),
599        }
600    })();
601    let core = core.map(|outcome| PullOutcome {
602        transfer,
603        ..outcome
604    });
605
606    match core {
607        Err(err) => {
608            // A hook that refused the merge commit leaves the merge in progress. The stash then
609            // waits in MERGE_AUTOSTASH, as with Git, so `merge --continue` does not auto-stage it.
610            match stash_oid.as_ref() {
611                Some(oid) if !merge_was_in_progress && merge_in_progress() => {
612                    record_merge_autostash(repo, oid).map_err(PullError::Repository)?;
613                }
614                _ => restore_autostash_after_abort(repo, stash_oid.as_ref()),
615            }
616            Err(err)
617        }
618        Ok(outcome) => {
619            if fetch_dry_run {
620                restore_autostash_after_abort(repo, stash_oid.as_ref());
621                return Ok(outcome);
622            }
623            if outcome.result == PullResult::RebaseConflicts {
624                // Applying the stash now would mix it into the conflict markers; it stays on
625                // `refs/stash` for the caller to pop once the rebase is finished or aborted.
626                return Ok(PullOutcome {
627                    autostash: stash_oid,
628                    ..outcome
629                });
630            }
631            if matches!(
632                outcome.result,
633                PullResult::Conflicts | PullResult::RefusedNotFf | PullResult::RebaseStopped
634            ) {
635                restore_autostash_after_abort(repo, stash_oid.as_ref());
636                return Ok(outcome);
637            }
638            apply_autostash_after_pull(repo, stash_oid.as_ref(), Ok(outcome))
639        }
640    }
641}
642
643/// Merge [`PullOptions::branch`] into fetch refspecs for embedders and tests.
644fn pull_fetch_refspecs(options: &PullOptions) -> (bool, Vec<String>) {
645    let mut refspecs = options.fetch.refspecs.clone();
646    let mut explicit = options.explicit_fetch;
647    if let Some(branch) = options.branch.as_deref().filter(|b| !b.is_empty()) {
648        if refspecs.is_empty() {
649            refspecs.push(branch.to_owned());
650        }
651        explicit = true;
652    }
653    (explicit, refspecs)
654}
655
656fn restore_autostash_after_abort(repo: &Repository, stash_oid: Option<&ObjectId>) {
657    let Some(stash_oid) = stash_oid else {
658        return;
659    };
660    let _ = restore_operation_autostash(repo, stash_oid);
661}
662
663fn apply_autostash_after_pull(
664    repo: &Repository,
665    stash_oid: Option<&ObjectId>,
666    outcome: std::result::Result<PullOutcome, PullError>,
667) -> std::result::Result<PullOutcome, PullError> {
668    let mut outcome = outcome?;
669    if stash_oid.is_none() {
670        return Ok(outcome);
671    }
672    if !matches!(
673        outcome.result,
674        PullResult::UpToDate
675            | PullResult::FastForward
676            | PullResult::Merged
677            | PullResult::Rebased
678            | PullResult::Landed
679            | PullResult::SetUpstream
680    ) {
681        return Ok(outcome);
682    }
683    if let Some(stash_oid) = stash_oid {
684        if apply_operation_autostash(repo, stash_oid).map_err(PullError::Repository)? {
685            outcome.result = PullResult::AutostashConflict;
686            outcome.autostash = Some(*stash_oid);
687        }
688    }
689    Ok(outcome)
690}
691
692fn pull_check_outcome(
693    repo: &Repository,
694    head_oid: Option<ObjectId>,
695    upstream_oid: ObjectId,
696    label: &str,
697    upstream_full_ref: &str,
698    integration: PullIntegration,
699    landed: Option<ObjectId>,
700) -> std::result::Result<PullOutcome, PullError> {
701    let (ahead, behind, would, fast_forward_possible) = match head_oid {
702        None => (0, 0, PullResult::SetUpstream, true),
703        Some(head_oid) => {
704            let (ahead, behind) = count_symmetric_ahead_behind(repo, head_oid, upstream_oid)
705                .map_err(PullError::Repository)?;
706            let would = if landed == Some(head_oid) && diverged(repo, head_oid, upstream_oid)? {
707                PullResult::Landed
708            } else {
709                predict_pull_result(
710                    repo,
711                    head_oid,
712                    upstream_oid,
713                    upstream_full_ref,
714                    integration,
715                    landed,
716                )?
717            };
718            let up_to_date = head_oid == upstream_oid || is_ancestor(repo, upstream_oid, head_oid)?;
719            let fast_forward = !up_to_date
720                && is_ancestor(repo, head_oid, upstream_oid).map_err(PullError::Repository)?;
721            (ahead, behind, would, up_to_date || fast_forward)
722        }
723    };
724    Ok(PullOutcome {
725        oid: head_oid,
726        change_id: head_oid
727            .and_then(|oid| logical_change_id_for_commit(repo, oid).ok())
728            .flatten(),
729        ahead: Some(ahead),
730        behind: Some(behind),
731        check: Some(PullCheck {
732            ahead,
733            behind,
734            fast_forward_possible,
735            would,
736        }),
737        ..PullOutcome::new(would, label)
738    })
739}
740
741fn resolve_fork_point_upstream_ref(repo: &Repository, label: &str) -> String {
742    crate::merge_base::resolve_fork_point_reflog_ref(repo, label)
743}
744
745fn predict_pull_result(
746    repo: &Repository,
747    head_oid: ObjectId,
748    upstream_oid: ObjectId,
749    upstream_full_ref: &str,
750    integration: PullIntegration,
751    landed: Option<ObjectId>,
752) -> std::result::Result<PullResult, PullError> {
753    if head_oid == upstream_oid || is_ancestor(repo, upstream_oid, head_oid)? {
754        return Ok(PullResult::UpToDate);
755    }
756    if integration.allow_fast_forward && is_ancestor(repo, head_oid, upstream_oid)? {
757        return Ok(PullResult::FastForward);
758    }
759    let diverged = !is_ancestor(repo, upstream_oid, head_oid)?
760        && !is_ancestor(repo, head_oid, upstream_oid)?
761        && head_oid != upstream_oid;
762    if diverged {
763        return match integration.mode {
764            PullMode::FfOnly => Ok(PullResult::RefusedNotFf),
765            PullMode::Rebase => {
766                let mut opts = pull_rebase_options(repo)?;
767                opts.rewrite.sign = false;
768                let request = rebase_request_after_fetch(
769                    repo,
770                    head_oid,
771                    upstream_oid,
772                    upstream_full_ref,
773                    landed,
774                    true,
775                )
776                .map_err(map_history_error)?;
777                match plan_rebase_check(repo, &request, &opts).map_err(map_history_error)? {
778                    RebaseCheckPlan::UpToDate | RebaseCheckPlan::Clean { .. } => {
779                        Ok(PullResult::Rebased)
780                    }
781                    RebaseCheckPlan::Conflict { .. } => Ok(PullResult::RebaseConflicts),
782                }
783            }
784            PullMode::Merge => match predict_merge(
785                repo,
786                head_oid,
787                upstream_oid,
788                &MergePredictionOptions {
789                    allow_fast_forward: integration.allow_fast_forward,
790                    theirs_label: "upstream".into(),
791                    ..Default::default()
792                },
793            )
794            .map_err(PullError::Repository)?
795            {
796                MergePrediction::UpToDate => Ok(PullResult::UpToDate),
797                MergePrediction::FastForward => Ok(PullResult::FastForward),
798                MergePrediction::Clean { .. } => Ok(PullResult::Merged),
799                MergePrediction::Conflicts { .. } => Ok(PullResult::Conflicts),
800                MergePrediction::Unrelated => Ok(PullResult::Conflicts),
801            },
802        };
803    }
804    Ok(PullResult::Merged)
805}
806
807fn adopt_unborn_upstream(
808    repo: &Repository,
809    refname: &str,
810    short_name: &str,
811    label: &str,
812    upstream_oid: ObjectId,
813) -> std::result::Result<PullOutcome, PullError> {
814    ensure_branch_tip(repo, refname, None)?;
815    let upstream_tree = repo.odb().commit_tree(&upstream_oid)?;
816    prepare_tree_checkout(repo, None, &upstream_tree).map_err(PullError::Repository)?;
817    checkout_between_trees(repo, None, &upstream_tree).map_err(PullError::Repository)?;
818    update_checked_out_branch(repo, refname, None, upstream_oid, INITIAL_PULL_REFLOG)?;
819    Ok(PullOutcome {
820        upstream: Some(label.to_owned()),
821        ..PullOutcome::at_commit(PullResult::SetUpstream, short_name, repo, upstream_oid)?
822    })
823}
824
825/// Whether `head_oid` and `upstream_oid` each have commits the other lacks.
826fn diverged(
827    repo: &Repository,
828    head_oid: ObjectId,
829    upstream_oid: ObjectId,
830) -> std::result::Result<bool, PullError> {
831    Ok(head_oid != upstream_oid
832        && !is_ancestor(repo, upstream_oid, head_oid).map_err(PullError::Repository)?
833        && !is_ancestor(repo, head_oid, upstream_oid).map_err(PullError::Repository)?)
834}
835
836/// The upstream a pull integrates.
837#[derive(Clone, Copy)]
838struct Upstream<'a> {
839    oid: ObjectId,
840    label: &'a str,
841    /// Full ref name for upstream reflog fork-point lookup.
842    full_ref: &'a str,
843    /// [`FetchedUpstream::landed`].
844    landed: Option<ObjectId>,
845    /// [`FetchedUpstream::copies`].
846    copies: &'a [ObjectId],
847}
848
849#[allow(clippy::too_many_arguments)]
850fn integrate_fetched_upstream(
851    repo: &Repository,
852    refname: &str,
853    head_oid: ObjectId,
854    upstream: Upstream<'_>,
855    integration: PullIntegration,
856    stop_on_conflict: bool,
857    progress: &mut dyn ProgressSink,
858    cancel: &dyn Cancel,
859) -> std::result::Result<PullOutcome, PullError> {
860    let Upstream {
861        oid: upstream_oid,
862        label,
863        landed,
864        ..
865    } = upstream;
866    let diverged = diverged(repo, head_oid, upstream_oid)?;
867
868    if diverged && landed == Some(head_oid) {
869        return move_landed_branch(
870            repo,
871            refname,
872            head_oid,
873            upstream_oid,
874            label,
875            progress,
876            cancel,
877        );
878    }
879
880    if diverged {
881        match integration.mode {
882            PullMode::FfOnly => {
883                let (ahead, behind) = count_symmetric_ahead_behind(repo, head_oid, upstream_oid)
884                    .map_err(PullError::Repository)?;
885                return Ok(PullOutcome {
886                    ahead: Some(ahead),
887                    behind: Some(behind),
888                    ..PullOutcome::at_commit(PullResult::RefusedNotFf, label, repo, head_oid)?
889                });
890            }
891            PullMode::Rebase => {
892                return rebase_fetched_upstream(
893                    repo,
894                    refname,
895                    head_oid,
896                    upstream,
897                    stop_on_conflict,
898                    progress,
899                    cancel,
900                );
901            }
902            PullMode::Merge => {}
903        }
904    }
905
906    let integrate = integrate_branch_with_options(
907        repo,
908        refname,
909        head_oid,
910        upstream_oid,
911        label,
912        IntegrateBranchOptions {
913            allow_fast_forward: integration.allow_fast_forward,
914        },
915        progress,
916        cancel,
917    )
918    .map_err(PullError::Repository)?;
919    Ok(map_integrate_outcome(integrate, label))
920}
921
922#[allow(clippy::too_many_arguments)]
923fn rebase_fetched_upstream(
924    repo: &Repository,
925    refname: &str,
926    head_oid: ObjectId,
927    upstream: Upstream<'_>,
928    stop_on_conflict: bool,
929    progress: &mut dyn ProgressSink,
930    cancel: &dyn Cancel,
931) -> std::result::Result<PullOutcome, PullError> {
932    let Upstream {
933        oid: upstream_oid,
934        label,
935        full_ref,
936        landed,
937        copies,
938    } = upstream;
939    let request = rebase_request_after_fetch(repo, head_oid, upstream_oid, full_ref, landed, true)
940        .map_err(map_history_error)?;
941
942    let mut rebase = pull_rebase_options(repo)?;
943    // The upstream already has these commits: they are its copies of local ones.
944    rebase.drop = copies.to_vec();
945    // Git's pull hands rebase the fork point (or the fetched tip) as a commit id.
946    let pre_rebase = PreRebase::Run {
947        upstream: Some(request.upstream.to_hex()),
948    };
949    let start = StartRebaseOptions {
950        request,
951        rebase,
952        apply: ApplyOptions {
953            identity: repo
954                .identity(IdentRole::Committer, repo.identity_now())
955                .map_err(PullError::Repository)?,
956            reflog_message: format!("pull: rebase onto {label}"),
957            record_operation: Some("rebase".into()),
958            hooks: crate::hooks::PostOperationHooks::Run,
959        },
960        pre_rebase,
961    };
962    match start_rebase(repo, &start, progress, cancel).map_err(map_rebase_session_error)? {
963        RebaseOutcome::Stopped(stop) if stop_on_conflict => Ok(PullOutcome {
964            conflicts: Some(stop.conflicts),
965            stopped_commit: Some(stop.commit),
966            ..PullOutcome::at_commit(PullResult::RebaseStopped, label, repo, head_oid)?
967        }),
968        RebaseOutcome::Stopped(stop) => Ok(PullOutcome {
969            conflicts: Some(stop.conflicts),
970            stopped_commit: Some(stop.commit),
971            ..PullOutcome::new(PullResult::RebaseConflicts, label)
972        }),
973        RebaseOutcome::Done { applied, .. } | RebaseOutcome::ExecDone { applied, .. }
974            if applied.head != head_oid =>
975        {
976            Ok(PullOutcome::at_commit(
977                PullResult::Rebased,
978                label,
979                repo,
980                applied.head,
981            )?)
982        }
983        // Nothing was rewritten: integrate like `git pull --rebase`, which fast-forwards when
984        // the branch is simply behind the upstream tip.
985        RebaseOutcome::Done { .. } | RebaseOutcome::ExecDone { .. } => {
986            let integrate = integrate_branch_with_options(
987                repo,
988                refname,
989                head_oid,
990                upstream_oid,
991                label,
992                IntegrateBranchOptions {
993                    allow_fast_forward: true,
994                },
995                progress,
996                cancel,
997            )
998            .map_err(PullError::Repository)?;
999            Ok(map_integrate_outcome(integrate, label))
1000        }
1001    }
1002}
1003
1004/// Replay options shared by the rebase pull and its `--check` prediction.
1005fn pull_rebase_options(repo: &Repository) -> std::result::Result<RebaseOntoOptions, PullError> {
1006    let committer = repo
1007        .identity(IdentRole::Committer, repo.identity_now())
1008        .map_err(PullError::Repository)?;
1009    let mut opts = RebaseOntoOptions::new(RewriteOptions {
1010        scope: RefScope::Head,
1011        empty: EmptyPolicy::Drop,
1012        committer,
1013        preserve_committer: false,
1014        sign: true,
1015    });
1016    opts.flatten_leaf_merges = true;
1017    Ok(opts)
1018}
1019
1020/// Move the checked-out branch from `head_oid`, whose commits `upstream_oid` already has
1021/// ([`FetchedUpstream::landed`]), to `upstream_oid`, as `git reset --keep` does: the index
1022/// and worktree go from the one tree to the other, keeping uncommitted and untracked changes
1023/// that the move doesn't touch, and nothing moves when it would overwrite some.
1024#[allow(clippy::too_many_arguments)]
1025fn move_landed_branch(
1026    repo: &Repository,
1027    refname: &str,
1028    head_oid: ObjectId,
1029    upstream_oid: ObjectId,
1030    label: &str,
1031    progress: &mut dyn ProgressSink,
1032    cancel: &dyn Cancel,
1033) -> std::result::Result<PullOutcome, PullError> {
1034    ensure_branch_tip(repo, refname, Some(head_oid))?;
1035    if repo.load_index()?.has_unmerged_entries() {
1036        return Err(Error::IndexUnmerged.into());
1037    }
1038    let from = repo.odb().commit_tree(&head_oid)?;
1039    let to = repo.odb().commit_tree(&upstream_oid)?;
1040    checkout_tree_transition_allow_local_reset(repo, Some(&from), &to, progress, cancel).map_err(
1041        |e| match e {
1042            Error::WouldOverwrite {
1043                reason: OverwriteReason::LocalChanges,
1044                paths,
1045                ..
1046            } => local_changes_would_be_overwritten(paths),
1047            other => other,
1048        },
1049    )?;
1050    update_checked_out_branch(
1051        repo,
1052        refname,
1053        Some(head_oid),
1054        upstream_oid,
1055        &format!("pull: move to {label}, which has the branch's commits"),
1056    )?;
1057    crate::state::write_orig_head(repo, head_oid)?;
1058    Ok(PullOutcome::at_commit(
1059        PullResult::Landed,
1060        label,
1061        repo,
1062        upstream_oid,
1063    )?)
1064}
1065
1066/// [`Error::WouldOverwrite`] for uncommitted changes to `paths` that a pull would overwrite.
1067fn local_changes_would_be_overwritten(paths: Vec<String>) -> Error {
1068    Error::WouldOverwrite {
1069        reason: OverwriteReason::LocalChanges,
1070        paths,
1071        message: "local changes would be overwritten".to_owned(),
1072    }
1073}
1074
1075fn map_history_error(err: HistoryError) -> PullError {
1076    match err {
1077        HistoryError::Library(e) => PullError::Repository(*e),
1078        HistoryError::LocalChangesWouldBeOverwritten { paths } => {
1079            PullError::Repository(local_changes_would_be_overwritten(paths))
1080        }
1081        other => PullError::Repository(Error::Message(other.to_string())),
1082    }
1083}
1084
1085fn map_rebase_session_error(err: RebaseSessionError) -> PullError {
1086    match err {
1087        RebaseSessionError::Library(e) => PullError::Repository(e),
1088        RebaseSessionError::History(e) => map_history_error(e),
1089        other => PullError::Rebase(other),
1090    }
1091}
1092
1093fn map_integrate_outcome(outcome: IntegrateBranchOutcome, label: &str) -> PullOutcome {
1094    match outcome {
1095        IntegrateBranchOutcome::UpToDate => PullOutcome::new(PullResult::UpToDate, label),
1096        IntegrateBranchOutcome::FastForward { oid, change_id } => PullOutcome {
1097            oid: Some(oid),
1098            change_id,
1099            ..PullOutcome::new(PullResult::FastForward, label)
1100        },
1101        IntegrateBranchOutcome::Merged { oid, change_id } => PullOutcome {
1102            oid: Some(oid),
1103            change_id,
1104            ..PullOutcome::new(PullResult::Merged, label)
1105        },
1106        IntegrateBranchOutcome::Conflicts { details, .. } => PullOutcome {
1107            conflicts: Some(details),
1108            ..PullOutcome::new(PullResult::Conflicts, label)
1109        },
1110        IntegrateBranchOutcome::Squashed | IntegrateBranchOutcome::Stopped => {
1111            unreachable!("pull does not use squash or no-commit merge modes")
1112        }
1113    }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use crate::config::environment::RepositoryOptions;
1120    use crate::config::ConfigSet;
1121    use crate::environment::Environment;
1122    use crate::progress::NeverCancel;
1123    use crate::remote::fetch::NoProgress;
1124    use std::path::{Path, PathBuf};
1125    use std::process::{Command, Output};
1126    use tempfile::TempDir;
1127
1128    fn git_output(dir: &Path, args: &[&str]) -> Output {
1129        Command::new("git")
1130            .current_dir(dir)
1131            .args(args)
1132            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1133            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1134            .env("GIT_AUTHOR_NAME", "T")
1135            .env("GIT_AUTHOR_EMAIL", "t@example.com")
1136            .env("GIT_COMMITTER_NAME", "T")
1137            .env("GIT_COMMITTER_EMAIL", "t@example.com")
1138            .output()
1139            .expect("run git")
1140    }
1141
1142    fn git(dir: &Path, args: &[&str]) -> String {
1143        let out = git_output(dir, args);
1144        assert!(
1145            out.status.success(),
1146            "git {args:?} failed: {}",
1147            String::from_utf8_lossy(&out.stderr)
1148        );
1149        String::from_utf8_lossy(&out.stdout).trim().to_owned()
1150    }
1151
1152    fn open(dir: &Path) -> Repository {
1153        Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open")
1154    }
1155
1156    fn commit(dir: &Path, file: &str, contents: &str) -> ObjectId {
1157        std::fs::write(dir.join(file), contents).expect("write");
1158        git(dir, &["add", file]);
1159        git(dir, &["commit", "-qm", contents]);
1160        ObjectId::from_hex(&git(dir, &["rev-parse", "HEAD"])).expect("oid")
1161    }
1162
1163    fn origin_and_clone(tmp: &TempDir) -> (PathBuf, PathBuf) {
1164        let origin = tmp.path().join("origin");
1165        std::fs::create_dir_all(&origin).expect("mkdir origin");
1166        git(&origin, &["init", "-q", "-b", "main"]);
1167        commit(&origin, "a.txt", "one");
1168        git(tmp.path(), &["clone", "-q", "origin", "clone"]);
1169        (origin, tmp.path().join("clone"))
1170    }
1171
1172    fn pull_options(repo: &Repository) -> PullOptions {
1173        PullOptions::for_working_branch(repo, Environment::empty(), None).expect("options")
1174    }
1175
1176    fn cfg(pairs: &[(&str, &str)]) -> ConfigSet {
1177        let mut set = ConfigSet::new();
1178        for (key, value) in pairs {
1179            set.add_command_override(key, value).expect("override");
1180        }
1181        set
1182    }
1183
1184    #[test]
1185    fn resolve_pull_mode_cli_ff_only_wins() {
1186        let config = cfg(&[("pull.rebase", "true")]);
1187        assert_eq!(
1188            resolve_pull_mode(&config, Some("main"), Some(PullMode::FfOnly)).expect("mode"),
1189            PullMode::FfOnly
1190        );
1191    }
1192
1193    #[test]
1194    fn resolve_pull_mode_no_rebase_overrides_rebase_config() {
1195        let config = cfg(&[("pull.rebase", "true")]);
1196        assert_eq!(
1197            resolve_pull_mode(&config, Some("main"), Some(PullMode::Merge)).expect("mode"),
1198            PullMode::Merge
1199        );
1200    }
1201
1202    #[test]
1203    fn resolve_pull_mode_rebase_from_config() {
1204        let config = cfg(&[("pull.rebase", "merges")]);
1205        assert_eq!(
1206            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1207            PullMode::Rebase
1208        );
1209    }
1210
1211    #[test]
1212    fn resolve_pull_mode_branch_rebase_overrides_global() {
1213        let config = cfg(&[("pull.rebase", "false"), ("branch.topic.rebase", "true")]);
1214        assert_eq!(
1215            resolve_pull_mode(&config, Some("topic"), None).expect("mode"),
1216            PullMode::Rebase
1217        );
1218    }
1219
1220    #[test]
1221    fn resolve_pull_mode_interactive_is_error() {
1222        let config = cfg(&[("pull.rebase", "interactive")]);
1223        assert!(matches!(
1224            resolve_pull_mode(&config, Some("main"), None),
1225            Err(PullError::RebaseInteractive)
1226        ));
1227    }
1228
1229    #[test]
1230    fn resolve_pull_mode_ff_only_from_config() {
1231        let config = cfg(&[("pull.ff", "only")]);
1232        assert_eq!(
1233            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1234            PullMode::FfOnly
1235        );
1236    }
1237
1238    #[test]
1239    fn resolve_pull_mode_ff_only_beats_rebase_config() {
1240        let config = cfg(&[("pull.ff", "only"), ("pull.rebase", "true")]);
1241        assert_eq!(
1242            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1243            PullMode::FfOnly
1244        );
1245    }
1246
1247    #[test]
1248    fn resolve_pull_mode_defaults_to_merge() {
1249        let config = ConfigSet::new();
1250        assert_eq!(
1251            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1252            PullMode::Merge
1253        );
1254    }
1255
1256    #[test]
1257    fn resolve_pull_integration_ff_false_disables_fast_forward() {
1258        let config = cfg(&[("pull.ff", "false")]);
1259        let resolved = resolve_pull_integration(&config, Some("main"), None).expect("resolved");
1260        assert_eq!(resolved.mode, PullMode::Merge);
1261        assert!(!resolved.allow_fast_forward);
1262    }
1263
1264    #[cfg_attr(
1265        not(feature = "full-tests"),
1266        ignore = "starts system git or is heavy; run with --features full-tests"
1267    )]
1268    #[test]
1269    fn fast_forward_pull_refuses_overlapping_local_changes() {
1270        let tmp = TempDir::new().expect("tempdir");
1271        let (origin, clone) = origin_and_clone(&tmp);
1272        let tip_before = git(&clone, &["rev-parse", "main"]);
1273        commit(&origin, "a.txt", "two");
1274        std::fs::write(clone.join("a.txt"), "local edit\n").expect("write");
1275        let repo = open(&clone);
1276        let err = pull(
1277            &repo,
1278            &pull_options(&repo),
1279            &mut NoProgress,
1280            &NeverCancel,
1281            None,
1282        )
1283        .expect_err("blocked");
1284        assert!(
1285            matches!(
1286                &err,
1287                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["a.txt"]
1288            ),
1289            "{err:?}"
1290        );
1291        assert_eq!(git(&clone, &["rev-parse", "main"]), tip_before);
1292        assert!(
1293            git(&clone, &["status", "--porcelain"]).contains("M a.txt"),
1294            "worktree still dirty"
1295        );
1296    }
1297
1298    #[cfg_attr(
1299        not(feature = "full-tests"),
1300        ignore = "starts system git or is heavy; run with --features full-tests"
1301    )]
1302    #[test]
1303    fn pull_maps_custom_fetch_refspec() {
1304        let tmp = TempDir::new().expect("tempdir");
1305        let (origin, clone) = origin_and_clone(&tmp);
1306        let tip = commit(&origin, "b.txt", "two");
1307        git(
1308            &clone,
1309            &[
1310                "config",
1311                "--replace-all",
1312                "remote.origin.fetch",
1313                "+refs/heads/*:refs/remotes/mirror/*",
1314            ],
1315        );
1316
1317        let repo = open(&clone);
1318        let outcome = pull(
1319            &repo,
1320            &pull_options(&repo),
1321            &mut NoProgress,
1322            &NeverCancel,
1323            None,
1324        )
1325        .expect("pull succeeds");
1326        assert_eq!(outcome.result, PullResult::FastForward);
1327        assert_eq!(outcome.oid, Some(tip));
1328        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1329    }
1330
1331    #[cfg_attr(
1332        not(feature = "full-tests"),
1333        ignore = "starts system git or is heavy; run with --features full-tests"
1334    )]
1335    #[test]
1336    fn pull_local_upstream_skips_fetch() {
1337        let tmp = TempDir::new().expect("tempdir");
1338        let dir = tmp.path();
1339        git(dir, &["init", "-q", "-b", "main"]);
1340        commit(dir, "a.txt", "one");
1341        git(dir, &["checkout", "-q", "-b", "dev"]);
1342        let tip = commit(dir, "b.txt", "two");
1343        git(dir, &["checkout", "-q", "main"]);
1344        git(dir, &["branch", "--set-upstream-to", "dev", "main"]);
1345
1346        let repo = open(dir);
1347        let outcome = pull(
1348            &repo,
1349            &pull_options(&repo),
1350            &mut NoProgress,
1351            &NeverCancel,
1352            None,
1353        )
1354        .expect("local pull succeeds");
1355        assert_eq!(outcome.result, PullResult::FastForward);
1356        assert_eq!(outcome.oid, Some(tip));
1357        assert_eq!(git(dir, &["rev-parse", "main"]), tip.to_hex());
1358        assert_eq!(outcome.transfer, TransferStats::default());
1359    }
1360
1361    #[cfg_attr(
1362        not(feature = "full-tests"),
1363        ignore = "starts system git or is heavy; run with --features full-tests"
1364    )]
1365    #[test]
1366    fn pull_reports_objects_received_by_its_fetch() {
1367        let tmp = TempDir::new().expect("tempdir");
1368        let (origin, clone) = origin_and_clone(&tmp);
1369        commit(&origin, "b.txt", "two");
1370
1371        let repo = open(&clone);
1372        let outcome = pull(
1373            &repo,
1374            &pull_options(&repo),
1375            &mut NoProgress,
1376            &NeverCancel,
1377            None,
1378        )
1379        .expect("pull succeeds");
1380        assert_eq!(outcome.result, PullResult::FastForward);
1381        // One new commit, its root tree and the added blob.
1382        assert_eq!(outcome.transfer.objects, 3, "{:?}", outcome.transfer);
1383        assert!(outcome.transfer.bytes > 0, "{:?}", outcome.transfer);
1384
1385        let again = pull(
1386            &repo,
1387            &pull_options(&repo),
1388            &mut NoProgress,
1389            &NeverCancel,
1390            None,
1391        )
1392        .expect("second pull");
1393        assert_eq!(again.result, PullResult::UpToDate);
1394        assert_eq!(again.transfer.objects, 0, "{:?}", again.transfer);
1395    }
1396
1397    #[cfg_attr(
1398        not(feature = "full-tests"),
1399        ignore = "starts system git or is heavy; run with --features full-tests"
1400    )]
1401    #[test]
1402    fn pull_adopts_upstream_on_unborn_branch_with_reflog() {
1403        let tmp = TempDir::new().expect("tempdir");
1404        let origin = tmp.path().join("origin");
1405        std::fs::create_dir_all(&origin).expect("mkdir origin");
1406        git(&origin, &["init", "-q", "-b", "main"]);
1407        let tip = commit(&origin, "a.txt", "one");
1408        let local = tmp.path().join("local");
1409        std::fs::create_dir_all(&local).expect("mkdir local");
1410        git(&local, &["init", "-q", "-b", "main"]);
1411        git(&local, &["remote", "add", "origin", "../origin"]);
1412
1413        let repo = open(&local);
1414        let outcome = pull(
1415            &repo,
1416            &pull_options(&repo),
1417            &mut NoProgress,
1418            &NeverCancel,
1419            None,
1420        )
1421        .expect("pull succeeds");
1422        assert_eq!(outcome.result, PullResult::SetUpstream);
1423        assert_eq!(outcome.oid, Some(tip));
1424        assert_eq!(
1425            git(&local, &["reflog", "show", "--format=%H %gs", "-1", "main"]),
1426            format!("{} {INITIAL_PULL_REFLOG}", tip.to_hex())
1427        );
1428    }
1429
1430    #[cfg_attr(
1431        not(feature = "full-tests"),
1432        ignore = "starts system git or is heavy; run with --features full-tests"
1433    )]
1434    #[test]
1435    fn pull_unborn_branch_skips_autostash_with_cli_flag() {
1436        let tmp = TempDir::new().expect("tempdir");
1437        let origin = tmp.path().join("origin");
1438        std::fs::create_dir_all(&origin).expect("mkdir origin");
1439        git(&origin, &["init", "-q", "-b", "main"]);
1440        let tip = commit(&origin, "a.txt", "one");
1441        let local = tmp.path().join("local");
1442        std::fs::create_dir_all(&local).expect("mkdir local");
1443        git(&local, &["init", "-q", "-b", "main"]);
1444        git(&local, &["remote", "add", "origin", "../origin"]);
1445
1446        let repo = open(&local);
1447        let mut options = pull_options(&repo);
1448        options.autostash = true;
1449        let outcome = pull(&repo, &options, &mut NoProgress, &NeverCancel, None)
1450            .expect("pull with --autostash on unborn branch");
1451        assert_eq!(outcome.result, PullResult::SetUpstream);
1452        assert_eq!(outcome.oid, Some(tip));
1453        assert!(
1454            git_output(&local, &["rev-parse", "refs/stash"])
1455                .status
1456                .code()
1457                == Some(128),
1458            "autostash must not create refs/stash without a commit"
1459        );
1460    }
1461
1462    #[cfg_attr(
1463        not(feature = "full-tests"),
1464        ignore = "starts system git or is heavy; run with --features full-tests"
1465    )]
1466    #[test]
1467    fn pull_unborn_branch_skips_autostash_from_config() {
1468        let tmp = TempDir::new().expect("tempdir");
1469        let origin = tmp.path().join("origin");
1470        std::fs::create_dir_all(&origin).expect("mkdir origin");
1471        git(&origin, &["init", "-q", "-b", "main"]);
1472        let tip = commit(&origin, "a.txt", "one");
1473        let local = tmp.path().join("local");
1474        std::fs::create_dir_all(&local).expect("mkdir local");
1475        git(&local, &["init", "-q", "-b", "main"]);
1476        git(&local, &["remote", "add", "origin", "../origin"]);
1477        git(&local, &["config", "rebase.autoStash", "true"]);
1478        git(&local, &["config", "pull.rebase", "true"]);
1479
1480        let repo = open(&local);
1481        let outcome = pull(
1482            &repo,
1483            &pull_options(&repo),
1484            &mut NoProgress,
1485            &NeverCancel,
1486            None,
1487        )
1488        .expect("pull with rebase.autoStash on unborn branch");
1489        assert_eq!(outcome.result, PullResult::SetUpstream);
1490        assert_eq!(outcome.oid, Some(tip));
1491        assert!(
1492            git_output(&local, &["rev-parse", "refs/stash"])
1493                .status
1494                .code()
1495                == Some(128),
1496            "config autostash must not run without a commit"
1497        );
1498    }
1499
1500    #[cfg_attr(
1501        not(feature = "full-tests"),
1502        ignore = "starts system git or is heavy; run with --features full-tests"
1503    )]
1504    #[test]
1505    fn pull_unborn_branch_with_autostash_still_refuses_overlapping_paths() {
1506        let tmp = TempDir::new().expect("tempdir");
1507        let origin = tmp.path().join("origin");
1508        std::fs::create_dir_all(&origin).expect("mkdir origin");
1509        git(&origin, &["init", "-q", "-b", "main"]);
1510        commit(&origin, "a.txt", "upstream");
1511        let local = tmp.path().join("local");
1512        std::fs::create_dir_all(&local).expect("mkdir local");
1513        git(&local, &["init", "-q", "-b", "main"]);
1514        git(&local, &["remote", "add", "origin", "../origin"]);
1515        std::fs::write(local.join("a.txt"), "local\n").expect("write");
1516
1517        let repo = open(&local);
1518        let mut options = pull_options(&repo);
1519        options.autostash = true;
1520        let err = pull(&repo, &options, &mut NoProgress, &NeverCancel, None)
1521            .expect_err("checkout collision");
1522        assert!(
1523            matches!(
1524                &err,
1525                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["a.txt"]
1526            ),
1527            "{err:?}"
1528        );
1529        assert!(
1530            git_output(&local, &["rev-parse", "refs/stash"])
1531                .status
1532                .code()
1533                == Some(128),
1534            "must not stash when HEAD is unborn"
1535        );
1536    }
1537
1538    #[cfg_attr(
1539        not(feature = "full-tests"),
1540        ignore = "starts system git or is heavy; run with --features full-tests"
1541    )]
1542    #[test]
1543    fn pull_integrates_a_fetched_upstream_without_fetching() {
1544        let tmp = TempDir::new().expect("tempdir");
1545        let (_origin, clone) = origin_and_clone(&tmp);
1546        git(&clone, &["checkout", "-q", "-b", "up"]);
1547        let tip = commit(&clone, "b.txt", "two");
1548        git(&clone, &["checkout", "-q", "main"]);
1549        // A fetch from this remote would fail.
1550        git(
1551            &clone,
1552            &["remote", "set-url", "origin", "/nonexistent/remote"],
1553        );
1554
1555        let repo = open(&clone);
1556        let mut options = pull_options(&repo);
1557        options.upstream = Some(FetchedUpstream {
1558            label: "origin/up".to_owned(),
1559            oid: tip,
1560            landed: None,
1561            copies: Vec::new(),
1562        });
1563        let outcome = pull(&repo, &options, &mut NoProgress, &NeverCancel, None)
1564            .expect("pull without a fetch");
1565        assert_eq!(outcome.result, PullResult::FastForward);
1566        assert_eq!(outcome.label, "origin/up");
1567        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1568        assert_eq!(outcome.transfer, TransferStats::default());
1569    }
1570
1571    /// An origin and a clone whose `main` each changed `a.txt` differently.
1572    fn conflicting_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1573        let (origin, clone) = origin_and_clone(tmp);
1574        let upstream = commit(&origin, "a.txt", "theirs");
1575        let local = commit(&clone, "a.txt", "ours");
1576        git(&clone, &["config", "user.name", "T"]);
1577        git(&clone, &["config", "user.email", "t@example.com"]);
1578        git(&clone, &["fetch", "-q"]);
1579        (clone, local, upstream)
1580    }
1581
1582    fn rebase_options(repo: &Repository, upstream: ObjectId, stop: bool) -> PullOptions {
1583        let mut options = pull_options(repo);
1584        options.mode = Some(PullMode::Rebase);
1585        options.stop_on_conflict = stop;
1586        options.upstream = Some(FetchedUpstream {
1587            label: "origin/main".to_owned(),
1588            oid: upstream,
1589            landed: None,
1590            copies: Vec::new(),
1591        });
1592        options
1593    }
1594
1595    fn abort(repo: &Repository) {
1596        let identity = "T <t@example.com> 0 +0000".parse().expect("signature");
1597        crate::history::abort_rebase(repo, &identity).expect("abort");
1598    }
1599
1600    #[cfg_attr(
1601        not(feature = "full-tests"),
1602        ignore = "starts system git or is heavy; run with --features full-tests"
1603    )]
1604    #[test]
1605    fn a_conflicting_rebase_pull_stops_in_a_resumable_rebase() {
1606        let tmp = TempDir::new().expect("tempdir");
1607        let (clone, local, upstream) = conflicting_clone(&tmp);
1608        let repo = open(&clone);
1609
1610        // Any rebase pull stops; `stop_on_conflict` only changes what it is called.
1611        let plain = pull(
1612            &repo,
1613            &rebase_options(&repo, upstream, false),
1614            &mut NoProgress,
1615            &NeverCancel,
1616            None,
1617        )
1618        .expect("pull");
1619        assert_eq!(plain.result, PullResult::RebaseConflicts);
1620        assert!(clone.join(".git/rebase-merge").exists());
1621        abort(&repo);
1622        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1623
1624        let stopped = pull(
1625            &repo,
1626            &rebase_options(&repo, upstream, true),
1627            &mut NoProgress,
1628            &NeverCancel,
1629            None,
1630        )
1631        .expect("pull");
1632        assert_eq!(stopped.result, PullResult::RebaseStopped);
1633        assert_eq!(stopped.oid, Some(local));
1634        let paths: Vec<&str> = stopped
1635            .conflicts
1636            .iter()
1637            .flatten()
1638            .map(|c| c.path.as_str())
1639            .collect();
1640        assert_eq!(paths, ["a.txt"]);
1641        // The rebase is left for `rebase --continue`; the branch keeps its tip until then.
1642        assert!(clone.join(".git/rebase-merge/grit-plan").is_file());
1643        assert_eq!(git(&clone, &["rev-parse", "main"]), local.to_hex());
1644        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1645        assert!(std::fs::read_to_string(clone.join("a.txt"))
1646            .expect("conflicted file")
1647            .contains("<<<<<<<"));
1648    }
1649
1650    #[cfg_attr(
1651        not(feature = "full-tests"),
1652        ignore = "starts system git or is heavy; run with --features full-tests"
1653    )]
1654    #[test]
1655    fn a_rebase_pull_with_local_changes_is_refused() {
1656        let tmp = TempDir::new().expect("tempdir");
1657        let (clone, _, upstream) = conflicting_clone(&tmp);
1658        let local = commit(&clone, "b.txt", "tracked");
1659        std::fs::write(clone.join("b.txt"), "edited, not committed").expect("write");
1660        let repo = open(&clone);
1661        let refused = pull(
1662            &repo,
1663            &rebase_options(&repo, upstream, true),
1664            &mut NoProgress,
1665            &NeverCancel,
1666            None,
1667        );
1668        assert!(
1669            matches!(
1670                refused,
1671                Err(PullError::Repository(Error::DirtyWorktree { .. }))
1672            ),
1673            "{refused:?}"
1674        );
1675        assert!(!clone.join(".git/rebase-merge").exists());
1676        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1677        assert_eq!(
1678            std::fs::read_to_string(clone.join("b.txt")).expect("read"),
1679            "edited, not committed"
1680        );
1681    }
1682
1683    #[cfg_attr(
1684        not(feature = "full-tests"),
1685        ignore = "starts system git or is heavy; run with --features full-tests"
1686    )]
1687    #[test]
1688    fn reflog_failure_aborts_branch_update() {
1689        let tmp = TempDir::new().expect("tempdir");
1690        let (origin, clone) = origin_and_clone(&tmp);
1691        let _tip = commit(&origin, "b.txt", "two");
1692        let before = git(&clone, &["rev-parse", "main"]);
1693        let branch_log = clone.join(".git/logs/refs/heads/main");
1694        std::fs::remove_file(&branch_log).expect("remove branch reflog");
1695        std::fs::create_dir(&branch_log).expect("replace reflog with directory");
1696
1697        let repo = open(&clone);
1698        let err = pull(
1699            &repo,
1700            &pull_options(&repo),
1701            &mut NoProgress,
1702            &NeverCancel,
1703            None,
1704        )
1705        .expect_err("pull fails when branch reflog cannot be written");
1706        assert!(matches!(err, PullError::Repository(_)), "{err:?}");
1707        assert_eq!(git(&clone, &["rev-parse", "main"]), before);
1708    }
1709
1710    /// A clone whose `main` has three commits appending to `a.txt`, and an upstream commit
1711    /// (`up`, on the clone's starting commit) holding their combined result, as a server's
1712    /// snapshot of a merge of them would. Returns the clone, its tip and the upstream.
1713    fn landed_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1714        let (_origin, clone) = origin_and_clone(tmp);
1715        git(&clone, &["config", "user.name", "T"]);
1716        git(&clone, &["config", "user.email", "t@example.com"]);
1717        std::fs::write(clone.join("b.txt"), "b\n").expect("write");
1718        git(&clone, &["add", "b.txt"]);
1719        git(&clone, &["commit", "-qm", "b"]);
1720        let base = git(&clone, &["rev-parse", "HEAD"]);
1721        let mut text = "one".to_owned();
1722        for line in ["1", "2", "3"] {
1723            text = format!("{text}\n{line}");
1724            commit(&clone, "a.txt", &text);
1725        }
1726        let tip = ObjectId::from_hex(&git(&clone, &["rev-parse", "HEAD"])).expect("oid");
1727        git(&clone, &["switch", "-q", "-c", "up", &base]);
1728        let upstream = commit(&clone, "a.txt", &text);
1729        git(&clone, &["switch", "-q", "main"]);
1730        (clone, tip, upstream)
1731    }
1732
1733    fn landed_options(
1734        repo: &Repository,
1735        upstream: ObjectId,
1736        landed: ObjectId,
1737        stop: bool,
1738    ) -> PullOptions {
1739        let mut options = rebase_options(repo, upstream, stop);
1740        if let Some(fetched) = options.upstream.as_mut() {
1741            fetched.landed = Some(landed);
1742        }
1743        options
1744    }
1745
1746    #[cfg_attr(
1747        not(feature = "full-tests"),
1748        ignore = "starts system git or is heavy; run with --features full-tests"
1749    )]
1750    #[test]
1751    fn a_landed_branch_moves_to_the_upstream_and_keeps_local_changes() {
1752        let tmp = TempDir::new().expect("tempdir");
1753        let (clone, tip, upstream) = landed_clone(&tmp);
1754        let repo = open(&clone);
1755        // Replayed one by one, the first commit conflicts with the upstream that has all three.
1756        let replayed = pull(
1757            &repo,
1758            &rebase_options(&repo, upstream, false),
1759            &mut NoProgress,
1760            &NeverCancel,
1761            None,
1762        )
1763        .expect("pull");
1764        assert_eq!(replayed.result, PullResult::RebaseConflicts);
1765        abort(&repo);
1766        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1767
1768        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1769        std::fs::write(clone.join("draft.txt"), "not committed\n").expect("write");
1770        let mut check = landed_options(&repo, upstream, tip, false);
1771        check.check = true;
1772        let predicted = pull(&repo, &check, &mut NoProgress, &NeverCancel, None).expect("check");
1773        assert_eq!(predicted.check.map(|c| c.would), Some(PullResult::Landed));
1774
1775        git(&clone, &["update-ref", "-d", "ORIG_HEAD"]);
1776        let landed = pull(
1777            &repo,
1778            &landed_options(&repo, upstream, tip, true),
1779            &mut NoProgress,
1780            &NeverCancel,
1781            None,
1782        )
1783        .expect("pull");
1784        assert_eq!(landed.result, PullResult::Landed);
1785        assert_eq!(landed.oid, Some(upstream));
1786        assert_eq!(git(&clone, &["rev-parse", "main"]), upstream.to_hex());
1787        assert_eq!(git(&clone, &["rev-parse", "ORIG_HEAD"]), tip.to_hex());
1788        assert_eq!(
1789            git(&clone, &["status", "--porcelain"]),
1790            "M b.txt\n?? draft.txt"
1791        );
1792        assert_eq!(
1793            git(&clone, &["reflog", "-1", "--format=%gs", "main"]),
1794            "pull: move to origin/main, which has the branch's commits"
1795        );
1796    }
1797
1798    #[cfg_attr(
1799        not(feature = "full-tests"),
1800        ignore = "starts system git or is heavy; run with --features full-tests"
1801    )]
1802    #[test]
1803    fn a_landed_branch_does_not_move_over_local_changes_the_upstream_has_other_content_for() {
1804        let tmp = TempDir::new().expect("tempdir");
1805        let (clone, tip, _) = landed_clone(&tmp);
1806        // The upstream has the branch's commits and a change of its own to b.txt.
1807        git(&clone, &["switch", "-q", "up"]);
1808        let upstream = commit(&clone, "b.txt", "b, upstream");
1809        git(&clone, &["switch", "-q", "main"]);
1810        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1811        let repo = open(&clone);
1812        let err = pull(
1813            &repo,
1814            &landed_options(&repo, upstream, tip, true),
1815            &mut NoProgress,
1816            &NeverCancel,
1817            None,
1818        )
1819        .expect_err("blocked");
1820        assert!(
1821            matches!(
1822                &err,
1823                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["b.txt"]
1824            ),
1825            "{err:?}"
1826        );
1827        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1828        assert_eq!(git(&clone, &["status", "--porcelain"]), "M b.txt");
1829    }
1830
1831    #[cfg_attr(
1832        not(feature = "full-tests"),
1833        ignore = "starts system git or is heavy; run with --features full-tests"
1834    )]
1835    #[test]
1836    fn commits_above_a_landed_one_are_rebased_without_it() {
1837        let tmp = TempDir::new().expect("tempdir");
1838        let (clone, landed, upstream) = landed_clone(&tmp);
1839        let own = commit(&clone, "c.txt", "c");
1840        let repo = open(&clone);
1841        let outcome = pull(
1842            &repo,
1843            &landed_options(&repo, upstream, landed, true),
1844            &mut NoProgress,
1845            &NeverCancel,
1846            None,
1847        )
1848        .expect("pull");
1849        assert_eq!(outcome.result, PullResult::Rebased);
1850        assert_eq!(git(&clone, &["rev-parse", "main^"]), upstream.to_hex());
1851        assert_eq!(git(&clone, &["log", "-1", "--format=%s", "main"]), "c");
1852        assert_ne!(git(&clone, &["rev-parse", "main"]), own.to_hex());
1853
1854        // One that conflicts stops on its own commit, not on the landed ones.
1855        let tmp = TempDir::new().expect("tempdir");
1856        let (clone, landed, _) = landed_clone(&tmp);
1857        commit(&clone, "b.txt", "b, ours");
1858        git(&clone, &["switch", "-q", "up"]);
1859        let upstream = commit(&clone, "b.txt", "b, theirs");
1860        git(&clone, &["switch", "-q", "main"]);
1861        let repo = open(&clone);
1862        let stopped = pull(
1863            &repo,
1864            &landed_options(&repo, upstream, landed, true),
1865            &mut NoProgress,
1866            &NeverCancel,
1867            None,
1868        )
1869        .expect("pull");
1870        assert_eq!(stopped.result, PullResult::RebaseStopped);
1871        let paths: Vec<&str> = stopped
1872            .conflicts
1873            .iter()
1874            .flatten()
1875            .map(|c| c.path.as_str())
1876            .collect();
1877        assert_eq!(paths, ["b.txt"]);
1878        // Nothing was replayed before it: the session stopped right on the upstream.
1879        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1880    }
1881}