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, merge_bases_first_vs_rest};
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, start_rebase, ApplyOptions, EmptyPolicy, HistoryError, PreRebase,
41    RebaseCheckPlan, RebaseOntoOptions, RebaseOutcome, RebaseRequest, RebaseSessionError, RefScope,
42    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 {
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) = match &options.upstream {
550            Some(fetched) => (fetched.label.clone(), fetched.oid),
551            None => {
552                let upstream =
553                    resolve_pull_integration_base(repo, &short_name, &remote_name, explicit_fetch)
554                        .ok_or(PullError::NoUpstream {
555                            branch: short_name.clone(),
556                        })?;
557                (upstream.name, upstream.oid)
558            }
559        };
560
561        let landed = options.upstream.as_ref().and_then(|u| u.landed);
562        if options.check {
563            return pull_check_outcome(repo, head_oid, upstream_oid, &label, integration, landed);
564        }
565
566        match head_oid {
567            None => adopt_unborn_upstream(repo, &refname, &short_name, &label, upstream_oid),
568            Some(head_oid) => integrate_fetched_upstream(
569                repo,
570                &refname,
571                head_oid,
572                Upstream {
573                    oid: upstream_oid,
574                    label: &label,
575                    landed,
576                    copies: options
577                        .upstream
578                        .as_ref()
579                        .map_or(&[], |u| u.copies.as_slice()),
580                },
581                integration,
582                stop_on_conflict,
583                progress,
584                cancel,
585            ),
586        }
587    })();
588    let core = core.map(|outcome| PullOutcome {
589        transfer,
590        ..outcome
591    });
592
593    match core {
594        Err(err) => {
595            // A hook that refused the merge commit leaves the merge in progress. The stash then
596            // waits in MERGE_AUTOSTASH, as with Git, so `merge --continue` does not auto-stage it.
597            match stash_oid.as_ref() {
598                Some(oid) if !merge_was_in_progress && merge_in_progress() => {
599                    record_merge_autostash(repo, oid).map_err(PullError::Repository)?;
600                }
601                _ => restore_autostash_after_abort(repo, stash_oid.as_ref()),
602            }
603            Err(err)
604        }
605        Ok(outcome) => {
606            if fetch_dry_run {
607                restore_autostash_after_abort(repo, stash_oid.as_ref());
608                return Ok(outcome);
609            }
610            if outcome.result == PullResult::RebaseConflicts {
611                // Applying the stash now would mix it into the conflict markers; it stays on
612                // `refs/stash` for the caller to pop once the rebase is finished or aborted.
613                return Ok(PullOutcome {
614                    autostash: stash_oid,
615                    ..outcome
616                });
617            }
618            if matches!(
619                outcome.result,
620                PullResult::Conflicts | PullResult::RefusedNotFf | PullResult::RebaseStopped
621            ) {
622                restore_autostash_after_abort(repo, stash_oid.as_ref());
623                return Ok(outcome);
624            }
625            apply_autostash_after_pull(repo, stash_oid.as_ref(), Ok(outcome))
626        }
627    }
628}
629
630/// Merge [`PullOptions::branch`] into fetch refspecs for embedders and tests.
631fn pull_fetch_refspecs(options: &PullOptions) -> (bool, Vec<String>) {
632    let mut refspecs = options.fetch.refspecs.clone();
633    let mut explicit = options.explicit_fetch;
634    if let Some(branch) = options.branch.as_deref().filter(|b| !b.is_empty()) {
635        if refspecs.is_empty() {
636            refspecs.push(branch.to_owned());
637        }
638        explicit = true;
639    }
640    (explicit, refspecs)
641}
642
643fn restore_autostash_after_abort(repo: &Repository, stash_oid: Option<&ObjectId>) {
644    let Some(stash_oid) = stash_oid else {
645        return;
646    };
647    let _ = restore_operation_autostash(repo, stash_oid);
648}
649
650fn apply_autostash_after_pull(
651    repo: &Repository,
652    stash_oid: Option<&ObjectId>,
653    outcome: std::result::Result<PullOutcome, PullError>,
654) -> std::result::Result<PullOutcome, PullError> {
655    let mut outcome = outcome?;
656    if stash_oid.is_none() {
657        return Ok(outcome);
658    }
659    if !matches!(
660        outcome.result,
661        PullResult::UpToDate
662            | PullResult::FastForward
663            | PullResult::Merged
664            | PullResult::Rebased
665            | PullResult::Landed
666            | PullResult::SetUpstream
667    ) {
668        return Ok(outcome);
669    }
670    if let Some(stash_oid) = stash_oid {
671        if apply_operation_autostash(repo, stash_oid).map_err(PullError::Repository)? {
672            outcome.result = PullResult::AutostashConflict;
673            outcome.autostash = Some(*stash_oid);
674        }
675    }
676    Ok(outcome)
677}
678
679fn pull_check_outcome(
680    repo: &Repository,
681    head_oid: Option<ObjectId>,
682    upstream_oid: ObjectId,
683    label: &str,
684    integration: PullIntegration,
685    landed: Option<ObjectId>,
686) -> std::result::Result<PullOutcome, PullError> {
687    let (ahead, behind, would, fast_forward_possible) = match head_oid {
688        None => (0, 0, PullResult::SetUpstream, true),
689        Some(head_oid) => {
690            let (ahead, behind) = count_symmetric_ahead_behind(repo, head_oid, upstream_oid)
691                .map_err(PullError::Repository)?;
692            let would = if landed == Some(head_oid) && diverged(repo, head_oid, upstream_oid)? {
693                PullResult::Landed
694            } else {
695                predict_pull_result(repo, head_oid, upstream_oid, integration)?
696            };
697            let up_to_date = head_oid == upstream_oid || is_ancestor(repo, upstream_oid, head_oid)?;
698            let fast_forward = !up_to_date
699                && is_ancestor(repo, head_oid, upstream_oid).map_err(PullError::Repository)?;
700            (ahead, behind, would, up_to_date || fast_forward)
701        }
702    };
703    Ok(PullOutcome {
704        oid: head_oid,
705        change_id: head_oid
706            .and_then(|oid| logical_change_id_for_commit(repo, oid).ok())
707            .flatten(),
708        ahead: Some(ahead),
709        behind: Some(behind),
710        check: Some(PullCheck {
711            ahead,
712            behind,
713            fast_forward_possible,
714            would,
715        }),
716        ..PullOutcome::new(would, label)
717    })
718}
719
720fn predict_pull_result(
721    repo: &Repository,
722    head_oid: ObjectId,
723    upstream_oid: ObjectId,
724    integration: PullIntegration,
725) -> std::result::Result<PullResult, PullError> {
726    if head_oid == upstream_oid || is_ancestor(repo, upstream_oid, head_oid)? {
727        return Ok(PullResult::UpToDate);
728    }
729    if integration.allow_fast_forward && is_ancestor(repo, head_oid, upstream_oid)? {
730        return Ok(PullResult::FastForward);
731    }
732    let diverged = !is_ancestor(repo, upstream_oid, head_oid)?
733        && !is_ancestor(repo, head_oid, upstream_oid)?
734        && head_oid != upstream_oid;
735    if diverged {
736        return match integration.mode {
737            PullMode::FfOnly => Ok(PullResult::RefusedNotFf),
738            PullMode::Rebase => {
739                let mut opts = pull_rebase_options(repo)?;
740                opts.rewrite.sign = false;
741                let request = RebaseRequest {
742                    upstream: upstream_oid,
743                    onto: None,
744                    branch_tip: head_oid,
745                };
746                match plan_rebase_check(repo, &request, &opts).map_err(map_history_error)? {
747                    RebaseCheckPlan::UpToDate | RebaseCheckPlan::Clean { .. } => {
748                        Ok(PullResult::Rebased)
749                    }
750                    RebaseCheckPlan::Conflict { .. } => Ok(PullResult::RebaseConflicts),
751                }
752            }
753            PullMode::Merge => match predict_merge(
754                repo,
755                head_oid,
756                upstream_oid,
757                &MergePredictionOptions {
758                    allow_fast_forward: integration.allow_fast_forward,
759                    theirs_label: "upstream".into(),
760                    ..Default::default()
761                },
762            )
763            .map_err(PullError::Repository)?
764            {
765                MergePrediction::UpToDate => Ok(PullResult::UpToDate),
766                MergePrediction::FastForward => Ok(PullResult::FastForward),
767                MergePrediction::Clean { .. } => Ok(PullResult::Merged),
768                MergePrediction::Conflicts { .. } => Ok(PullResult::Conflicts),
769                MergePrediction::Unrelated => Ok(PullResult::Conflicts),
770            },
771        };
772    }
773    Ok(PullResult::Merged)
774}
775
776fn adopt_unborn_upstream(
777    repo: &Repository,
778    refname: &str,
779    short_name: &str,
780    label: &str,
781    upstream_oid: ObjectId,
782) -> std::result::Result<PullOutcome, PullError> {
783    ensure_branch_tip(repo, refname, None)?;
784    let upstream_tree = repo.odb().commit_tree(&upstream_oid)?;
785    prepare_tree_checkout(repo, None, &upstream_tree).map_err(PullError::Repository)?;
786    checkout_between_trees(repo, None, &upstream_tree).map_err(PullError::Repository)?;
787    update_checked_out_branch(repo, refname, None, upstream_oid, INITIAL_PULL_REFLOG)?;
788    Ok(PullOutcome {
789        upstream: Some(label.to_owned()),
790        ..PullOutcome::at_commit(PullResult::SetUpstream, short_name, repo, upstream_oid)?
791    })
792}
793
794/// Whether `head_oid` and `upstream_oid` each have commits the other lacks.
795fn diverged(
796    repo: &Repository,
797    head_oid: ObjectId,
798    upstream_oid: ObjectId,
799) -> std::result::Result<bool, PullError> {
800    Ok(head_oid != upstream_oid
801        && !is_ancestor(repo, upstream_oid, head_oid).map_err(PullError::Repository)?
802        && !is_ancestor(repo, head_oid, upstream_oid).map_err(PullError::Repository)?)
803}
804
805/// The upstream a pull integrates.
806#[derive(Clone, Copy)]
807struct Upstream<'a> {
808    oid: ObjectId,
809    label: &'a str,
810    /// [`FetchedUpstream::landed`].
811    landed: Option<ObjectId>,
812    /// [`FetchedUpstream::copies`].
813    copies: &'a [ObjectId],
814}
815
816#[allow(clippy::too_many_arguments)]
817fn integrate_fetched_upstream(
818    repo: &Repository,
819    refname: &str,
820    head_oid: ObjectId,
821    upstream: Upstream<'_>,
822    integration: PullIntegration,
823    stop_on_conflict: bool,
824    progress: &mut dyn ProgressSink,
825    cancel: &dyn Cancel,
826) -> std::result::Result<PullOutcome, PullError> {
827    let Upstream {
828        oid: upstream_oid,
829        label,
830        landed,
831        ..
832    } = upstream;
833    let diverged = diverged(repo, head_oid, upstream_oid)?;
834
835    if diverged && landed == Some(head_oid) {
836        return move_landed_branch(
837            repo,
838            refname,
839            head_oid,
840            upstream_oid,
841            label,
842            progress,
843            cancel,
844        );
845    }
846
847    if diverged {
848        match integration.mode {
849            PullMode::FfOnly => {
850                let (ahead, behind) = count_symmetric_ahead_behind(repo, head_oid, upstream_oid)
851                    .map_err(PullError::Repository)?;
852                return Ok(PullOutcome {
853                    ahead: Some(ahead),
854                    behind: Some(behind),
855                    ..PullOutcome::at_commit(PullResult::RefusedNotFf, label, repo, head_oid)?
856                });
857            }
858            PullMode::Rebase => {
859                return rebase_fetched_upstream(
860                    repo,
861                    refname,
862                    head_oid,
863                    upstream,
864                    stop_on_conflict,
865                    progress,
866                    cancel,
867                );
868            }
869            PullMode::Merge => {}
870        }
871    }
872
873    let integrate = integrate_branch_with_options(
874        repo,
875        refname,
876        head_oid,
877        upstream_oid,
878        label,
879        IntegrateBranchOptions {
880            allow_fast_forward: integration.allow_fast_forward,
881        },
882        progress,
883        cancel,
884    )
885    .map_err(PullError::Repository)?;
886    Ok(map_integrate_outcome(integrate, label))
887}
888
889#[allow(clippy::too_many_arguments)]
890fn rebase_fetched_upstream(
891    repo: &Repository,
892    refname: &str,
893    head_oid: ObjectId,
894    upstream: Upstream<'_>,
895    stop_on_conflict: bool,
896    progress: &mut dyn ProgressSink,
897    cancel: &dyn Cancel,
898) -> std::result::Result<PullOutcome, PullError> {
899    let Upstream {
900        oid: upstream_oid,
901        label,
902        landed,
903        copies,
904    } = upstream;
905    let fork = merge_bases_first_vs_rest(repo, head_oid, &[upstream_oid])
906        .map_err(PullError::Repository)?
907        .into_iter()
908        .next()
909        .ok_or_else(|| {
910            PullError::Repository(Error::Message(format!(
911                "'{label}' has no common history with the current branch"
912            )))
913        })?;
914    // Commits up to a landed one are in the upstream already: replay only those above it,
915    // as `git rebase --onto <upstream> <landed>` does.
916    let request = match landed {
917        Some(base)
918            if base != fork
919                && is_ancestor(repo, base, head_oid).map_err(PullError::Repository)?
920                && is_ancestor(repo, fork, base).map_err(PullError::Repository)? =>
921        {
922            RebaseRequest {
923                upstream: base,
924                onto: Some(upstream_oid),
925                branch_tip: head_oid,
926            }
927        }
928        _ => RebaseRequest {
929            upstream: upstream_oid,
930            onto: None,
931            branch_tip: head_oid,
932        },
933    };
934
935    let mut rebase = pull_rebase_options(repo)?;
936    // The upstream already has these commits: they are its copies of local ones.
937    rebase.drop = copies.to_vec();
938    // Git's pull hands rebase the fork point (or the fetched tip) as a commit id.
939    let pre_rebase = PreRebase::Run {
940        upstream: Some(request.upstream.to_hex()),
941    };
942    let start = StartRebaseOptions {
943        request,
944        rebase,
945        apply: ApplyOptions {
946            identity: repo
947                .identity(IdentRole::Committer, repo.identity_now())
948                .map_err(PullError::Repository)?,
949            reflog_message: format!("pull: rebase onto {label}"),
950            record_operation: Some("rebase".into()),
951            hooks: crate::hooks::PostOperationHooks::Run,
952        },
953        pre_rebase,
954    };
955    match start_rebase(repo, &start, progress, cancel).map_err(map_rebase_session_error)? {
956        RebaseOutcome::Stopped(stop) if stop_on_conflict => Ok(PullOutcome {
957            conflicts: Some(stop.conflicts),
958            stopped_commit: Some(stop.commit),
959            ..PullOutcome::at_commit(PullResult::RebaseStopped, label, repo, head_oid)?
960        }),
961        RebaseOutcome::Stopped(stop) => Ok(PullOutcome {
962            conflicts: Some(stop.conflicts),
963            stopped_commit: Some(stop.commit),
964            ..PullOutcome::new(PullResult::RebaseConflicts, label)
965        }),
966        RebaseOutcome::Done { applied, .. } | RebaseOutcome::ExecDone { applied, .. }
967            if applied.head != head_oid =>
968        {
969            Ok(PullOutcome::at_commit(
970                PullResult::Rebased,
971                label,
972                repo,
973                applied.head,
974            )?)
975        }
976        // Nothing was rewritten: integrate like `git pull --rebase`, which fast-forwards when
977        // the branch is simply behind the upstream tip.
978        RebaseOutcome::Done { .. } | RebaseOutcome::ExecDone { .. } => {
979            let integrate = integrate_branch_with_options(
980                repo,
981                refname,
982                head_oid,
983                upstream_oid,
984                label,
985                IntegrateBranchOptions {
986                    allow_fast_forward: true,
987                },
988                progress,
989                cancel,
990            )
991            .map_err(PullError::Repository)?;
992            Ok(map_integrate_outcome(integrate, label))
993        }
994    }
995}
996
997/// Replay options shared by the rebase pull and its `--check` prediction.
998fn pull_rebase_options(repo: &Repository) -> std::result::Result<RebaseOntoOptions, PullError> {
999    let committer = repo
1000        .identity(IdentRole::Committer, repo.identity_now())
1001        .map_err(PullError::Repository)?;
1002    let mut opts = RebaseOntoOptions::new(RewriteOptions {
1003        scope: RefScope::Head,
1004        empty: EmptyPolicy::Drop,
1005        committer,
1006        preserve_committer: false,
1007        sign: true,
1008    });
1009    opts.flatten_leaf_merges = true;
1010    Ok(opts)
1011}
1012
1013/// Move the checked-out branch from `head_oid`, whose commits `upstream_oid` already has
1014/// ([`FetchedUpstream::landed`]), to `upstream_oid`, as `git reset --keep` does: the index
1015/// and worktree go from the one tree to the other, keeping uncommitted and untracked changes
1016/// that the move doesn't touch, and nothing moves when it would overwrite some.
1017#[allow(clippy::too_many_arguments)]
1018fn move_landed_branch(
1019    repo: &Repository,
1020    refname: &str,
1021    head_oid: ObjectId,
1022    upstream_oid: ObjectId,
1023    label: &str,
1024    progress: &mut dyn ProgressSink,
1025    cancel: &dyn Cancel,
1026) -> std::result::Result<PullOutcome, PullError> {
1027    ensure_branch_tip(repo, refname, Some(head_oid))?;
1028    if repo.load_index()?.has_unmerged_entries() {
1029        return Err(Error::IndexUnmerged.into());
1030    }
1031    let from = repo.odb().commit_tree(&head_oid)?;
1032    let to = repo.odb().commit_tree(&upstream_oid)?;
1033    checkout_tree_transition_allow_local_reset(repo, Some(&from), &to, progress, cancel).map_err(
1034        |e| match e {
1035            Error::WouldOverwrite {
1036                reason: OverwriteReason::LocalChanges,
1037                paths,
1038                ..
1039            } => local_changes_would_be_overwritten(paths),
1040            other => other,
1041        },
1042    )?;
1043    update_checked_out_branch(
1044        repo,
1045        refname,
1046        Some(head_oid),
1047        upstream_oid,
1048        &format!("pull: move to {label}, which has the branch's commits"),
1049    )?;
1050    crate::state::write_orig_head(repo, head_oid)?;
1051    Ok(PullOutcome::at_commit(
1052        PullResult::Landed,
1053        label,
1054        repo,
1055        upstream_oid,
1056    )?)
1057}
1058
1059/// [`Error::WouldOverwrite`] for uncommitted changes to `paths` that a pull would overwrite.
1060fn local_changes_would_be_overwritten(paths: Vec<String>) -> Error {
1061    Error::WouldOverwrite {
1062        reason: OverwriteReason::LocalChanges,
1063        paths,
1064        message: "local changes would be overwritten".to_owned(),
1065    }
1066}
1067
1068fn map_history_error(err: HistoryError) -> PullError {
1069    match err {
1070        HistoryError::Library(e) => PullError::Repository(*e),
1071        HistoryError::LocalChangesWouldBeOverwritten { paths } => {
1072            PullError::Repository(local_changes_would_be_overwritten(paths))
1073        }
1074        other => PullError::Repository(Error::Message(other.to_string())),
1075    }
1076}
1077
1078fn map_rebase_session_error(err: RebaseSessionError) -> PullError {
1079    match err {
1080        RebaseSessionError::Library(e) => PullError::Repository(e),
1081        RebaseSessionError::History(e) => map_history_error(e),
1082        other => PullError::Rebase(other),
1083    }
1084}
1085
1086fn map_integrate_outcome(outcome: IntegrateBranchOutcome, label: &str) -> PullOutcome {
1087    match outcome {
1088        IntegrateBranchOutcome::UpToDate => PullOutcome::new(PullResult::UpToDate, label),
1089        IntegrateBranchOutcome::FastForward { oid, change_id } => PullOutcome {
1090            oid: Some(oid),
1091            change_id,
1092            ..PullOutcome::new(PullResult::FastForward, label)
1093        },
1094        IntegrateBranchOutcome::Merged { oid, change_id } => PullOutcome {
1095            oid: Some(oid),
1096            change_id,
1097            ..PullOutcome::new(PullResult::Merged, label)
1098        },
1099        IntegrateBranchOutcome::Conflicts { details, .. } => PullOutcome {
1100            conflicts: Some(details),
1101            ..PullOutcome::new(PullResult::Conflicts, label)
1102        },
1103        IntegrateBranchOutcome::Squashed | IntegrateBranchOutcome::Stopped => {
1104            unreachable!("pull does not use squash or no-commit merge modes")
1105        }
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use crate::config::environment::RepositoryOptions;
1113    use crate::config::ConfigSet;
1114    use crate::environment::Environment;
1115    use crate::progress::NeverCancel;
1116    use crate::remote::fetch::NoProgress;
1117    use std::path::{Path, PathBuf};
1118    use std::process::{Command, Output};
1119    use tempfile::TempDir;
1120
1121    fn git_output(dir: &Path, args: &[&str]) -> Output {
1122        Command::new("git")
1123            .current_dir(dir)
1124            .args(args)
1125            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1126            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1127            .env("GIT_AUTHOR_NAME", "T")
1128            .env("GIT_AUTHOR_EMAIL", "t@example.com")
1129            .env("GIT_COMMITTER_NAME", "T")
1130            .env("GIT_COMMITTER_EMAIL", "t@example.com")
1131            .output()
1132            .expect("run git")
1133    }
1134
1135    fn git(dir: &Path, args: &[&str]) -> String {
1136        let out = git_output(dir, args);
1137        assert!(
1138            out.status.success(),
1139            "git {args:?} failed: {}",
1140            String::from_utf8_lossy(&out.stderr)
1141        );
1142        String::from_utf8_lossy(&out.stdout).trim().to_owned()
1143    }
1144
1145    fn open(dir: &Path) -> Repository {
1146        Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open")
1147    }
1148
1149    fn commit(dir: &Path, file: &str, contents: &str) -> ObjectId {
1150        std::fs::write(dir.join(file), contents).expect("write");
1151        git(dir, &["add", file]);
1152        git(dir, &["commit", "-qm", contents]);
1153        ObjectId::from_hex(&git(dir, &["rev-parse", "HEAD"])).expect("oid")
1154    }
1155
1156    fn origin_and_clone(tmp: &TempDir) -> (PathBuf, PathBuf) {
1157        let origin = tmp.path().join("origin");
1158        std::fs::create_dir_all(&origin).expect("mkdir origin");
1159        git(&origin, &["init", "-q", "-b", "main"]);
1160        commit(&origin, "a.txt", "one");
1161        git(tmp.path(), &["clone", "-q", "origin", "clone"]);
1162        (origin, tmp.path().join("clone"))
1163    }
1164
1165    fn pull_options(repo: &Repository) -> PullOptions {
1166        PullOptions::for_working_branch(repo, Environment::empty(), None).expect("options")
1167    }
1168
1169    fn cfg(pairs: &[(&str, &str)]) -> ConfigSet {
1170        let mut set = ConfigSet::new();
1171        for (key, value) in pairs {
1172            set.add_command_override(key, value).expect("override");
1173        }
1174        set
1175    }
1176
1177    #[test]
1178    fn resolve_pull_mode_cli_ff_only_wins() {
1179        let config = cfg(&[("pull.rebase", "true")]);
1180        assert_eq!(
1181            resolve_pull_mode(&config, Some("main"), Some(PullMode::FfOnly)).expect("mode"),
1182            PullMode::FfOnly
1183        );
1184    }
1185
1186    #[test]
1187    fn resolve_pull_mode_no_rebase_overrides_rebase_config() {
1188        let config = cfg(&[("pull.rebase", "true")]);
1189        assert_eq!(
1190            resolve_pull_mode(&config, Some("main"), Some(PullMode::Merge)).expect("mode"),
1191            PullMode::Merge
1192        );
1193    }
1194
1195    #[test]
1196    fn resolve_pull_mode_rebase_from_config() {
1197        let config = cfg(&[("pull.rebase", "merges")]);
1198        assert_eq!(
1199            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1200            PullMode::Rebase
1201        );
1202    }
1203
1204    #[test]
1205    fn resolve_pull_mode_branch_rebase_overrides_global() {
1206        let config = cfg(&[("pull.rebase", "false"), ("branch.topic.rebase", "true")]);
1207        assert_eq!(
1208            resolve_pull_mode(&config, Some("topic"), None).expect("mode"),
1209            PullMode::Rebase
1210        );
1211    }
1212
1213    #[test]
1214    fn resolve_pull_mode_interactive_is_error() {
1215        let config = cfg(&[("pull.rebase", "interactive")]);
1216        assert!(matches!(
1217            resolve_pull_mode(&config, Some("main"), None),
1218            Err(PullError::RebaseInteractive)
1219        ));
1220    }
1221
1222    #[test]
1223    fn resolve_pull_mode_ff_only_from_config() {
1224        let config = cfg(&[("pull.ff", "only")]);
1225        assert_eq!(
1226            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1227            PullMode::FfOnly
1228        );
1229    }
1230
1231    #[test]
1232    fn resolve_pull_mode_ff_only_beats_rebase_config() {
1233        let config = cfg(&[("pull.ff", "only"), ("pull.rebase", "true")]);
1234        assert_eq!(
1235            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1236            PullMode::FfOnly
1237        );
1238    }
1239
1240    #[test]
1241    fn resolve_pull_mode_defaults_to_merge() {
1242        let config = ConfigSet::new();
1243        assert_eq!(
1244            resolve_pull_mode(&config, Some("main"), None).expect("mode"),
1245            PullMode::Merge
1246        );
1247    }
1248
1249    #[test]
1250    fn resolve_pull_integration_ff_false_disables_fast_forward() {
1251        let config = cfg(&[("pull.ff", "false")]);
1252        let resolved = resolve_pull_integration(&config, Some("main"), None).expect("resolved");
1253        assert_eq!(resolved.mode, PullMode::Merge);
1254        assert!(!resolved.allow_fast_forward);
1255    }
1256
1257    #[cfg_attr(
1258        not(feature = "full-tests"),
1259        ignore = "starts system git or is heavy; run with --features full-tests"
1260    )]
1261    #[test]
1262    fn pull_maps_custom_fetch_refspec() {
1263        let tmp = TempDir::new().expect("tempdir");
1264        let (origin, clone) = origin_and_clone(&tmp);
1265        let tip = commit(&origin, "b.txt", "two");
1266        git(
1267            &clone,
1268            &[
1269                "config",
1270                "--replace-all",
1271                "remote.origin.fetch",
1272                "+refs/heads/*:refs/remotes/mirror/*",
1273            ],
1274        );
1275
1276        let repo = open(&clone);
1277        let outcome = pull(
1278            &repo,
1279            &pull_options(&repo),
1280            &mut NoProgress,
1281            &NeverCancel,
1282            None,
1283        )
1284        .expect("pull succeeds");
1285        assert_eq!(outcome.result, PullResult::FastForward);
1286        assert_eq!(outcome.oid, Some(tip));
1287        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1288    }
1289
1290    #[cfg_attr(
1291        not(feature = "full-tests"),
1292        ignore = "starts system git or is heavy; run with --features full-tests"
1293    )]
1294    #[test]
1295    fn pull_local_upstream_skips_fetch() {
1296        let tmp = TempDir::new().expect("tempdir");
1297        let dir = tmp.path();
1298        git(dir, &["init", "-q", "-b", "main"]);
1299        commit(dir, "a.txt", "one");
1300        git(dir, &["checkout", "-q", "-b", "dev"]);
1301        let tip = commit(dir, "b.txt", "two");
1302        git(dir, &["checkout", "-q", "main"]);
1303        git(dir, &["branch", "--set-upstream-to", "dev", "main"]);
1304
1305        let repo = open(dir);
1306        let outcome = pull(
1307            &repo,
1308            &pull_options(&repo),
1309            &mut NoProgress,
1310            &NeverCancel,
1311            None,
1312        )
1313        .expect("local pull succeeds");
1314        assert_eq!(outcome.result, PullResult::FastForward);
1315        assert_eq!(outcome.oid, Some(tip));
1316        assert_eq!(git(dir, &["rev-parse", "main"]), tip.to_hex());
1317        assert_eq!(outcome.transfer, TransferStats::default());
1318    }
1319
1320    #[cfg_attr(
1321        not(feature = "full-tests"),
1322        ignore = "starts system git or is heavy; run with --features full-tests"
1323    )]
1324    #[test]
1325    fn pull_reports_objects_received_by_its_fetch() {
1326        let tmp = TempDir::new().expect("tempdir");
1327        let (origin, clone) = origin_and_clone(&tmp);
1328        commit(&origin, "b.txt", "two");
1329
1330        let repo = open(&clone);
1331        let outcome = pull(
1332            &repo,
1333            &pull_options(&repo),
1334            &mut NoProgress,
1335            &NeverCancel,
1336            None,
1337        )
1338        .expect("pull succeeds");
1339        assert_eq!(outcome.result, PullResult::FastForward);
1340        // One new commit, its root tree and the added blob.
1341        assert_eq!(outcome.transfer.objects, 3, "{:?}", outcome.transfer);
1342        assert!(outcome.transfer.bytes > 0, "{:?}", outcome.transfer);
1343
1344        let again = pull(
1345            &repo,
1346            &pull_options(&repo),
1347            &mut NoProgress,
1348            &NeverCancel,
1349            None,
1350        )
1351        .expect("second pull");
1352        assert_eq!(again.result, PullResult::UpToDate);
1353        assert_eq!(again.transfer.objects, 0, "{:?}", again.transfer);
1354    }
1355
1356    #[cfg_attr(
1357        not(feature = "full-tests"),
1358        ignore = "starts system git or is heavy; run with --features full-tests"
1359    )]
1360    #[test]
1361    fn pull_adopts_upstream_on_unborn_branch_with_reflog() {
1362        let tmp = TempDir::new().expect("tempdir");
1363        let origin = tmp.path().join("origin");
1364        std::fs::create_dir_all(&origin).expect("mkdir origin");
1365        git(&origin, &["init", "-q", "-b", "main"]);
1366        let tip = commit(&origin, "a.txt", "one");
1367        let local = tmp.path().join("local");
1368        std::fs::create_dir_all(&local).expect("mkdir local");
1369        git(&local, &["init", "-q", "-b", "main"]);
1370        git(&local, &["remote", "add", "origin", "../origin"]);
1371
1372        let repo = open(&local);
1373        let outcome = pull(
1374            &repo,
1375            &pull_options(&repo),
1376            &mut NoProgress,
1377            &NeverCancel,
1378            None,
1379        )
1380        .expect("pull succeeds");
1381        assert_eq!(outcome.result, PullResult::SetUpstream);
1382        assert_eq!(outcome.oid, Some(tip));
1383        assert_eq!(
1384            git(&local, &["reflog", "show", "--format=%H %gs", "-1", "main"]),
1385            format!("{} {INITIAL_PULL_REFLOG}", tip.to_hex())
1386        );
1387    }
1388
1389    #[cfg_attr(
1390        not(feature = "full-tests"),
1391        ignore = "starts system git or is heavy; run with --features full-tests"
1392    )]
1393    #[test]
1394    fn pull_integrates_a_fetched_upstream_without_fetching() {
1395        let tmp = TempDir::new().expect("tempdir");
1396        let (_origin, clone) = origin_and_clone(&tmp);
1397        git(&clone, &["checkout", "-q", "-b", "up"]);
1398        let tip = commit(&clone, "b.txt", "two");
1399        git(&clone, &["checkout", "-q", "main"]);
1400        // A fetch from this remote would fail.
1401        git(
1402            &clone,
1403            &["remote", "set-url", "origin", "/nonexistent/remote"],
1404        );
1405
1406        let repo = open(&clone);
1407        let mut options = pull_options(&repo);
1408        options.upstream = Some(FetchedUpstream {
1409            label: "origin/up".to_owned(),
1410            oid: tip,
1411            landed: None,
1412            copies: Vec::new(),
1413        });
1414        let outcome = pull(&repo, &options, &mut NoProgress, &NeverCancel, None)
1415            .expect("pull without a fetch");
1416        assert_eq!(outcome.result, PullResult::FastForward);
1417        assert_eq!(outcome.label, "origin/up");
1418        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1419        assert_eq!(outcome.transfer, TransferStats::default());
1420    }
1421
1422    /// An origin and a clone whose `main` each changed `a.txt` differently.
1423    fn conflicting_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1424        let (origin, clone) = origin_and_clone(tmp);
1425        let upstream = commit(&origin, "a.txt", "theirs");
1426        let local = commit(&clone, "a.txt", "ours");
1427        git(&clone, &["config", "user.name", "T"]);
1428        git(&clone, &["config", "user.email", "t@example.com"]);
1429        git(&clone, &["fetch", "-q"]);
1430        (clone, local, upstream)
1431    }
1432
1433    fn rebase_options(repo: &Repository, upstream: ObjectId, stop: bool) -> PullOptions {
1434        let mut options = pull_options(repo);
1435        options.mode = Some(PullMode::Rebase);
1436        options.stop_on_conflict = stop;
1437        options.upstream = Some(FetchedUpstream {
1438            label: "origin/main".to_owned(),
1439            oid: upstream,
1440            landed: None,
1441            copies: Vec::new(),
1442        });
1443        options
1444    }
1445
1446    fn abort(repo: &Repository) {
1447        let identity = "T <t@example.com> 0 +0000".parse().expect("signature");
1448        crate::history::abort_rebase(repo, &identity).expect("abort");
1449    }
1450
1451    #[cfg_attr(
1452        not(feature = "full-tests"),
1453        ignore = "starts system git or is heavy; run with --features full-tests"
1454    )]
1455    #[test]
1456    fn a_conflicting_rebase_pull_stops_in_a_resumable_rebase() {
1457        let tmp = TempDir::new().expect("tempdir");
1458        let (clone, local, upstream) = conflicting_clone(&tmp);
1459        let repo = open(&clone);
1460
1461        // Any rebase pull stops; `stop_on_conflict` only changes what it is called.
1462        let plain = pull(
1463            &repo,
1464            &rebase_options(&repo, upstream, false),
1465            &mut NoProgress,
1466            &NeverCancel,
1467            None,
1468        )
1469        .expect("pull");
1470        assert_eq!(plain.result, PullResult::RebaseConflicts);
1471        assert!(clone.join(".git/rebase-merge").exists());
1472        abort(&repo);
1473        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1474
1475        let stopped = pull(
1476            &repo,
1477            &rebase_options(&repo, upstream, true),
1478            &mut NoProgress,
1479            &NeverCancel,
1480            None,
1481        )
1482        .expect("pull");
1483        assert_eq!(stopped.result, PullResult::RebaseStopped);
1484        assert_eq!(stopped.oid, Some(local));
1485        let paths: Vec<&str> = stopped
1486            .conflicts
1487            .iter()
1488            .flatten()
1489            .map(|c| c.path.as_str())
1490            .collect();
1491        assert_eq!(paths, ["a.txt"]);
1492        // The rebase is left for `rebase --continue`; the branch keeps its tip until then.
1493        assert!(clone.join(".git/rebase-merge/grit-plan").is_file());
1494        assert_eq!(git(&clone, &["rev-parse", "main"]), local.to_hex());
1495        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1496        assert!(std::fs::read_to_string(clone.join("a.txt"))
1497            .expect("conflicted file")
1498            .contains("<<<<<<<"));
1499    }
1500
1501    #[cfg_attr(
1502        not(feature = "full-tests"),
1503        ignore = "starts system git or is heavy; run with --features full-tests"
1504    )]
1505    #[test]
1506    fn a_rebase_pull_with_local_changes_is_refused() {
1507        let tmp = TempDir::new().expect("tempdir");
1508        let (clone, _, upstream) = conflicting_clone(&tmp);
1509        let local = commit(&clone, "b.txt", "tracked");
1510        std::fs::write(clone.join("b.txt"), "edited, not committed").expect("write");
1511        let repo = open(&clone);
1512        let refused = pull(
1513            &repo,
1514            &rebase_options(&repo, upstream, true),
1515            &mut NoProgress,
1516            &NeverCancel,
1517            None,
1518        );
1519        assert!(
1520            matches!(
1521                refused,
1522                Err(PullError::Repository(Error::DirtyWorktree { .. }))
1523            ),
1524            "{refused:?}"
1525        );
1526        assert!(!clone.join(".git/rebase-merge").exists());
1527        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1528        assert_eq!(
1529            std::fs::read_to_string(clone.join("b.txt")).expect("read"),
1530            "edited, not committed"
1531        );
1532    }
1533
1534    #[cfg_attr(
1535        not(feature = "full-tests"),
1536        ignore = "starts system git or is heavy; run with --features full-tests"
1537    )]
1538    #[test]
1539    fn reflog_failure_after_ref_update_is_a_warning() {
1540        let tmp = TempDir::new().expect("tempdir");
1541        let (origin, clone) = origin_and_clone(&tmp);
1542        let tip = commit(&origin, "b.txt", "two");
1543        let branch_log = clone.join(".git/logs/refs/heads/main");
1544        std::fs::remove_file(&branch_log).expect("remove branch reflog");
1545        std::fs::create_dir(&branch_log).expect("replace reflog with directory");
1546
1547        let mut repo = open(&clone);
1548        let sink = std::sync::Arc::new(crate::diagnostics::CollectingDiagnostics::new());
1549        repo.set_diagnostics(sink.clone());
1550        let outcome = pull(
1551            &repo,
1552            &pull_options(&repo),
1553            &mut NoProgress,
1554            &NeverCancel,
1555            None,
1556        )
1557        .expect("pull completes");
1558        assert_eq!(outcome.result, PullResult::FastForward);
1559        assert_eq!(outcome.oid, Some(tip));
1560        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1561        assert!(sink.warnings().iter().any(|warning| matches!(
1562            warning,
1563            crate::diagnostics::Warning::ReflogNotWritten { refname, .. }
1564                if refname == "refs/heads/main"
1565        )));
1566    }
1567
1568    /// A clone whose `main` has three commits appending to `a.txt`, and an upstream commit
1569    /// (`up`, on the clone's starting commit) holding their combined result, as a server's
1570    /// snapshot of a merge of them would. Returns the clone, its tip and the upstream.
1571    fn landed_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1572        let (_origin, clone) = origin_and_clone(tmp);
1573        git(&clone, &["config", "user.name", "T"]);
1574        git(&clone, &["config", "user.email", "t@example.com"]);
1575        std::fs::write(clone.join("b.txt"), "b\n").expect("write");
1576        git(&clone, &["add", "b.txt"]);
1577        git(&clone, &["commit", "-qm", "b"]);
1578        let base = git(&clone, &["rev-parse", "HEAD"]);
1579        let mut text = "one".to_owned();
1580        for line in ["1", "2", "3"] {
1581            text = format!("{text}\n{line}");
1582            commit(&clone, "a.txt", &text);
1583        }
1584        let tip = ObjectId::from_hex(&git(&clone, &["rev-parse", "HEAD"])).expect("oid");
1585        git(&clone, &["switch", "-q", "-c", "up", &base]);
1586        let upstream = commit(&clone, "a.txt", &text);
1587        git(&clone, &["switch", "-q", "main"]);
1588        (clone, tip, upstream)
1589    }
1590
1591    fn landed_options(
1592        repo: &Repository,
1593        upstream: ObjectId,
1594        landed: ObjectId,
1595        stop: bool,
1596    ) -> PullOptions {
1597        let mut options = rebase_options(repo, upstream, stop);
1598        if let Some(fetched) = options.upstream.as_mut() {
1599            fetched.landed = Some(landed);
1600        }
1601        options
1602    }
1603
1604    #[cfg_attr(
1605        not(feature = "full-tests"),
1606        ignore = "starts system git or is heavy; run with --features full-tests"
1607    )]
1608    #[test]
1609    fn a_landed_branch_moves_to_the_upstream_and_keeps_local_changes() {
1610        let tmp = TempDir::new().expect("tempdir");
1611        let (clone, tip, upstream) = landed_clone(&tmp);
1612        let repo = open(&clone);
1613        // Replayed one by one, the first commit conflicts with the upstream that has all three.
1614        let replayed = pull(
1615            &repo,
1616            &rebase_options(&repo, upstream, false),
1617            &mut NoProgress,
1618            &NeverCancel,
1619            None,
1620        )
1621        .expect("pull");
1622        assert_eq!(replayed.result, PullResult::RebaseConflicts);
1623        abort(&repo);
1624        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1625
1626        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1627        std::fs::write(clone.join("draft.txt"), "not committed\n").expect("write");
1628        let mut check = landed_options(&repo, upstream, tip, false);
1629        check.check = true;
1630        let predicted = pull(&repo, &check, &mut NoProgress, &NeverCancel, None).expect("check");
1631        assert_eq!(predicted.check.map(|c| c.would), Some(PullResult::Landed));
1632
1633        git(&clone, &["update-ref", "-d", "ORIG_HEAD"]);
1634        let landed = pull(
1635            &repo,
1636            &landed_options(&repo, upstream, tip, true),
1637            &mut NoProgress,
1638            &NeverCancel,
1639            None,
1640        )
1641        .expect("pull");
1642        assert_eq!(landed.result, PullResult::Landed);
1643        assert_eq!(landed.oid, Some(upstream));
1644        assert_eq!(git(&clone, &["rev-parse", "main"]), upstream.to_hex());
1645        assert_eq!(git(&clone, &["rev-parse", "ORIG_HEAD"]), tip.to_hex());
1646        assert_eq!(
1647            git(&clone, &["status", "--porcelain"]),
1648            "M b.txt\n?? draft.txt"
1649        );
1650        assert_eq!(
1651            git(&clone, &["reflog", "-1", "--format=%gs", "main"]),
1652            "pull: move to origin/main, which has the branch's commits"
1653        );
1654    }
1655
1656    #[cfg_attr(
1657        not(feature = "full-tests"),
1658        ignore = "starts system git or is heavy; run with --features full-tests"
1659    )]
1660    #[test]
1661    fn a_landed_branch_does_not_move_over_local_changes_the_upstream_has_other_content_for() {
1662        let tmp = TempDir::new().expect("tempdir");
1663        let (clone, tip, _) = landed_clone(&tmp);
1664        // The upstream has the branch's commits and a change of its own to b.txt.
1665        git(&clone, &["switch", "-q", "up"]);
1666        let upstream = commit(&clone, "b.txt", "b, upstream");
1667        git(&clone, &["switch", "-q", "main"]);
1668        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1669        let repo = open(&clone);
1670        let err = pull(
1671            &repo,
1672            &landed_options(&repo, upstream, tip, true),
1673            &mut NoProgress,
1674            &NeverCancel,
1675            None,
1676        )
1677        .expect_err("blocked");
1678        assert!(
1679            matches!(
1680                &err,
1681                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["b.txt"]
1682            ),
1683            "{err:?}"
1684        );
1685        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1686        assert_eq!(git(&clone, &["status", "--porcelain"]), "M b.txt");
1687    }
1688
1689    #[cfg_attr(
1690        not(feature = "full-tests"),
1691        ignore = "starts system git or is heavy; run with --features full-tests"
1692    )]
1693    #[test]
1694    fn commits_above_a_landed_one_are_rebased_without_it() {
1695        let tmp = TempDir::new().expect("tempdir");
1696        let (clone, landed, upstream) = landed_clone(&tmp);
1697        let own = commit(&clone, "c.txt", "c");
1698        let repo = open(&clone);
1699        let outcome = pull(
1700            &repo,
1701            &landed_options(&repo, upstream, landed, true),
1702            &mut NoProgress,
1703            &NeverCancel,
1704            None,
1705        )
1706        .expect("pull");
1707        assert_eq!(outcome.result, PullResult::Rebased);
1708        assert_eq!(git(&clone, &["rev-parse", "main^"]), upstream.to_hex());
1709        assert_eq!(git(&clone, &["log", "-1", "--format=%s", "main"]), "c");
1710        assert_ne!(git(&clone, &["rev-parse", "main"]), own.to_hex());
1711
1712        // One that conflicts stops on its own commit, not on the landed ones.
1713        let tmp = TempDir::new().expect("tempdir");
1714        let (clone, landed, _) = landed_clone(&tmp);
1715        commit(&clone, "b.txt", "b, ours");
1716        git(&clone, &["switch", "-q", "up"]);
1717        let upstream = commit(&clone, "b.txt", "b, theirs");
1718        git(&clone, &["switch", "-q", "main"]);
1719        let repo = open(&clone);
1720        let stopped = pull(
1721            &repo,
1722            &landed_options(&repo, upstream, landed, true),
1723            &mut NoProgress,
1724            &NeverCancel,
1725            None,
1726        )
1727        .expect("pull");
1728        assert_eq!(stopped.result, PullResult::RebaseStopped);
1729        let paths: Vec<&str> = stopped
1730            .conflicts
1731            .iter()
1732            .flatten()
1733            .map(|c| c.path.as_str())
1734            .collect();
1735        assert_eq!(paths, ["b.txt"]);
1736        // Nothing was replayed before it: the session stopped right on the upstream.
1737        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1738    }
1739}