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