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}