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