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 fast_forward_pull_refuses_overlapping_local_changes() {
1263        let tmp = TempDir::new().expect("tempdir");
1264        let (origin, clone) = origin_and_clone(&tmp);
1265        let tip_before = git(&clone, &["rev-parse", "main"]);
1266        commit(&origin, "a.txt", "two");
1267        std::fs::write(clone.join("a.txt"), "local edit\n").expect("write");
1268        let repo = open(&clone);
1269        let err = pull(
1270            &repo,
1271            &pull_options(&repo),
1272            &mut NoProgress,
1273            &NeverCancel,
1274            None,
1275        )
1276        .expect_err("blocked");
1277        assert!(
1278            matches!(
1279                &err,
1280                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["a.txt"]
1281            ),
1282            "{err:?}"
1283        );
1284        assert_eq!(git(&clone, &["rev-parse", "main"]), tip_before);
1285        assert!(
1286            git(&clone, &["status", "--porcelain"]).contains("M a.txt"),
1287            "worktree still dirty"
1288        );
1289    }
1290
1291    #[cfg_attr(
1292        not(feature = "full-tests"),
1293        ignore = "starts system git or is heavy; run with --features full-tests"
1294    )]
1295    #[test]
1296    fn pull_maps_custom_fetch_refspec() {
1297        let tmp = TempDir::new().expect("tempdir");
1298        let (origin, clone) = origin_and_clone(&tmp);
1299        let tip = commit(&origin, "b.txt", "two");
1300        git(
1301            &clone,
1302            &[
1303                "config",
1304                "--replace-all",
1305                "remote.origin.fetch",
1306                "+refs/heads/*:refs/remotes/mirror/*",
1307            ],
1308        );
1309
1310        let repo = open(&clone);
1311        let outcome = pull(
1312            &repo,
1313            &pull_options(&repo),
1314            &mut NoProgress,
1315            &NeverCancel,
1316            None,
1317        )
1318        .expect("pull succeeds");
1319        assert_eq!(outcome.result, PullResult::FastForward);
1320        assert_eq!(outcome.oid, Some(tip));
1321        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1322    }
1323
1324    #[cfg_attr(
1325        not(feature = "full-tests"),
1326        ignore = "starts system git or is heavy; run with --features full-tests"
1327    )]
1328    #[test]
1329    fn pull_local_upstream_skips_fetch() {
1330        let tmp = TempDir::new().expect("tempdir");
1331        let dir = tmp.path();
1332        git(dir, &["init", "-q", "-b", "main"]);
1333        commit(dir, "a.txt", "one");
1334        git(dir, &["checkout", "-q", "-b", "dev"]);
1335        let tip = commit(dir, "b.txt", "two");
1336        git(dir, &["checkout", "-q", "main"]);
1337        git(dir, &["branch", "--set-upstream-to", "dev", "main"]);
1338
1339        let repo = open(dir);
1340        let outcome = pull(
1341            &repo,
1342            &pull_options(&repo),
1343            &mut NoProgress,
1344            &NeverCancel,
1345            None,
1346        )
1347        .expect("local pull succeeds");
1348        assert_eq!(outcome.result, PullResult::FastForward);
1349        assert_eq!(outcome.oid, Some(tip));
1350        assert_eq!(git(dir, &["rev-parse", "main"]), tip.to_hex());
1351        assert_eq!(outcome.transfer, TransferStats::default());
1352    }
1353
1354    #[cfg_attr(
1355        not(feature = "full-tests"),
1356        ignore = "starts system git or is heavy; run with --features full-tests"
1357    )]
1358    #[test]
1359    fn pull_reports_objects_received_by_its_fetch() {
1360        let tmp = TempDir::new().expect("tempdir");
1361        let (origin, clone) = origin_and_clone(&tmp);
1362        commit(&origin, "b.txt", "two");
1363
1364        let repo = open(&clone);
1365        let outcome = pull(
1366            &repo,
1367            &pull_options(&repo),
1368            &mut NoProgress,
1369            &NeverCancel,
1370            None,
1371        )
1372        .expect("pull succeeds");
1373        assert_eq!(outcome.result, PullResult::FastForward);
1374        // One new commit, its root tree and the added blob.
1375        assert_eq!(outcome.transfer.objects, 3, "{:?}", outcome.transfer);
1376        assert!(outcome.transfer.bytes > 0, "{:?}", outcome.transfer);
1377
1378        let again = pull(
1379            &repo,
1380            &pull_options(&repo),
1381            &mut NoProgress,
1382            &NeverCancel,
1383            None,
1384        )
1385        .expect("second pull");
1386        assert_eq!(again.result, PullResult::UpToDate);
1387        assert_eq!(again.transfer.objects, 0, "{:?}", again.transfer);
1388    }
1389
1390    #[cfg_attr(
1391        not(feature = "full-tests"),
1392        ignore = "starts system git or is heavy; run with --features full-tests"
1393    )]
1394    #[test]
1395    fn pull_adopts_upstream_on_unborn_branch_with_reflog() {
1396        let tmp = TempDir::new().expect("tempdir");
1397        let origin = tmp.path().join("origin");
1398        std::fs::create_dir_all(&origin).expect("mkdir origin");
1399        git(&origin, &["init", "-q", "-b", "main"]);
1400        let tip = commit(&origin, "a.txt", "one");
1401        let local = tmp.path().join("local");
1402        std::fs::create_dir_all(&local).expect("mkdir local");
1403        git(&local, &["init", "-q", "-b", "main"]);
1404        git(&local, &["remote", "add", "origin", "../origin"]);
1405
1406        let repo = open(&local);
1407        let outcome = pull(
1408            &repo,
1409            &pull_options(&repo),
1410            &mut NoProgress,
1411            &NeverCancel,
1412            None,
1413        )
1414        .expect("pull succeeds");
1415        assert_eq!(outcome.result, PullResult::SetUpstream);
1416        assert_eq!(outcome.oid, Some(tip));
1417        assert_eq!(
1418            git(&local, &["reflog", "show", "--format=%H %gs", "-1", "main"]),
1419            format!("{} {INITIAL_PULL_REFLOG}", tip.to_hex())
1420        );
1421    }
1422
1423    #[cfg_attr(
1424        not(feature = "full-tests"),
1425        ignore = "starts system git or is heavy; run with --features full-tests"
1426    )]
1427    #[test]
1428    fn pull_integrates_a_fetched_upstream_without_fetching() {
1429        let tmp = TempDir::new().expect("tempdir");
1430        let (_origin, clone) = origin_and_clone(&tmp);
1431        git(&clone, &["checkout", "-q", "-b", "up"]);
1432        let tip = commit(&clone, "b.txt", "two");
1433        git(&clone, &["checkout", "-q", "main"]);
1434        // A fetch from this remote would fail.
1435        git(
1436            &clone,
1437            &["remote", "set-url", "origin", "/nonexistent/remote"],
1438        );
1439
1440        let repo = open(&clone);
1441        let mut options = pull_options(&repo);
1442        options.upstream = Some(FetchedUpstream {
1443            label: "origin/up".to_owned(),
1444            oid: tip,
1445            landed: None,
1446            copies: Vec::new(),
1447        });
1448        let outcome = pull(&repo, &options, &mut NoProgress, &NeverCancel, None)
1449            .expect("pull without a fetch");
1450        assert_eq!(outcome.result, PullResult::FastForward);
1451        assert_eq!(outcome.label, "origin/up");
1452        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1453        assert_eq!(outcome.transfer, TransferStats::default());
1454    }
1455
1456    /// An origin and a clone whose `main` each changed `a.txt` differently.
1457    fn conflicting_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1458        let (origin, clone) = origin_and_clone(tmp);
1459        let upstream = commit(&origin, "a.txt", "theirs");
1460        let local = commit(&clone, "a.txt", "ours");
1461        git(&clone, &["config", "user.name", "T"]);
1462        git(&clone, &["config", "user.email", "t@example.com"]);
1463        git(&clone, &["fetch", "-q"]);
1464        (clone, local, upstream)
1465    }
1466
1467    fn rebase_options(repo: &Repository, upstream: ObjectId, stop: bool) -> PullOptions {
1468        let mut options = pull_options(repo);
1469        options.mode = Some(PullMode::Rebase);
1470        options.stop_on_conflict = stop;
1471        options.upstream = Some(FetchedUpstream {
1472            label: "origin/main".to_owned(),
1473            oid: upstream,
1474            landed: None,
1475            copies: Vec::new(),
1476        });
1477        options
1478    }
1479
1480    fn abort(repo: &Repository) {
1481        let identity = "T <t@example.com> 0 +0000".parse().expect("signature");
1482        crate::history::abort_rebase(repo, &identity).expect("abort");
1483    }
1484
1485    #[cfg_attr(
1486        not(feature = "full-tests"),
1487        ignore = "starts system git or is heavy; run with --features full-tests"
1488    )]
1489    #[test]
1490    fn a_conflicting_rebase_pull_stops_in_a_resumable_rebase() {
1491        let tmp = TempDir::new().expect("tempdir");
1492        let (clone, local, upstream) = conflicting_clone(&tmp);
1493        let repo = open(&clone);
1494
1495        // Any rebase pull stops; `stop_on_conflict` only changes what it is called.
1496        let plain = pull(
1497            &repo,
1498            &rebase_options(&repo, upstream, false),
1499            &mut NoProgress,
1500            &NeverCancel,
1501            None,
1502        )
1503        .expect("pull");
1504        assert_eq!(plain.result, PullResult::RebaseConflicts);
1505        assert!(clone.join(".git/rebase-merge").exists());
1506        abort(&repo);
1507        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1508
1509        let stopped = pull(
1510            &repo,
1511            &rebase_options(&repo, upstream, true),
1512            &mut NoProgress,
1513            &NeverCancel,
1514            None,
1515        )
1516        .expect("pull");
1517        assert_eq!(stopped.result, PullResult::RebaseStopped);
1518        assert_eq!(stopped.oid, Some(local));
1519        let paths: Vec<&str> = stopped
1520            .conflicts
1521            .iter()
1522            .flatten()
1523            .map(|c| c.path.as_str())
1524            .collect();
1525        assert_eq!(paths, ["a.txt"]);
1526        // The rebase is left for `rebase --continue`; the branch keeps its tip until then.
1527        assert!(clone.join(".git/rebase-merge/grit-plan").is_file());
1528        assert_eq!(git(&clone, &["rev-parse", "main"]), local.to_hex());
1529        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1530        assert!(std::fs::read_to_string(clone.join("a.txt"))
1531            .expect("conflicted file")
1532            .contains("<<<<<<<"));
1533    }
1534
1535    #[cfg_attr(
1536        not(feature = "full-tests"),
1537        ignore = "starts system git or is heavy; run with --features full-tests"
1538    )]
1539    #[test]
1540    fn a_rebase_pull_with_local_changes_is_refused() {
1541        let tmp = TempDir::new().expect("tempdir");
1542        let (clone, _, upstream) = conflicting_clone(&tmp);
1543        let local = commit(&clone, "b.txt", "tracked");
1544        std::fs::write(clone.join("b.txt"), "edited, not committed").expect("write");
1545        let repo = open(&clone);
1546        let refused = pull(
1547            &repo,
1548            &rebase_options(&repo, upstream, true),
1549            &mut NoProgress,
1550            &NeverCancel,
1551            None,
1552        );
1553        assert!(
1554            matches!(
1555                refused,
1556                Err(PullError::Repository(Error::DirtyWorktree { .. }))
1557            ),
1558            "{refused:?}"
1559        );
1560        assert!(!clone.join(".git/rebase-merge").exists());
1561        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), local.to_hex());
1562        assert_eq!(
1563            std::fs::read_to_string(clone.join("b.txt")).expect("read"),
1564            "edited, not committed"
1565        );
1566    }
1567
1568    #[cfg_attr(
1569        not(feature = "full-tests"),
1570        ignore = "starts system git or is heavy; run with --features full-tests"
1571    )]
1572    #[test]
1573    fn reflog_failure_after_ref_update_is_a_warning() {
1574        let tmp = TempDir::new().expect("tempdir");
1575        let (origin, clone) = origin_and_clone(&tmp);
1576        let tip = commit(&origin, "b.txt", "two");
1577        let branch_log = clone.join(".git/logs/refs/heads/main");
1578        std::fs::remove_file(&branch_log).expect("remove branch reflog");
1579        std::fs::create_dir(&branch_log).expect("replace reflog with directory");
1580
1581        let mut repo = open(&clone);
1582        let sink = std::sync::Arc::new(crate::diagnostics::CollectingDiagnostics::new());
1583        repo.set_diagnostics(sink.clone());
1584        let outcome = pull(
1585            &repo,
1586            &pull_options(&repo),
1587            &mut NoProgress,
1588            &NeverCancel,
1589            None,
1590        )
1591        .expect("pull completes");
1592        assert_eq!(outcome.result, PullResult::FastForward);
1593        assert_eq!(outcome.oid, Some(tip));
1594        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1595        assert!(sink.warnings().iter().any(|warning| matches!(
1596            warning,
1597            crate::diagnostics::Warning::ReflogNotWritten { refname, .. }
1598                if refname == "refs/heads/main"
1599        )));
1600    }
1601
1602    /// A clone whose `main` has three commits appending to `a.txt`, and an upstream commit
1603    /// (`up`, on the clone's starting commit) holding their combined result, as a server's
1604    /// snapshot of a merge of them would. Returns the clone, its tip and the upstream.
1605    fn landed_clone(tmp: &TempDir) -> (PathBuf, ObjectId, ObjectId) {
1606        let (_origin, clone) = origin_and_clone(tmp);
1607        git(&clone, &["config", "user.name", "T"]);
1608        git(&clone, &["config", "user.email", "t@example.com"]);
1609        std::fs::write(clone.join("b.txt"), "b\n").expect("write");
1610        git(&clone, &["add", "b.txt"]);
1611        git(&clone, &["commit", "-qm", "b"]);
1612        let base = git(&clone, &["rev-parse", "HEAD"]);
1613        let mut text = "one".to_owned();
1614        for line in ["1", "2", "3"] {
1615            text = format!("{text}\n{line}");
1616            commit(&clone, "a.txt", &text);
1617        }
1618        let tip = ObjectId::from_hex(&git(&clone, &["rev-parse", "HEAD"])).expect("oid");
1619        git(&clone, &["switch", "-q", "-c", "up", &base]);
1620        let upstream = commit(&clone, "a.txt", &text);
1621        git(&clone, &["switch", "-q", "main"]);
1622        (clone, tip, upstream)
1623    }
1624
1625    fn landed_options(
1626        repo: &Repository,
1627        upstream: ObjectId,
1628        landed: ObjectId,
1629        stop: bool,
1630    ) -> PullOptions {
1631        let mut options = rebase_options(repo, upstream, stop);
1632        if let Some(fetched) = options.upstream.as_mut() {
1633            fetched.landed = Some(landed);
1634        }
1635        options
1636    }
1637
1638    #[cfg_attr(
1639        not(feature = "full-tests"),
1640        ignore = "starts system git or is heavy; run with --features full-tests"
1641    )]
1642    #[test]
1643    fn a_landed_branch_moves_to_the_upstream_and_keeps_local_changes() {
1644        let tmp = TempDir::new().expect("tempdir");
1645        let (clone, tip, upstream) = landed_clone(&tmp);
1646        let repo = open(&clone);
1647        // Replayed one by one, the first commit conflicts with the upstream that has all three.
1648        let replayed = pull(
1649            &repo,
1650            &rebase_options(&repo, upstream, false),
1651            &mut NoProgress,
1652            &NeverCancel,
1653            None,
1654        )
1655        .expect("pull");
1656        assert_eq!(replayed.result, PullResult::RebaseConflicts);
1657        abort(&repo);
1658        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1659
1660        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1661        std::fs::write(clone.join("draft.txt"), "not committed\n").expect("write");
1662        let mut check = landed_options(&repo, upstream, tip, false);
1663        check.check = true;
1664        let predicted = pull(&repo, &check, &mut NoProgress, &NeverCancel, None).expect("check");
1665        assert_eq!(predicted.check.map(|c| c.would), Some(PullResult::Landed));
1666
1667        git(&clone, &["update-ref", "-d", "ORIG_HEAD"]);
1668        let landed = pull(
1669            &repo,
1670            &landed_options(&repo, upstream, tip, true),
1671            &mut NoProgress,
1672            &NeverCancel,
1673            None,
1674        )
1675        .expect("pull");
1676        assert_eq!(landed.result, PullResult::Landed);
1677        assert_eq!(landed.oid, Some(upstream));
1678        assert_eq!(git(&clone, &["rev-parse", "main"]), upstream.to_hex());
1679        assert_eq!(git(&clone, &["rev-parse", "ORIG_HEAD"]), tip.to_hex());
1680        assert_eq!(
1681            git(&clone, &["status", "--porcelain"]),
1682            "M b.txt\n?? draft.txt"
1683        );
1684        assert_eq!(
1685            git(&clone, &["reflog", "-1", "--format=%gs", "main"]),
1686            "pull: move to origin/main, which has the branch's commits"
1687        );
1688    }
1689
1690    #[cfg_attr(
1691        not(feature = "full-tests"),
1692        ignore = "starts system git or is heavy; run with --features full-tests"
1693    )]
1694    #[test]
1695    fn a_landed_branch_does_not_move_over_local_changes_the_upstream_has_other_content_for() {
1696        let tmp = TempDir::new().expect("tempdir");
1697        let (clone, tip, _) = landed_clone(&tmp);
1698        // The upstream has the branch's commits and a change of its own to b.txt.
1699        git(&clone, &["switch", "-q", "up"]);
1700        let upstream = commit(&clone, "b.txt", "b, upstream");
1701        git(&clone, &["switch", "-q", "main"]);
1702        std::fs::write(clone.join("b.txt"), "b, edited\n").expect("write");
1703        let repo = open(&clone);
1704        let err = pull(
1705            &repo,
1706            &landed_options(&repo, upstream, tip, true),
1707            &mut NoProgress,
1708            &NeverCancel,
1709            None,
1710        )
1711        .expect_err("blocked");
1712        assert!(
1713            matches!(
1714                &err,
1715                PullError::Repository(Error::WouldOverwrite { paths, .. }) if paths == &["b.txt"]
1716            ),
1717            "{err:?}"
1718        );
1719        assert_eq!(git(&clone, &["rev-parse", "main"]), tip.to_hex());
1720        assert_eq!(git(&clone, &["status", "--porcelain"]), "M b.txt");
1721    }
1722
1723    #[cfg_attr(
1724        not(feature = "full-tests"),
1725        ignore = "starts system git or is heavy; run with --features full-tests"
1726    )]
1727    #[test]
1728    fn commits_above_a_landed_one_are_rebased_without_it() {
1729        let tmp = TempDir::new().expect("tempdir");
1730        let (clone, landed, upstream) = landed_clone(&tmp);
1731        let own = commit(&clone, "c.txt", "c");
1732        let repo = open(&clone);
1733        let outcome = pull(
1734            &repo,
1735            &landed_options(&repo, upstream, landed, true),
1736            &mut NoProgress,
1737            &NeverCancel,
1738            None,
1739        )
1740        .expect("pull");
1741        assert_eq!(outcome.result, PullResult::Rebased);
1742        assert_eq!(git(&clone, &["rev-parse", "main^"]), upstream.to_hex());
1743        assert_eq!(git(&clone, &["log", "-1", "--format=%s", "main"]), "c");
1744        assert_ne!(git(&clone, &["rev-parse", "main"]), own.to_hex());
1745
1746        // One that conflicts stops on its own commit, not on the landed ones.
1747        let tmp = TempDir::new().expect("tempdir");
1748        let (clone, landed, _) = landed_clone(&tmp);
1749        commit(&clone, "b.txt", "b, ours");
1750        git(&clone, &["switch", "-q", "up"]);
1751        let upstream = commit(&clone, "b.txt", "b, theirs");
1752        git(&clone, &["switch", "-q", "main"]);
1753        let repo = open(&clone);
1754        let stopped = pull(
1755            &repo,
1756            &landed_options(&repo, upstream, landed, true),
1757            &mut NoProgress,
1758            &NeverCancel,
1759            None,
1760        )
1761        .expect("pull");
1762        assert_eq!(stopped.result, PullResult::RebaseStopped);
1763        let paths: Vec<&str> = stopped
1764            .conflicts
1765            .iter()
1766            .flatten()
1767            .map(|c| c.path.as_str())
1768            .collect();
1769        assert_eq!(paths, ["b.txt"]);
1770        // Nothing was replayed before it: the session stopped right on the upstream.
1771        assert_eq!(git(&clone, &["rev-parse", "HEAD"]), upstream.to_hex());
1772    }
1773}