Skip to main content

git_meta_lib/
materialize.rs

1//! Materialize remote metadata into the local SQLite store.
2//!
3//! This module implements the full materialization workflow: discovering
4//! remote metadata refs, determining merge strategies (fast-forward,
5//! three-way, or two-way), applying changes to the database, creating
6//! merge commits, and updating tracking refs.
7//!
8//! The public entry point is [`run()`], which takes a [`Session`](crate::Session)
9//! and returns a [`MaterializeOutput`] describing what was applied.
10
11use 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/// How a remote ref was materialized.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum MaterializeStrategy {
29    /// Remote was a strict superset of local — direct apply.
30    FastForward,
31    /// Both sides had changes — three-way merge with common ancestor.
32    ThreeWayMerge,
33    /// No common ancestor — two-way merge, local wins on conflicts.
34    TwoWayMerge,
35    /// Already up-to-date — no changes applied.
36    UpToDate,
37}
38
39/// Result of materializing a single remote ref.
40#[must_use]
41#[derive(Debug, Clone)]
42pub struct MaterializeRefResult {
43    /// The ref that was materialized.
44    pub ref_name: String,
45    /// The merge strategy used.
46    pub strategy: MaterializeStrategy,
47    /// Number of DB changes applied.
48    pub changes: usize,
49    /// Conflicts that were resolved during merge.
50    pub conflicts: Vec<ConflictDecision>,
51}
52
53/// Result of a materialize operation.
54#[must_use]
55#[derive(Debug, Clone)]
56pub struct MaterializeOutput {
57    /// Results per remote ref.
58    pub results: Vec<MaterializeRefResult>,
59}
60
61/// Materialize remote metadata into the local SQLite store.
62///
63/// For each matching remote ref, determines the merge strategy and
64/// applies changes to the database. Updates tracking refs and the
65/// materialization timestamp.
66///
67/// # Parameters
68///
69/// - `session`: the git-meta session providing the repository, store, and config.
70/// - `remote`: optional remote name filter. If `None`, all remotes are materialized.
71/// - `now`: the current timestamp in milliseconds since the Unix epoch,
72///   used for database writes and the `last_materialized` marker.
73///
74/// # Returns
75///
76/// A [`MaterializeOutput`] with per-ref results. If no remote refs
77/// are found, the `results` vec will be empty.
78///
79/// # Errors
80///
81/// Returns an error if Git object reads, database writes, or ref updates fail.
82pub 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        // Get local commit (if any)
127        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        // Check if we can fast-forward: local is None, or local is an
134        // ancestor of remote (no local-only commits to preserve).
135        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            // Fast-forward the ref
159            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            // Need a real merge
175            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
212/// Apply a fast-forward materialization: parse the remote tree and apply
213/// it directly to the database, handling legacy deletes.
214///
215/// Returns the number of values in the remote tree (the change count).
216fn 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    // Apply remote tree to SQLite
243    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    // Ensure deletes are applied even for trees produced before tombstones.
253    apply_legacy_deletes(session, &local_entries.values, remote_entries, email, now)?;
254
255    Ok(changes)
256}
257
258/// Perform a merge materialization (three-way or two-way), apply the
259/// merged result to the database, build the merged tree, and create
260/// a merge commit.
261///
262/// Returns `(change_count, conflict_decisions, strategy)`.
263#[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    // Get commit timestamps for conflict resolution
288    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    // Update SQLite
317    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    // Handle removals where no explicit tombstone exists (legacy trees)
327    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    // Build the merged tree and write a merge commit
339    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/// Run a three-way merge using a common ancestor.
380///
381/// Returns the merged values, tombstones, set tombstones, list tombstones,
382/// conflict decisions, strategy, and legacy base values for implicit deletes.
383#[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/// Run a two-way merge when no common ancestor exists.
449///
450/// Returns the merged values, tombstones, set tombstones, list tombstones,
451/// conflict decisions, strategy, and `None` for legacy base values.
452#[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
493/// Apply legacy deletes: entries present in the local tree but absent
494/// from the remote tree and not covered by an explicit tombstone.
495///
496/// This handles the case where trees were produced before tombstone
497/// support was added.
498fn 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
516/// Extract the author timestamp (in seconds) from a commit object.
517///
518/// # Errors
519///
520/// Returns an error if the commit cannot be decoded or the author
521/// signature is malformed.
522fn 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
532/// Find remote refs matching the given namespace and optional remote filter.
533///
534/// Returns a list of `(ref_name, object_id)` pairs for remote metadata refs.
535/// Local refs (under `refs/{ns}/local/`) are excluded.
536///
537/// # Parameters
538///
539/// - `repo`: the git repository to search.
540/// - `ns`: the metadata namespace (e.g. `"meta"`).
541/// - `remote`: optional remote name filter. If `None`, all non-local refs
542///   under the namespace are returned.
543///
544/// # Errors
545///
546/// Returns an error if iterating refs fails.
547pub 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}