Skip to main content

git_meta_lib/
pull.rs

1//! Pull remote metadata: fetch, materialize, and index history.
2//!
3//! This module implements the full pull workflow: resolving the remote,
4//! fetching the metadata ref, counting new commits, hydrating tip blobs,
5//! serializing local state for merge, materializing remote changes, and
6//! indexing historical keys for lazy loading.
7//!
8//! The public entry point is [`run()`], which takes a [`Session`](crate::Session)
9//! and returns a [`PullOutput`] describing what happened.
10
11use crate::error::Result;
12use crate::git_utils;
13use crate::session::Session;
14
15/// Result of a pull operation.
16///
17/// Contains all the information needed by a CLI or other consumer
18/// to report what happened, without performing any I/O itself.
19#[must_use]
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct PullOutput {
22    /// The remote that was pulled from.
23    pub remote_name: String,
24    /// Number of new commits fetched.
25    pub new_commits: usize,
26    /// Number of historical keys indexed for lazy loading.
27    pub indexed_keys: usize,
28    /// Whether materialization was performed.
29    pub materialized: bool,
30}
31
32/// Pull remote metadata: fetch, materialize, and index history.
33///
34/// Resolves the remote, fetches the metadata ref, hydrates tip blobs,
35/// serializes local state for merge, materializes remote changes, and
36/// indexes historical keys for lazy loading.
37///
38/// # Parameters
39///
40/// - `session`: the gmeta session providing the repository, store, and config.
41/// - `remote`: optional remote name to pull from. If `None`, the first
42///   configured metadata remote is used.
43/// - `now`: the current timestamp in milliseconds since the Unix epoch,
44///   used for database writes during materialization.
45///
46/// # Returns
47///
48/// A [`PullOutput`] describing the remote pulled from, new commits fetched,
49/// whether materialization occurred, and how many history keys were indexed.
50///
51/// # Errors
52///
53/// Returns an error if the remote cannot be resolved, fetch fails,
54/// materialization fails, or history indexing fails.
55pub fn run(session: &Session, remote: Option<&str>, now: i64) -> Result<PullOutput> {
56    let repo = &session.repo;
57    let ns = session.namespace();
58
59    let remote_name = git_utils::resolve_meta_remote(repo, remote)?;
60    let remote_refspec = format!("refs/{ns}/main");
61    let tracking_ref = format!("refs/{ns}/remotes/main");
62    let fetch_refspec = format!("{remote_refspec}:{tracking_ref}");
63
64    // Record the old tip so we can count new commits
65    let old_tip = repo
66        .find_reference(&tracking_ref)
67        .ok()
68        .and_then(|r| r.into_fully_peeled_id().ok());
69
70    // Fetch latest remote metadata
71    git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
72
73    // Get the new tip
74    let new_tip = repo
75        .find_reference(&tracking_ref)
76        .ok()
77        .and_then(|r| r.into_fully_peeled_id().ok());
78
79    // Check if we need to materialize even if no new commits were fetched
80    // (e.g. remote add fetched but never materialized)
81    let needs_materialize = session.store.get_last_materialized()?.is_none()
82        || repo.find_reference(&session.local_ref()).is_err();
83
84    // Count new commits
85    let new_commits = match (old_tip.as_ref(), new_tip.as_ref()) {
86        (Some(old), Some(new)) if old == new => {
87            if !needs_materialize {
88                return Ok(PullOutput {
89                    remote_name,
90                    new_commits: 0,
91                    indexed_keys: 0,
92                    materialized: false,
93                });
94            }
95            0
96        }
97        (Some(old), Some(new)) => count_commits_between(repo, old.detach(), new.detach()),
98        (None, Some(_)) => 1, // initial fetch, at least one commit
99        _ => 0,
100    };
101
102    // Hydrate tip tree blobs so gix can read them
103    let short_ref = format!("{ns}/remotes/main");
104    git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
105
106    // Serialize local state so materialize can do a proper 3-way merge
107    let _ = crate::serialize::run(session, now, false)?;
108
109    // Materialize: merge remote tree into local DB
110    let _ = crate::materialize::run(session, None, now)?;
111
112    // Insert promisor entries from non-tip commits so we know what keys exist
113    // in the history even though we haven't fetched their blob data yet.
114    // On first materialize, walk the entire history (pass None as old_tip).
115    let indexed_keys = if let Some(new) = new_tip {
116        let walk_from = if needs_materialize {
117            None
118        } else {
119            old_tip.map(gix::Id::detach)
120        };
121        session.index_history(new.detach(), walk_from)?
122    } else {
123        0
124    };
125
126    Ok(PullOutput {
127        remote_name,
128        new_commits,
129        indexed_keys,
130        materialized: true,
131    })
132}
133
134/// Count commits reachable from `new` but not from `old`.
135fn count_commits_between(repo: &gix::Repository, old: gix::ObjectId, new: gix::ObjectId) -> usize {
136    let walk = repo.rev_walk(Some(new)).with_boundary(Some(old));
137    match walk.all() {
138        // Subtract the boundary commit itself
139        Ok(iter) => iter
140            .filter(std::result::Result::is_ok)
141            .count()
142            .saturating_sub(1),
143        Err(_) => 0,
144    }
145}