1use crate::error::Result;
12use crate::git_utils;
13use crate::session::Session;
14
15#[must_use]
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct PullOutput {
22 pub remote_name: String,
24 pub new_commits: usize,
26 pub indexed_keys: usize,
28 pub materialized: bool,
30}
31
32pub 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 let old_tip = repo
71 .find_reference(&tracking_ref)
72 .ok()
73 .and_then(|r| r.into_fully_peeled_id().ok());
74
75 git_utils::run_git(repo, &["fetch", &remote_name, &fetch_refspec])?;
77
78 let new_tip = repo
80 .find_reference(&tracking_ref)
81 .ok()
82 .and_then(|r| r.into_fully_peeled_id().ok());
83
84 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 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, _ => 0,
115 };
116
117 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 let _ = crate::serialize::run(session, now, false)?;
126
127 let _ = crate::materialize::run(session, None, now)?;
129
130 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
179fn 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 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}