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 git-meta 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 config = repo.config_snapshot();
62    let tracking_ref = if config.boolean(&format!("remote.{remote_name}.metaside")) == Some(true) {
63        format!("refs/{ns}/remotes/{remote_name}/main")
64    } else {
65        format!("refs/{ns}/remotes/main")
66    };
67    let fetch_refspec = format!("{remote_refspec}:{tracking_ref}");
68
69    // Record the old tip so we can count new commits
70    let old_tip = repo
71        .find_reference(&tracking_ref)
72        .ok()
73        .and_then(|r| r.into_fully_peeled_id().ok());
74
75    // Fetch latest remote metadata
76    git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
77
78    // Get the new tip
79    let new_tip = repo
80        .find_reference(&tracking_ref)
81        .ok()
82        .and_then(|r| r.into_fully_peeled_id().ok());
83
84    // Check if we need to materialize even if no new commits were fetched.
85    // A previous pull may have advanced the tracking ref before failing during
86    // hydration or materialization, so also verify the local ref contains the
87    // fetched remote tip before treating this pull as a no-op.
88    let local_ref = session.local_ref();
89    let last_materialized_missing = session.store.get_last_materialized()?.is_none();
90    let local_ref_missing = repo.find_reference(&local_ref).is_err();
91    let needs_materialize = last_materialized_missing
92        || local_ref_missing
93        || !local_ref_contains_tip(
94            repo,
95            &local_ref,
96            new_tip.as_ref().map(|tip| (*tip).detach()),
97        );
98
99    // Count new commits
100    let new_commits = match (old_tip.as_ref(), new_tip.as_ref()) {
101        (Some(old), Some(new)) if old == new => {
102            if !needs_materialize {
103                return Ok(PullOutput {
104                    remote_name,
105                    new_commits: 0,
106                    indexed_keys: 0,
107                    materialized: false,
108                });
109            }
110            0
111        }
112        (Some(old), Some(new)) => count_commits_between(repo, old.detach(), new.detach()),
113        (None, Some(_)) => 1, // initial fetch, at least one commit
114        _ => 0,
115    };
116
117    // Hydrate tip tree blobs so gix can read them
118    let short_ref = tracking_ref
119        .strip_prefix("refs/")
120        .unwrap_or(&tracking_ref)
121        .to_string();
122    git_utils::hydrate_tip_blobs(repo, &remote_name, &short_ref)?;
123
124    // Serialize local state so materialize can do a proper 3-way merge
125    let _ = crate::serialize::run(session, now, false)?;
126
127    // Materialize: merge remote tree into local DB
128    let _ = crate::materialize::run(session, None, now)?;
129
130    // Insert promisor entries from non-tip commits so we know what keys exist
131    // in the history even though we haven't fetched their blob data yet.
132    // On first materialize, walk the entire history (pass None as old_tip).
133    let indexed_keys = if let Some(new) = new_tip {
134        let tracking_ref_unchanged = old_tip.as_ref() == Some(&new);
135        let ref_state_needs_repair = tracking_ref_unchanged && needs_materialize;
136        let full_history_index =
137            last_materialized_missing || local_ref_missing || ref_state_needs_repair;
138        let walk_from = if full_history_index {
139            None
140        } else {
141            old_tip.map(gix::Id::detach)
142        };
143        session.index_history(new.detach(), walk_from)?
144    } else {
145        0
146    };
147
148    Ok(PullOutput {
149        remote_name,
150        new_commits,
151        indexed_keys,
152        materialized: true,
153    })
154}
155
156fn local_ref_contains_tip(
157    repo: &gix::Repository,
158    local_ref: &str,
159    remote_tip: Option<gix::ObjectId>,
160) -> bool {
161    let Some(remote_tip) = remote_tip else {
162        return true;
163    };
164    let Some(local_tip) = repo
165        .find_reference(local_ref)
166        .ok()
167        .and_then(|r| r.into_fully_peeled_id().ok())
168        .map(gix::Id::detach)
169    else {
170        return false;
171    };
172
173    local_tip == remote_tip
174        || repo
175            .merge_base(local_tip, remote_tip)
176            .is_ok_and(|base| base == remote_tip)
177}
178
179/// Count commits reachable from `new` but not from `old`.
180fn count_commits_between(repo: &gix::Repository, old: gix::ObjectId, new: gix::ObjectId) -> usize {
181    let walk = repo.rev_walk(Some(new)).with_boundary(Some(old));
182    match walk.all() {
183        // Subtract the boundary commit itself
184        Ok(iter) => iter
185            .filter(std::result::Result::is_ok)
186            .count()
187            .saturating_sub(1),
188        Err(_) => 0,
189    }
190}
191
192#[cfg(test)]
193#[allow(clippy::expect_used, clippy::unwrap_used)]
194mod tests {
195    use gix::refs::transaction::PreviousValue;
196
197    use super::*;
198
199    #[test]
200    fn local_ref_contains_tip_when_local_descends_from_remote() {
201        let (_dir, repo) = setup_repo();
202        let remote_tip = write_commit(&repo, Vec::new());
203        let local_tip = write_commit(&repo, vec![remote_tip]);
204        repo.reference(
205            "refs/meta/local/main",
206            local_tip,
207            PreviousValue::Any,
208            "local metadata",
209        )
210        .unwrap();
211
212        assert!(local_ref_contains_tip(
213            &repo,
214            "refs/meta/local/main",
215            Some(remote_tip)
216        ));
217    }
218
219    #[test]
220    fn local_ref_does_not_contain_newer_remote_tip() {
221        let (_dir, repo) = setup_repo();
222        let local_tip = write_commit(&repo, Vec::new());
223        let remote_tip = write_commit(&repo, vec![local_tip]);
224        repo.reference(
225            "refs/meta/local/main",
226            local_tip,
227            PreviousValue::Any,
228            "local metadata",
229        )
230        .unwrap();
231
232        assert!(!local_ref_contains_tip(
233            &repo,
234            "refs/meta/local/main",
235            Some(remote_tip)
236        ));
237    }
238
239    fn setup_repo() -> (tempfile::TempDir, gix::Repository) {
240        let dir = tempfile::TempDir::new().unwrap();
241        let _repo = gix::init(dir.path()).unwrap();
242        let repo = gix::open_opts(
243            dir.path(),
244            gix::open::Options::isolated()
245                .config_overrides(["user.name=Test User", "user.email=test@example.com"]),
246        )
247        .unwrap();
248
249        (dir, repo)
250    }
251
252    fn write_commit(repo: &gix::Repository, parents: Vec<gix::ObjectId>) -> gix::ObjectId {
253        let tree_oid = repo.empty_tree().edit().unwrap().write().unwrap().detach();
254        let sig = gix::actor::Signature {
255            name: "Test User".into(),
256            email: "test@example.com".into(),
257            time: gix::date::Time::new(946684800, 0),
258        };
259        let commit = gix::objs::Commit {
260            message: "metadata".into(),
261            tree: tree_oid,
262            author: sig.clone(),
263            committer: sig,
264            encoding: None,
265            parents: parents.into(),
266            extra_headers: Default::default(),
267        };
268        repo.write_object(&commit).unwrap().detach()
269    }
270}