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 let local_commit_oid = repo
112 .find_reference(&local_ref_name)
113 .ok()
114 .and_then(|r| r.into_fully_peeled_id().ok())
115 .map(gix::Id::detach);
116
117 let can_fast_forward = match &local_commit_oid {
120 None => true,
121 Some(local_oid) => {
122 if *local_oid == *remote_oid {
123 results.push(MaterializeRefResult {
124 ref_name: ref_name.clone(),
125 strategy: MaterializeStrategy::UpToDate,
126 changes: 0,
127 conflicts: Vec::new(),
128 });
129 continue;
130 }
131 match repo.merge_base(*local_oid, *remote_oid) {
132 Ok(base_oid) => base_oid == *local_oid,
133 Err(_) => false,
134 }
135 }
136 };
137
138 if can_fast_forward {
139 let changes =
140 materialize_fast_forward(session, &local_commit_oid, &remote_entries, email, now)?;
141
142 repo.reference(
144 local_ref_name.as_str(),
145 *remote_oid,
146 PreviousValue::Any,
147 "fast-forward materialize",
148 )
149 .map_err(|e| Error::Other(format!("{e}")))?;
150
151 results.push(MaterializeRefResult {
152 ref_name: ref_name.clone(),
153 strategy: MaterializeStrategy::FastForward,
154 changes,
155 conflicts: Vec::new(),
156 });
157 } else {
158 let local_oid = local_commit_oid.as_ref().ok_or_else(|| {
160 Error::Other("expected local commit for merge but found None".into())
161 })?;
162
163 let (changes, conflict_decisions, strategy) = materialize_merge(
164 session,
165 local_oid,
166 remote_oid,
167 &remote_entries,
168 &remote_commit_obj,
169 email,
170 now,
171 &local_ref_name,
172 )?;
173
174 results.push(MaterializeRefResult {
175 ref_name: ref_name.clone(),
176 strategy,
177 changes,
178 conflicts: conflict_decisions,
179 });
180 }
181 }
182
183 session.store.set_last_materialized(now)?;
184
185 Ok(MaterializeOutput { results })
186}
187
188fn materialize_fast_forward(
193 session: &Session,
194 local_commit_oid: &Option<gix::ObjectId>,
195 remote_entries: &ParsedTree,
196 email: &str,
197 now: i64,
198) -> Result<usize> {
199 let repo = &session.repo;
200
201 let local_entries = if let Some(local_oid) = local_commit_oid {
202 let lc = local_oid
203 .attach(repo)
204 .object()
205 .map_err(|e| Error::Other(format!("{e}")))?
206 .into_commit();
207 let lt = lc
208 .tree_id()
209 .map_err(|e| Error::Other(format!("{e}")))?
210 .detach();
211 parse_tree(repo, lt, "")?
212 } else {
213 ParsedTree::default()
214 };
215
216 let changes = remote_entries.values.len();
217
218 session.store.apply_tree(
220 &remote_entries.values,
221 &remote_entries.tombstones,
222 &remote_entries.set_tombstones,
223 &remote_entries.list_tombstones,
224 email,
225 now,
226 )?;
227
228 apply_legacy_deletes(session, &local_entries.values, remote_entries, email, now)?;
230
231 Ok(changes)
232}
233
234#[allow(clippy::too_many_arguments)]
240fn materialize_merge(
241 session: &Session,
242 local_oid: &gix::ObjectId,
243 remote_oid: &gix::ObjectId,
244 remote_entries: &ParsedTree,
245 remote_commit_obj: &gix::Commit<'_>,
246 email: &str,
247 now: i64,
248 local_ref_name: &str,
249) -> Result<(usize, Vec<ConflictDecision>, MaterializeStrategy)> {
250 let repo = &session.repo;
251
252 let local_commit_obj = local_oid
253 .attach(repo)
254 .object()
255 .map_err(|e| Error::Other(format!("{e}")))?
256 .into_commit();
257 let local_tree_id = local_commit_obj
258 .tree_id()
259 .map_err(|e| Error::Other(format!("{e}")))?
260 .detach();
261 let local_entries = parse_tree(repo, local_tree_id, "")?;
262
263 let local_timestamp = extract_author_timestamp(&local_commit_obj)?;
265 let remote_timestamp = extract_author_timestamp(remote_commit_obj)?;
266
267 let merge_base_oid = repo.merge_base(*local_oid, *remote_oid).ok();
268
269 let (
270 merged_values,
271 merged_tombstones,
272 merged_set_tombstones,
273 merged_list_tombstones,
274 conflict_decisions,
275 strategy,
276 legacy_base_values,
277 ) = if let Some(base_oid) = merge_base_oid {
278 run_three_way_merge(
279 repo,
280 base_oid,
281 &local_entries,
282 remote_entries,
283 local_timestamp,
284 remote_timestamp,
285 )?
286 } else {
287 run_two_way_merge(&local_entries, remote_entries)?
288 };
289
290 let changes = merged_values.len();
291
292 session.store.apply_tree(
294 &merged_values,
295 &merged_tombstones,
296 &merged_set_tombstones,
297 &merged_list_tombstones,
298 email,
299 now,
300 )?;
301
302 if let Some(base_values) = &legacy_base_values {
304 for key in base_values.keys() {
305 if !merged_values.contains_key(key) && !merged_tombstones.contains_key(key) {
306 let target = key.to_target();
307 session
308 .store
309 .apply_tombstone(&target, &key.key, email, now)?;
310 }
311 }
312 }
313
314 let merged_tree_oid = build_merged_tree(
316 repo,
317 &merged_values,
318 &merged_tombstones,
319 &merged_set_tombstones,
320 &merged_list_tombstones,
321 )?;
322
323 let name = session.name();
324 let sig = gix::actor::Signature {
325 name: name.into(),
326 email: email.into(),
327 time: gix::date::Time::new(now / 1000, 0),
328 };
329
330 let commit = gix::objs::Commit {
331 message: "materialize".into(),
332 tree: merged_tree_oid,
333 author: sig.clone(),
334 committer: sig,
335 encoding: None,
336 parents: vec![*local_oid, *remote_oid].into(),
337 extra_headers: Default::default(),
338 };
339
340 let merge_commit_oid = repo
341 .write_object(&commit)
342 .map_err(|e| Error::Other(format!("{e}")))?
343 .detach();
344 repo.reference(
345 local_ref_name,
346 merge_commit_oid,
347 PreviousValue::Any,
348 "materialize merge",
349 )
350 .map_err(|e| Error::Other(format!("{e}")))?;
351
352 Ok((changes, conflict_decisions, strategy))
353}
354
355#[allow(clippy::type_complexity)]
360fn run_three_way_merge(
361 repo: &gix::Repository,
362 base_oid: gix::Id<'_>,
363 local_entries: &ParsedTree,
364 remote_entries: &ParsedTree,
365 local_timestamp: i64,
366 remote_timestamp: i64,
367) -> Result<(
368 BTreeMap<Key, TreeValue>,
369 BTreeMap<Key, Tombstone>,
370 BTreeMap<(Key, String), String>,
371 BTreeMap<(Key, String), Tombstone>,
372 Vec<ConflictDecision>,
373 MaterializeStrategy,
374 Option<BTreeMap<Key, TreeValue>>,
375)> {
376 let base_commit_obj = base_oid
377 .object()
378 .map_err(|e| Error::Other(format!("{e}")))?
379 .into_commit();
380 let base_tree_id = base_commit_obj
381 .tree_id()
382 .map_err(|e| Error::Other(format!("{e}")))?
383 .detach();
384 let base_entries = parse_tree(repo, base_tree_id, "")?;
385
386 let legacy_base_values = Some(base_entries.values.clone());
387
388 let (merged_values, conflict_decisions) = three_way_merge(
389 &base_entries.values,
390 &local_entries.values,
391 &remote_entries.values,
392 local_timestamp,
393 remote_timestamp,
394 )?;
395
396 let merged_tombstones = merge_tombstones(
397 &base_entries.tombstones,
398 &local_entries.tombstones,
399 &remote_entries.tombstones,
400 &merged_values,
401 );
402 let merged_set_tombstones = merge_set_member_tombstones(
403 &local_entries.set_tombstones,
404 &remote_entries.set_tombstones,
405 &merged_values,
406 );
407 let merged_list_tombstones = merge_list_tombstones(
408 &local_entries.list_tombstones,
409 &remote_entries.list_tombstones,
410 &merged_values,
411 );
412
413 Ok((
414 merged_values,
415 merged_tombstones,
416 merged_set_tombstones,
417 merged_list_tombstones,
418 conflict_decisions,
419 MaterializeStrategy::ThreeWayMerge,
420 legacy_base_values,
421 ))
422}
423
424#[allow(clippy::type_complexity)]
429fn run_two_way_merge(
430 local_entries: &ParsedTree,
431 remote_entries: &ParsedTree,
432) -> Result<(
433 BTreeMap<Key, TreeValue>,
434 BTreeMap<Key, Tombstone>,
435 BTreeMap<(Key, String), String>,
436 BTreeMap<(Key, String), Tombstone>,
437 Vec<ConflictDecision>,
438 MaterializeStrategy,
439 Option<BTreeMap<Key, TreeValue>>,
440)> {
441 let (merged_values, merged_tombstones, conflict_decisions) = two_way_merge_no_common_ancestor(
442 &local_entries.values,
443 &local_entries.tombstones,
444 &remote_entries.values,
445 &remote_entries.tombstones,
446 );
447 let merged_set_tombstones = merge_set_member_tombstones(
448 &local_entries.set_tombstones,
449 &remote_entries.set_tombstones,
450 &merged_values,
451 );
452 let merged_list_tombstones = merge_list_tombstones(
453 &local_entries.list_tombstones,
454 &remote_entries.list_tombstones,
455 &merged_values,
456 );
457
458 Ok((
459 merged_values,
460 merged_tombstones,
461 merged_set_tombstones,
462 merged_list_tombstones,
463 conflict_decisions,
464 MaterializeStrategy::TwoWayMerge,
465 None,
466 ))
467}
468
469fn apply_legacy_deletes(
475 session: &Session,
476 local_values: &BTreeMap<Key, TreeValue>,
477 remote_entries: &ParsedTree,
478 email: &str,
479 now: i64,
480) -> Result<()> {
481 for key in local_values.keys() {
482 if !remote_entries.values.contains_key(key) {
483 let target = key.to_target();
484 session
485 .store
486 .apply_tombstone(&target, &key.key, email, now)?;
487 }
488 }
489 Ok(())
490}
491
492fn extract_author_timestamp(commit: &gix::Commit<'_>) -> Result<i64> {
499 let decoded = commit.decode().map_err(|e| Error::Other(format!("{e}")))?;
500 let time = decoded
501 .author()
502 .map_err(|e| Error::Other(format!("{e}")))?
503 .time()
504 .map_err(|e| Error::Other(format!("{e}")))?;
505 Ok(time.seconds)
506}
507
508pub fn find_remote_refs(
524 repo: &gix::Repository,
525 ns: &str,
526 remote: Option<&str>,
527) -> Result<Vec<(String, gix::ObjectId)>> {
528 let mut results = Vec::new();
529
530 let prefix = match remote {
531 Some(r) => format!("refs/{ns}/{r}"),
532 None => format!("refs/{ns}/"),
533 };
534 let local_prefix = format!("refs/{ns}/local/");
535
536 let platform = repo
537 .references()
538 .map_err(|e| Error::Other(format!("{e}")))?;
539 for reference in platform.all().map_err(|e| Error::Other(format!("{e}")))? {
540 let reference = reference.map_err(|e| Error::Other(format!("{e}")))?;
541 let name = reference.name().as_bstr().to_string();
542 if name.starts_with(&prefix) && !name.starts_with(&local_prefix) {
543 if let Ok(id) = reference.into_fully_peeled_id() {
544 results.push((name, id.detach()));
545 }
546 }
547 }
548
549 Ok(results)
550}