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 gmeta 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        // Get local commit (if any)
111        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        // Check if we can fast-forward: local is None, or local is an
118        // ancestor of remote (no local-only commits to preserve).
119        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            // Fast-forward the ref
143            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            // Need a real merge
159            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
188/// Apply a fast-forward materialization: parse the remote tree and apply
189/// it directly to the database, handling legacy deletes.
190///
191/// Returns the number of values in the remote tree (the change count).
192fn 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    // Apply remote tree to SQLite
219    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    // Ensure deletes are applied even for trees produced before tombstones.
229    apply_legacy_deletes(session, &local_entries.values, remote_entries, email, now)?;
230
231    Ok(changes)
232}
233
234/// Perform a merge materialization (three-way or two-way), apply the
235/// merged result to the database, build the merged tree, and create
236/// a merge commit.
237///
238/// Returns `(change_count, conflict_decisions, strategy)`.
239#[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    // Get commit timestamps for conflict resolution
264    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    // Update SQLite
293    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    // Handle removals where no explicit tombstone exists (legacy trees)
303    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    // Build the merged tree and write a merge commit
315    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/// Run a three-way merge using a common ancestor.
356///
357/// Returns the merged values, tombstones, set tombstones, list tombstones,
358/// conflict decisions, strategy, and legacy base values for implicit deletes.
359#[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/// Run a two-way merge when no common ancestor exists.
425///
426/// Returns the merged values, tombstones, set tombstones, list tombstones,
427/// conflict decisions, strategy, and `None` for legacy base values.
428#[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
469/// Apply legacy deletes: entries present in the local tree but absent
470/// from the remote tree and not covered by an explicit tombstone.
471///
472/// This handles the case where trees were produced before tombstone
473/// support was added.
474fn 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
492/// Extract the author timestamp (in seconds) from a commit object.
493///
494/// # Errors
495///
496/// Returns an error if the commit cannot be decoded or the author
497/// signature is malformed.
498fn 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
508/// Find remote refs matching the given namespace and optional remote filter.
509///
510/// Returns a list of `(ref_name, object_id)` pairs for remote metadata refs.
511/// Local refs (under `refs/{ns}/local/`) are excluded.
512///
513/// # Parameters
514///
515/// - `repo`: the git repository to search.
516/// - `ns`: the metadata namespace (e.g. `"meta"`).
517/// - `remote`: optional remote name filter. If `None`, all non-local refs
518///   under the namespace are returned.
519///
520/// # Errors
521///
522/// Returns an error if iterating refs fails.
523pub 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}