1use std::collections::BTreeMap;
12
13use gix::prelude::ObjectIdExt;
14use gix::refs::transaction::PreviousValue;
15
16use crate::error::{Error, Result};
17use crate::session::Session;
18use crate::tree::format::{build_merged_tree, parse_tree};
19use crate::tree::merge::{
20 merge_list_tombstones, merge_set_member_tombstones, merge_tombstones, three_way_merge,
21 two_way_merge_no_common_ancestor, ConflictDecision,
22};
23use crate::tree::model::{Key, ParsedTree, Tombstone, TreeValue};
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum MaterializeStrategy {
29 FastForward,
31 ThreeWayMerge,
33 TwoWayMerge,
35 UpToDate,
37}
38
39#[must_use]
41#[derive(Debug, Clone)]
42pub struct MaterializeRefResult {
43 pub ref_name: String,
45 pub strategy: MaterializeStrategy,
47 pub changes: usize,
49 pub conflicts: Vec<ConflictDecision>,
51}
52
53#[must_use]
55#[derive(Debug, Clone)]
56pub struct MaterializeOutput {
57 pub results: Vec<MaterializeRefResult>,
59}
60
61pub fn run(session: &Session, remote: Option<&str>, now: i64) -> Result<MaterializeOutput> {
83 let repo = &session.repo;
84 let ns = session.namespace();
85 let local_ref_name = session.local_ref();
86 let email = session.email();
87
88 let remote_refs = find_remote_refs(repo, ns, remote)?;
89
90 if remote_refs.is_empty() {
91 return Ok(MaterializeOutput {
92 results: Vec::new(),
93 });
94 }
95
96 let mut results = Vec::new();
97
98 for (ref_name, remote_oid) in &remote_refs {
99 let remote_commit_obj = remote_oid
100 .attach(repo)
101 .object()
102 .map_err(|e| Error::Other(format!("{e}")))?
103 .into_commit();
104 let remote_tree_id = remote_commit_obj
105 .tree_id()
106 .map_err(|e| Error::Other(format!("{e}")))?
107 .detach();
108 let remote_entries = parse_tree(repo, remote_tree_id, "")?;
109
110 if is_side_remote_ref(repo, ns, ref_name) {
111 let remote_timestamp = extract_author_timestamp(&remote_commit_obj)? * 1000;
112 let changes = session.store.apply_tree_from_source_ref(
113 &remote_entries.values,
114 ref_name,
115 remote_timestamp,
116 )?;
117 results.push(MaterializeRefResult {
118 ref_name: ref_name.clone(),
119 strategy: MaterializeStrategy::FastForward,
120 changes,
121 conflicts: Vec::new(),
122 });
123 continue;
124 }
125
126 let local_commit_oid = repo
128 .find_reference(&local_ref_name)
129 .ok()
130 .and_then(|r| r.into_fully_peeled_id().ok())
131 .map(gix::Id::detach);
132
133 let can_fast_forward = match &local_commit_oid {
136 None => true,
137 Some(local_oid) => {
138 if *local_oid == *remote_oid {
139 results.push(MaterializeRefResult {
140 ref_name: ref_name.clone(),
141 strategy: MaterializeStrategy::UpToDate,
142 changes: 0,
143 conflicts: Vec::new(),
144 });
145 continue;
146 }
147 match repo.merge_base(*local_oid, *remote_oid) {
148 Ok(base_oid) => base_oid == *local_oid,
149 Err(_) => false,
150 }
151 }
152 };
153
154 if can_fast_forward {
155 let changes =
156 materialize_fast_forward(session, local_commit_oid, &remote_entries, email, now)?;
157
158 repo.reference(
160 local_ref_name.as_str(),
161 *remote_oid,
162 PreviousValue::Any,
163 "fast-forward materialize",
164 )
165 .map_err(|e| Error::Other(format!("{e}")))?;
166
167 results.push(MaterializeRefResult {
168 ref_name: ref_name.clone(),
169 strategy: MaterializeStrategy::FastForward,
170 changes,
171 conflicts: Vec::new(),
172 });
173 } else {
174 let local_oid = local_commit_oid.as_ref().ok_or_else(|| {
176 Error::Other("expected local commit for merge but found None".into())
177 })?;
178
179 let (changes, conflict_decisions, strategy) = materialize_merge(
180 session,
181 local_oid,
182 remote_oid,
183 &remote_entries,
184 &remote_commit_obj,
185 email,
186 now,
187 &local_ref_name,
188 )?;
189
190 results.push(MaterializeRefResult {
191 ref_name: ref_name.clone(),
192 strategy,
193 changes,
194 conflicts: conflict_decisions,
195 });
196 }
197 }
198
199 session.store.set_last_materialized(now)?;
200
201 Ok(MaterializeOutput { results })
202}
203
204fn is_side_remote_ref(repo: &gix::Repository, ns: &str, ref_name: &str) -> bool {
205 let config = repo.config_snapshot();
206 repo.remote_names().iter().any(|name| {
207 config.boolean(&format!("remote.{name}.metaside")) == Some(true)
208 && ref_name == format!("refs/{ns}/remotes/{name}/main")
209 })
210}
211
212fn materialize_fast_forward(
217 session: &Session,
218 local_commit_oid: Option<gix::ObjectId>,
219 remote_entries: &ParsedTree,
220 email: &str,
221 now: i64,
222) -> Result<usize> {
223 let repo = &session.repo;
224
225 let local_entries = if let Some(local_oid) = local_commit_oid {
226 let lc = local_oid
227 .attach(repo)
228 .object()
229 .map_err(|e| Error::Other(format!("{e}")))?
230 .into_commit();
231 let lt = lc
232 .tree_id()
233 .map_err(|e| Error::Other(format!("{e}")))?
234 .detach();
235 parse_tree(repo, lt, "")?
236 } else {
237 ParsedTree::default()
238 };
239
240 let changes = remote_entries.values.len();
241
242 session.store.apply_tree(
244 &remote_entries.values,
245 &remote_entries.tombstones,
246 &remote_entries.set_tombstones,
247 &remote_entries.list_tombstones,
248 email,
249 now,
250 )?;
251
252 apply_legacy_deletes(session, &local_entries.values, remote_entries, email, now)?;
254
255 Ok(changes)
256}
257
258#[allow(clippy::too_many_arguments)]
264fn materialize_merge(
265 session: &Session,
266 local_oid: &gix::ObjectId,
267 remote_oid: &gix::ObjectId,
268 remote_entries: &ParsedTree,
269 remote_commit_obj: &gix::Commit<'_>,
270 email: &str,
271 now: i64,
272 local_ref_name: &str,
273) -> Result<(usize, Vec<ConflictDecision>, MaterializeStrategy)> {
274 let repo = &session.repo;
275
276 let local_commit_obj = local_oid
277 .attach(repo)
278 .object()
279 .map_err(|e| Error::Other(format!("{e}")))?
280 .into_commit();
281 let local_tree_id = local_commit_obj
282 .tree_id()
283 .map_err(|e| Error::Other(format!("{e}")))?
284 .detach();
285 let local_entries = parse_tree(repo, local_tree_id, "")?;
286
287 let local_timestamp = extract_author_timestamp(&local_commit_obj)?;
289 let remote_timestamp = extract_author_timestamp(remote_commit_obj)?;
290
291 let merge_base_oid = repo.merge_base(*local_oid, *remote_oid).ok();
292
293 let (
294 merged_values,
295 merged_tombstones,
296 merged_set_tombstones,
297 merged_list_tombstones,
298 conflict_decisions,
299 strategy,
300 legacy_base_values,
301 ) = if let Some(base_oid) = merge_base_oid {
302 run_three_way_merge(
303 repo,
304 base_oid,
305 &local_entries,
306 remote_entries,
307 local_timestamp,
308 remote_timestamp,
309 )?
310 } else {
311 run_two_way_merge(&local_entries, remote_entries)?
312 };
313
314 let changes = merged_values.len();
315
316 session.store.apply_tree(
318 &merged_values,
319 &merged_tombstones,
320 &merged_set_tombstones,
321 &merged_list_tombstones,
322 email,
323 now,
324 )?;
325
326 if let Some(base_values) = &legacy_base_values {
328 for key in base_values.keys() {
329 if !merged_values.contains_key(key) && !merged_tombstones.contains_key(key) {
330 let target = key.to_target();
331 session
332 .store
333 .apply_tombstone(&target, &key.key, email, now)?;
334 }
335 }
336 }
337
338 let merged_tree_oid = build_merged_tree(
340 repo,
341 &merged_values,
342 &merged_tombstones,
343 &merged_set_tombstones,
344 &merged_list_tombstones,
345 )?;
346
347 let name = session.name();
348 let sig = gix::actor::Signature {
349 name: name.into(),
350 email: email.into(),
351 time: gix::date::Time::new(now / 1000, 0),
352 };
353
354 let commit = gix::objs::Commit {
355 message: "materialize".into(),
356 tree: merged_tree_oid,
357 author: sig.clone(),
358 committer: sig,
359 encoding: None,
360 parents: vec![*local_oid, *remote_oid].into(),
361 extra_headers: Default::default(),
362 };
363
364 let merge_commit_oid = repo
365 .write_object(&commit)
366 .map_err(|e| Error::Other(format!("{e}")))?
367 .detach();
368 repo.reference(
369 local_ref_name,
370 merge_commit_oid,
371 PreviousValue::Any,
372 "materialize merge",
373 )
374 .map_err(|e| Error::Other(format!("{e}")))?;
375
376 Ok((changes, conflict_decisions, strategy))
377}
378
379#[allow(clippy::type_complexity)]
384fn run_three_way_merge(
385 repo: &gix::Repository,
386 base_oid: gix::Id<'_>,
387 local_entries: &ParsedTree,
388 remote_entries: &ParsedTree,
389 local_timestamp: i64,
390 remote_timestamp: i64,
391) -> Result<(
392 BTreeMap<Key, TreeValue>,
393 BTreeMap<Key, Tombstone>,
394 BTreeMap<(Key, String), String>,
395 BTreeMap<(Key, String), Tombstone>,
396 Vec<ConflictDecision>,
397 MaterializeStrategy,
398 Option<BTreeMap<Key, TreeValue>>,
399)> {
400 let base_commit_obj = base_oid
401 .object()
402 .map_err(|e| Error::Other(format!("{e}")))?
403 .into_commit();
404 let base_tree_id = base_commit_obj
405 .tree_id()
406 .map_err(|e| Error::Other(format!("{e}")))?
407 .detach();
408 let base_entries = parse_tree(repo, base_tree_id, "")?;
409
410 let legacy_base_values = Some(base_entries.values.clone());
411
412 let (merged_values, conflict_decisions) = three_way_merge(
413 &base_entries.values,
414 &local_entries.values,
415 &remote_entries.values,
416 local_timestamp,
417 remote_timestamp,
418 )?;
419
420 let merged_tombstones = merge_tombstones(
421 &base_entries.tombstones,
422 &local_entries.tombstones,
423 &remote_entries.tombstones,
424 &merged_values,
425 );
426 let merged_set_tombstones = merge_set_member_tombstones(
427 &local_entries.set_tombstones,
428 &remote_entries.set_tombstones,
429 &merged_values,
430 );
431 let merged_list_tombstones = merge_list_tombstones(
432 &local_entries.list_tombstones,
433 &remote_entries.list_tombstones,
434 &merged_values,
435 );
436
437 Ok((
438 merged_values,
439 merged_tombstones,
440 merged_set_tombstones,
441 merged_list_tombstones,
442 conflict_decisions,
443 MaterializeStrategy::ThreeWayMerge,
444 legacy_base_values,
445 ))
446}
447
448#[allow(clippy::type_complexity)]
453fn run_two_way_merge(
454 local_entries: &ParsedTree,
455 remote_entries: &ParsedTree,
456) -> Result<(
457 BTreeMap<Key, TreeValue>,
458 BTreeMap<Key, Tombstone>,
459 BTreeMap<(Key, String), String>,
460 BTreeMap<(Key, String), Tombstone>,
461 Vec<ConflictDecision>,
462 MaterializeStrategy,
463 Option<BTreeMap<Key, TreeValue>>,
464)> {
465 let (merged_values, merged_tombstones, conflict_decisions) = two_way_merge_no_common_ancestor(
466 &local_entries.values,
467 &local_entries.tombstones,
468 &remote_entries.values,
469 &remote_entries.tombstones,
470 );
471 let merged_set_tombstones = merge_set_member_tombstones(
472 &local_entries.set_tombstones,
473 &remote_entries.set_tombstones,
474 &merged_values,
475 );
476 let merged_list_tombstones = merge_list_tombstones(
477 &local_entries.list_tombstones,
478 &remote_entries.list_tombstones,
479 &merged_values,
480 );
481
482 Ok((
483 merged_values,
484 merged_tombstones,
485 merged_set_tombstones,
486 merged_list_tombstones,
487 conflict_decisions,
488 MaterializeStrategy::TwoWayMerge,
489 None,
490 ))
491}
492
493fn apply_legacy_deletes(
499 session: &Session,
500 local_values: &BTreeMap<Key, TreeValue>,
501 remote_entries: &ParsedTree,
502 email: &str,
503 now: i64,
504) -> Result<()> {
505 for key in local_values.keys() {
506 if !remote_entries.values.contains_key(key) {
507 let target = key.to_target();
508 session
509 .store
510 .apply_tombstone(&target, &key.key, email, now)?;
511 }
512 }
513 Ok(())
514}
515
516fn extract_author_timestamp(commit: &gix::Commit<'_>) -> Result<i64> {
523 let decoded = commit.decode().map_err(|e| Error::Other(format!("{e}")))?;
524 let time = decoded
525 .author()
526 .map_err(|e| Error::Other(format!("{e}")))?
527 .time()
528 .map_err(|e| Error::Other(format!("{e}")))?;
529 Ok(time.seconds)
530}
531
532pub fn find_remote_refs(
548 repo: &gix::Repository,
549 ns: &str,
550 remote: Option<&str>,
551) -> Result<Vec<(String, gix::ObjectId)>> {
552 let mut results = Vec::new();
553
554 let prefix = match remote {
555 Some(r) => format!("refs/{ns}/{r}"),
556 None => format!("refs/{ns}/"),
557 };
558 let local_prefix = format!("refs/{ns}/local/");
559
560 let platform = repo
561 .references()
562 .map_err(|e| Error::Other(format!("{e}")))?;
563 for reference in platform.all().map_err(|e| Error::Other(format!("{e}")))? {
564 let reference = reference.map_err(|e| Error::Other(format!("{e}")))?;
565 let name = reference.name().as_bstr().to_string();
566 if name.starts_with(&prefix) && !name.starts_with(&local_prefix) {
567 if let Ok(id) = reference.into_fully_peeled_id() {
568 results.push((name, id.detach()));
569 }
570 }
571 }
572
573 Ok(results)
574}