Skip to main content

git_meta_lib/
serialize.rs

1//! Serialize local metadata to Git tree(s) and commit(s).
2//!
3//! This module implements the full serialization workflow: reading metadata
4//! from the SQLite store, building Git trees (full or incremental), creating
5//! commits, updating refs, and optionally auto-pruning old entries.
6//!
7//! The public entry point is [`run()`], which takes a [`Session`](crate::Session)
8//! and returns a [`SerializeOutput`] describing what was written.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use gix::bstr::ByteSlice;
13use gix::prelude::ObjectIdExt;
14use gix::refs::transaction::PreviousValue;
15
16use crate::db::types::{
17    ListTombstoneRecord, Operation, SerializableEntry, SetTombstoneRecord, TombstoneRecord,
18};
19use crate::db::Store;
20use crate::error::{Error, Result};
21use crate::list_value::{make_entry_name, parse_entries};
22use crate::prune::{self, PruneRules};
23use crate::session::Session;
24use crate::tree::filter::{classify_key, parse_filter_rules, MAIN_DEST};
25use crate::tree::format::{build_dir, build_tree_from_paths, insert_path, TreeDir};
26use crate::tree::model::Tombstone;
27use crate::tree_paths;
28use crate::types::{Target, TargetType, ValueType};
29
30/// Maximum number of individual change lines included in a commit message.
31const MAX_COMMIT_CHANGES: usize = 1000;
32
33/// Result of a serialize operation.
34///
35/// Contains all the information needed by a CLI or other consumer
36/// to report what happened, without performing any I/O itself.
37#[must_use]
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct SerializeOutput {
40    /// Number of metadata changes serialized (total entries across all destinations).
41    pub changes: usize,
42    /// Refs that were written, e.g. `["refs/meta/local/main"]`.
43    pub refs_written: Vec<String>,
44    /// Number of entries dropped by auto-prune (0 if no prune triggered).
45    pub pruned: u64,
46}
47
48/// Serialize local metadata to Git tree(s) and commit(s).
49///
50/// Determines incremental vs full mode automatically based on
51/// `last_materialized`. Applies filter routing and pruning rules.
52/// Updates local refs and the materialization timestamp.
53///
54/// # Parameters
55///
56/// - `session`: the gmeta session providing the repository, store, and config.
57/// - `now`: the current timestamp in milliseconds since the Unix epoch,
58///   used for the commit signature and the `last_materialized` marker.
59///
60/// # Returns
61///
62/// A [`SerializeOutput`] with counts and written refs. If there is nothing
63/// to serialize, `changes` will be `0` and `refs_written` will be empty.
64///
65/// # Errors
66///
67/// Returns an error if database reads, Git object writes, or ref updates fail.
68pub fn run(session: &Session, now: i64) -> Result<SerializeOutput> {
69    let repo = &session.repo;
70    let local_ref_name = session.local_ref();
71    let last_materialized = session.store.get_last_materialized()?;
72
73    // Determine existing tree for incremental mode
74    let existing_tree_oid = repo
75        .find_reference(&local_ref_name)
76        .ok()
77        .and_then(|r| r.into_fully_peeled_id().ok())
78        .and_then(|id| {
79            id.object()
80                .ok()?
81                .into_commit()
82                .tree_id()
83                .ok()
84                .map(gix::Id::detach)
85        });
86
87    // Determine incremental vs full mode and collect entries + changes
88    let (
89        metadata_entries,
90        tombstone_entries,
91        set_tombstone_entries,
92        list_tombstone_entries,
93        dirty_target_bases,
94        changes,
95    ) = if let Some(since) = last_materialized {
96        let modified = session.store.get_modified_since(since)?;
97        if modified.is_empty() && existing_tree_oid.is_some() {
98            return Ok(SerializeOutput {
99                changes: 0,
100                refs_written: Vec::new(),
101                pruned: 0,
102            });
103        }
104
105        let changes: Vec<(char, String, String)> = modified
106            .iter()
107            .map(|entry| {
108                let op_char = match entry.operation {
109                    Operation::Remove => 'D',
110                    Operation::Set => {
111                        if existing_tree_oid.is_some() {
112                            'M'
113                        } else {
114                            'A'
115                        }
116                    }
117                    _ => 'M',
118                };
119                let target_label = if entry.target_type == TargetType::Project {
120                    "project".to_string()
121                } else {
122                    format!("{}:{}", entry.target_type, entry.target_value)
123                };
124                (op_char, target_label, entry.key.clone())
125            })
126            .collect();
127
128        // Compute dirty target base paths from modified entries
129        let mut dirty_bases: BTreeSet<String> = BTreeSet::new();
130        for entry in &modified {
131            let target = if entry.target_type == TargetType::Project {
132                Target::parse("project")?
133            } else {
134                Target::parse(&format!("{}:{}", entry.target_type, entry.target_value))?
135            };
136            dirty_bases.insert(tree_paths::tree_base_path(&target));
137        }
138
139        let metadata = session.store.get_all_metadata()?;
140        let tombstones = session.store.get_all_tombstones()?;
141        let set_tombstones = session.store.get_all_set_tombstones()?;
142        let list_tombstones = session.store.get_all_list_tombstones()?;
143
144        (
145            metadata,
146            tombstones,
147            set_tombstones,
148            list_tombstones,
149            if existing_tree_oid.is_some() {
150                Some(dirty_bases)
151            } else {
152                None
153            },
154            changes,
155        )
156    } else {
157        let metadata = session.store.get_all_metadata()?;
158
159        let changes: Vec<(char, String, String)> = metadata
160            .iter()
161            .map(|e| {
162                let target_label = if e.target_type == TargetType::Project {
163                    "project".to_string()
164                } else {
165                    format!("{}:{}", e.target_type, e.target_value)
166                };
167                ('A', target_label, e.key.clone())
168            })
169            .collect();
170
171        (
172            metadata,
173            session.store.get_all_tombstones()?,
174            session.store.get_all_set_tombstones()?,
175            session.store.get_all_list_tombstones()?,
176            None,
177            changes,
178        )
179    };
180
181    if metadata_entries.is_empty() && tombstone_entries.is_empty() {
182        return Ok(SerializeOutput {
183            changes: 0,
184            refs_written: Vec::new(),
185            pruned: 0,
186        });
187    }
188
189    // Apply prune-since cutoff to filter old entries before building the tree
190    let prune_since = session
191        .store
192        .get(&Target::project(), "meta:prune:since")?
193        .and_then(|e| serde_json::from_str::<String>(&e.value).ok());
194    let prune_rules = prune::read_prune_rules(&session.store)?;
195    let prune_cutoff_ms = prune_since
196        .as_deref()
197        .map(|s| prune::parse_since_to_cutoff_ms(s, now))
198        .transpose()?;
199    let mut pruned_count = 0u64;
200    let metadata_entries = if let Some(cutoff) = prune_cutoff_ms {
201        metadata_entries
202            .into_iter()
203            .filter(|e| {
204                if e.target_type != TargetType::Project && e.last_timestamp < cutoff {
205                    pruned_count += 1;
206                    false
207                } else {
208                    true
209                }
210            })
211            .collect()
212    } else {
213        metadata_entries
214    };
215
216    // Route entries through filter rules to destinations
217    let filter_rules = parse_filter_rules(&session.store)?;
218
219    let mut dest_metadata: BTreeMap<String, Vec<SerializableEntry>> = BTreeMap::new();
220    let mut dest_tombstones: BTreeMap<String, Vec<TombstoneRecord>> = BTreeMap::new();
221    let mut dest_set_tombstones: BTreeMap<String, Vec<SetTombstoneRecord>> = BTreeMap::new();
222    let mut dest_list_tombstones: BTreeMap<String, Vec<ListTombstoneRecord>> = BTreeMap::new();
223
224    for entry in &metadata_entries {
225        let key = &entry.key;
226        if let Some(dests) = classify_key(key, &filter_rules) {
227            for dest in dests {
228                dest_metadata.entry(dest).or_default().push(entry.clone());
229            }
230        }
231    }
232
233    for entry in &tombstone_entries {
234        if let Some(dests) = classify_key(&entry.key, &filter_rules) {
235            for dest in dests {
236                dest_tombstones.entry(dest).or_default().push(entry.clone());
237            }
238        }
239    }
240
241    for entry in &set_tombstone_entries {
242        if let Some(dests) = classify_key(&entry.key, &filter_rules) {
243            for dest in dests {
244                dest_set_tombstones
245                    .entry(dest)
246                    .or_default()
247                    .push(entry.clone());
248            }
249        }
250    }
251
252    for entry in &list_tombstone_entries {
253        if let Some(dests) = classify_key(&entry.key, &filter_rules) {
254            for dest in dests {
255                dest_list_tombstones
256                    .entry(dest)
257                    .or_default()
258                    .push(entry.clone());
259            }
260        }
261    }
262
263    // Ensure "main" is always present
264    dest_metadata.entry(MAIN_DEST.to_string()).or_default();
265
266    let mut all_dests: BTreeSet<String> = BTreeSet::new();
267    all_dests.extend(dest_metadata.keys().cloned());
268    all_dests.extend(dest_tombstones.keys().cloned());
269    all_dests.extend(dest_set_tombstones.keys().cloned());
270    all_dests.extend(dest_list_tombstones.keys().cloned());
271
272    let total_changes: usize = dest_metadata.values().map(std::vec::Vec::len).sum();
273
274    let name = session.name();
275    let email = session.email();
276    let sig = gix::actor::Signature {
277        name: name.into(),
278        email: email.into(),
279        time: gix::date::Time::new(now / 1000, 0),
280    };
281
282    let mut refs_written = Vec::new();
283    let mut auto_pruned = 0u64;
284
285    for dest in &all_dests {
286        let ref_name = session.destination_ref(dest);
287        let empty_meta: Vec<SerializableEntry> = Vec::new();
288        let empty_tomb: Vec<TombstoneRecord> = Vec::new();
289        let empty_set_tomb: Vec<SetTombstoneRecord> = Vec::new();
290        let empty_list_tomb: Vec<ListTombstoneRecord> = Vec::new();
291
292        let meta = dest_metadata.get(dest).unwrap_or(&empty_meta);
293        let tombs = dest_tombstones.get(dest).unwrap_or(&empty_tomb);
294        let set_tombs = dest_set_tombstones.get(dest).unwrap_or(&empty_set_tomb);
295        let list_tombs = dest_list_tombstones.get(dest).unwrap_or(&empty_list_tomb);
296
297        if meta.is_empty() && tombs.is_empty() && set_tombs.is_empty() && list_tombs.is_empty() {
298            continue;
299        }
300
301        // Use incremental mode only for the main destination
302        let (existing, dirty) = if dest == MAIN_DEST {
303            (existing_tree_oid, dirty_target_bases.as_ref())
304        } else {
305            (None, None)
306        };
307
308        let tree_oid = build_tree(repo, meta, tombs, set_tombs, list_tombs, existing, dirty)?;
309
310        let parent_oid = repo
311            .find_reference(&ref_name)
312            .ok()
313            .and_then(|r| r.into_fully_peeled_id().ok())
314            .map(gix::Id::detach);
315
316        let parents: Vec<gix::ObjectId> = parent_oid.into_iter().collect();
317        let commit_message = build_commit_message(&changes);
318        let commit = gix::objs::Commit {
319            message: commit_message.into(),
320            tree: tree_oid,
321            author: sig.clone(),
322            committer: sig.clone(),
323            encoding: None,
324            parents: parents.into(),
325            extra_headers: Default::default(),
326        };
327
328        let commit_oid = repo
329            .write_object(&commit)
330            .map_err(|e| Error::Other(format!("{e}")))?
331            .detach();
332        repo.reference(
333            ref_name.as_str(),
334            commit_oid,
335            PreviousValue::Any,
336            "git-meta: serialize",
337        )
338        .map_err(|e| Error::Other(format!("{e}")))?;
339
340        refs_written.push(ref_name.clone());
341
342        // Auto-prune only for main destination
343        if dest == MAIN_DEST {
344            if let Some(ref prune_rules_val) = prune_rules {
345                if prune::should_prune(repo, tree_oid, prune_rules_val)? {
346                    let prune_tree_oid =
347                        prune_tree(repo, tree_oid, prune_rules_val, &session.store, now)?;
348
349                    if prune_tree_oid != tree_oid {
350                        let prune_parent_oid = repo
351                            .find_reference(&ref_name)
352                            .map_err(|e| Error::Other(format!("{e}")))?
353                            .into_fully_peeled_id()
354                            .map_err(|e| Error::Other(format!("{e}")))?
355                            .detach();
356
357                        let (keys_dropped, keys_retained) =
358                            count_prune_stats(repo, tree_oid, prune_tree_oid)?;
359
360                        auto_pruned = keys_dropped;
361
362                        let min_size_str = prune_rules_val
363                            .min_size
364                            .map(|s| format!("\nmin-size: {s}"))
365                            .unwrap_or_default();
366
367                        let message = format!(
368                            "git-meta: prune --since={}\n\npruned: true\nsince: {}{}\nkeys-dropped: {}\nkeys-retained: {}",
369                            prune_rules_val.since, prune_rules_val.since, min_size_str, keys_dropped, keys_retained
370                        );
371
372                        let prune_commit = gix::objs::Commit {
373                            message: message.into(),
374                            tree: prune_tree_oid,
375                            author: sig.clone(),
376                            committer: sig.clone(),
377                            encoding: None,
378                            parents: vec![prune_parent_oid].into(),
379                            extra_headers: Default::default(),
380                        };
381
382                        let _prune_commit_oid = repo
383                            .write_object(&prune_commit)
384                            .map_err(|e| Error::Other(format!("{e}")))?
385                            .detach();
386                        repo.reference(
387                            ref_name.as_str(),
388                            _prune_commit_oid,
389                            PreviousValue::Any,
390                            "git-meta: auto-prune",
391                        )
392                        .map_err(|e| Error::Other(format!("{e}")))?;
393                    }
394                }
395            }
396        }
397    }
398
399    session.store.set_last_materialized(now)?;
400
401    Ok(SerializeOutput {
402        changes: total_changes,
403        refs_written,
404        pruned: pruned_count + auto_pruned,
405    })
406}
407
408/// Build a commit message from a list of changes.
409///
410/// Each change is `(op_char, target_label, key)`.
411fn build_commit_message(changes: &[(char, String, String)]) -> String {
412    if changes.len() > MAX_COMMIT_CHANGES {
413        format!(
414            "git-meta: serialize ({} changes)\n\nchanges-omitted: true\ncount: {}",
415            changes.len(),
416            changes.len()
417        )
418    } else {
419        let mut msg = format!("git-meta: serialize ({} changes)\n", changes.len());
420        for (op, target, key) in changes {
421            msg.push('\n');
422            msg.push(*op);
423            msg.push('\t');
424            msg.push_str(target);
425            msg.push('\t');
426            msg.push_str(key);
427        }
428        msg
429    }
430}
431
432/// Build a Git tree from pre-filtered metadata (no incremental mode).
433///
434/// Used by `git-meta prune` to rebuild a tree from only the surviving entries.
435///
436/// # Parameters
437///
438/// - `repo`: the Git repository to write objects into
439/// - `metadata_entries`: metadata entries to include
440/// - `tombstone_entries`: key tombstones
441/// - `set_tombstone_entries`: set-member tombstones
442/// - `list_tombstone_entries`: list-entry tombstones
443///
444/// # Returns
445///
446/// The OID of the root Git tree object.
447///
448/// # Errors
449///
450/// Returns an error if target parsing or Git object writes fail.
451pub fn build_filtered_tree(
452    repo: &gix::Repository,
453    metadata_entries: &[SerializableEntry],
454    tombstone_entries: &[TombstoneRecord],
455    set_tombstone_entries: &[SetTombstoneRecord],
456    list_tombstone_entries: &[ListTombstoneRecord],
457) -> Result<gix::ObjectId> {
458    build_tree(
459        repo,
460        metadata_entries,
461        tombstone_entries,
462        set_tombstone_entries,
463        list_tombstone_entries,
464        None,
465        None,
466    )
467}
468
469/// Build a complete Git tree from all metadata entries.
470///
471/// When `existing_tree_oid` and `dirty_target_bases` are provided, only entries
472/// belonging to dirty targets are processed; unchanged subtrees are reused
473/// from the existing tree by OID (incremental mode).
474fn build_tree(
475    repo: &gix::Repository,
476    metadata_entries: &[SerializableEntry],
477    tombstone_entries: &[TombstoneRecord],
478    set_tombstone_entries: &[SetTombstoneRecord],
479    list_tombstone_entries: &[ListTombstoneRecord],
480    existing_tree_oid: Option<gix::ObjectId>,
481    dirty_target_bases: Option<&BTreeSet<String>>,
482) -> Result<gix::ObjectId> {
483    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
484
485    for e in metadata_entries {
486        let target = if e.target_type == TargetType::Project {
487            Target::parse("project")?
488        } else {
489            Target::parse(&format!("{}:{}", e.target_type, e.target_value))?
490        };
491
492        // Skip entries for clean targets -- their subtrees will be reused
493        if let Some(dirty) = dirty_target_bases {
494            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
495                continue;
496            }
497        }
498
499        match e.value_type {
500            ValueType::String => {
501                let full_path = tree_paths::tree_path(&target, &e.key)?;
502                if e.is_git_ref {
503                    let oid = gix::ObjectId::from_hex(e.value.as_bytes())
504                        .map_err(|e| Error::Other(format!("{e}")))?;
505                    let blob = oid
506                        .attach(repo)
507                        .object()
508                        .map_err(|e| Error::Other(format!("{e}")))?
509                        .into_blob();
510                    files.insert(full_path, blob.data.clone());
511                } else {
512                    let raw_value: String = match serde_json::from_str(&e.value) {
513                        Ok(s) => s,
514                        Err(_) => e.value.clone(),
515                    };
516                    files.insert(full_path, raw_value.into_bytes());
517                }
518            }
519            ValueType::List => {
520                let list_entries =
521                    parse_entries(&e.value).map_err(|e| Error::InvalidValue(format!("{e}")))?;
522                let list_dir_path = tree_paths::list_dir_path(&target, &e.key)?;
523                for entry in list_entries {
524                    let entry_name = make_entry_name(&entry);
525                    let full_path = format!("{list_dir_path}/{entry_name}");
526                    files.insert(full_path, entry.value.into_bytes());
527                }
528            }
529            ValueType::Set => {
530                let members: Vec<String> = serde_json::from_str(&e.value)
531                    .map_err(|e| Error::InvalidValue(format!("failed to decode set value: {e}")))?;
532                let set_dir_path = tree_paths::set_dir_path(&target, &e.key)?;
533                for member in members {
534                    let member_id = crate::types::set_member_id(&member);
535                    let full_path = format!("{set_dir_path}/{member_id}");
536                    files.insert(full_path, member.into_bytes());
537                }
538            }
539        }
540    }
541
542    for record in tombstone_entries {
543        let target = if record.target_type == TargetType::Project {
544            Target::parse("project")?
545        } else {
546            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
547        };
548
549        if let Some(dirty) = dirty_target_bases {
550            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
551                continue;
552            }
553        }
554
555        let full_path = tree_paths::tombstone_path(&target, &record.key)?;
556        let payload = serde_json::to_vec(&Tombstone {
557            timestamp: record.timestamp,
558            email: record.email.clone(),
559        })?;
560        files.insert(full_path, payload);
561    }
562
563    for record in set_tombstone_entries {
564        let target = if record.target_type == TargetType::Project {
565            Target::parse("project")?
566        } else {
567            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
568        };
569
570        if let Some(dirty) = dirty_target_bases {
571            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
572                continue;
573            }
574        }
575
576        let full_path =
577            tree_paths::set_member_tombstone_path(&target, &record.key, &record.member_id)?;
578        files.insert(full_path, record.value.as_bytes().to_vec());
579    }
580
581    for record in list_tombstone_entries {
582        let target = if record.target_type == TargetType::Project {
583            Target::parse("project")?
584        } else {
585            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
586        };
587
588        if let Some(dirty) = dirty_target_bases {
589            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
590                continue;
591            }
592        }
593
594        let full_path =
595            tree_paths::list_entry_tombstone_path(&target, &record.key, &record.entry_name)?;
596        let payload = serde_json::to_vec(&Tombstone {
597            timestamp: record.timestamp,
598            email: record.email.clone(),
599        })?;
600        files.insert(full_path, payload);
601    }
602
603    // Build nested tree, reusing unchanged subtrees from existing tree
604    if let (Some(existing_oid), Some(dirty_bases)) = (existing_tree_oid, dirty_target_bases) {
605        build_tree_incremental(repo, existing_oid, &files, dirty_bases)
606    } else {
607        build_tree_from_paths(repo, &files)
608    }
609}
610
611/// Incrementally build a tree by patching an existing tree.
612///
613/// Only dirty target subtrees are rebuilt from `files`; all other subtrees
614/// are reused from the existing tree by OID.
615fn build_tree_incremental(
616    repo: &gix::Repository,
617    existing_tree_oid: gix::ObjectId,
618    files: &BTreeMap<String, Vec<u8>>,
619    dirty_target_bases: &BTreeSet<String>,
620) -> Result<gix::ObjectId> {
621    // Step 1: Remove dirty target subtrees from existing tree
622    let cleaned_oid = remove_subtrees(repo, existing_tree_oid, dirty_target_bases)?;
623
624    // Step 2: Build TreeDir from dirty files only
625    let mut root = TreeDir::default();
626    for (path, content) in files {
627        let parts: Vec<&str> = path.split('/').collect();
628        insert_path(&mut root, &parts, content.clone());
629    }
630
631    // Step 3: Merge new content into cleaned tree
632    merge_dir_into_tree(repo, &root, cleaned_oid)
633}
634
635/// Remove subtrees at specific paths from an existing tree.
636fn remove_subtrees(
637    repo: &gix::Repository,
638    tree_oid: gix::ObjectId,
639    paths: &BTreeSet<String>,
640) -> Result<gix::ObjectId> {
641    let mut grouped: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
642    let mut direct_removes: BTreeSet<String> = BTreeSet::new();
643
644    for path in paths {
645        if let Some((first, rest)) = path.split_once('/') {
646            grouped
647                .entry(first.to_string())
648                .or_default()
649                .insert(rest.to_string());
650        } else {
651            direct_removes.insert(path.clone());
652        }
653    }
654
655    let mut editor = repo
656        .edit_tree(tree_oid)
657        .map_err(|e| Error::Other(format!("{e}")))?;
658
659    for name in &direct_removes {
660        let _ = editor.remove(name);
661    }
662
663    // For grouped paths, recurse into subtrees
664    let tree = tree_oid
665        .attach(repo)
666        .object()
667        .map_err(|e| Error::Other(format!("{e}")))?
668        .into_tree();
669    for (name, sub_paths) in &grouped {
670        let entry = tree.iter().find_map(|e| {
671            let e = e.ok()?;
672            if e.filename().to_str_lossy() == *name && e.mode().is_tree() {
673                Some(e.object_id())
674            } else {
675                None
676            }
677        });
678        if let Some(subtree_oid) = entry {
679            let new_oid = remove_subtrees(repo, subtree_oid, sub_paths)?;
680            let new_tree = new_oid
681                .attach(repo)
682                .object()
683                .map_err(|e| Error::Other(format!("{e}")))?
684                .into_tree();
685            if new_tree.iter().count() > 0 {
686                editor
687                    .upsert(name, gix::objs::tree::EntryKind::Tree, new_oid)
688                    .map_err(|e| Error::Other(format!("{e}")))?;
689            } else {
690                let _ = editor.remove(name);
691            }
692        }
693    }
694
695    Ok(editor
696        .write()
697        .map_err(|e| Error::Other(format!("{e}")))?
698        .detach())
699}
700
701/// Merge a [`TreeDir`] structure into an existing tree.
702///
703/// Existing entries not present in `dir` are preserved.
704/// Entries in `dir` overwrite existing entries with the same name.
705fn merge_dir_into_tree(
706    repo: &gix::Repository,
707    dir: &TreeDir,
708    existing_oid: gix::ObjectId,
709) -> Result<gix::ObjectId> {
710    let mut editor = repo
711        .edit_tree(existing_oid)
712        .map_err(|e| Error::Other(format!("{e}")))?;
713
714    for (name, content) in &dir.files {
715        let blob_oid: gix::ObjectId = repo
716            .write_blob(content)
717            .map_err(|e| Error::Other(format!("{e}")))?
718            .into();
719        editor
720            .upsert(name, gix::objs::tree::EntryKind::Blob, blob_oid)
721            .map_err(|e| Error::Other(format!("{e}")))?;
722    }
723
724    let existing_tree = existing_oid
725        .attach(repo)
726        .object()
727        .map_err(|e| Error::Other(format!("{e}")))?
728        .into_tree();
729    for (name, child_dir) in &dir.dirs {
730        let existing_child_oid = existing_tree.iter().find_map(|e| {
731            let e = e.ok()?;
732            if e.filename().to_str_lossy() == *name && e.mode().is_tree() {
733                Some(e.object_id())
734            } else {
735                None
736            }
737        });
738
739        let child_oid = if let Some(existing_child) = existing_child_oid {
740            merge_dir_into_tree(repo, child_dir, existing_child)?
741        } else {
742            build_dir(repo, child_dir)?
743        };
744        editor
745            .upsert(name, gix::objs::tree::EntryKind::Tree, child_oid)
746            .map_err(|e| Error::Other(format!("{e}")))?;
747    }
748
749    Ok(editor
750        .write()
751        .map_err(|e| Error::Other(format!("{e}")))?
752        .detach())
753}
754
755/// Prune a serialized tree by dropping entries older than the cutoff.
756///
757/// Returns the OID of the new (possibly smaller) tree. If the tree would
758/// be unchanged, the same OID is returned.
759///
760/// # Parameters
761///
762/// - `repo`: the Git repository
763/// - `tree_oid`: the root tree to prune
764/// - `rules`: the prune rules to apply
765/// - `db`: the metadata store (for potential future use by prune helpers)
766///
767/// # Errors
768///
769/// Returns an error if Git object reads/writes fail or cutoff parsing fails.
770pub fn prune_tree(
771    repo: &gix::Repository,
772    tree_oid: gix::ObjectId,
773    rules: &PruneRules,
774    db: &Store,
775    now_ms: i64,
776) -> Result<gix::ObjectId> {
777    let cutoff_ms = prune::parse_since_to_cutoff_ms(&rules.since, now_ms)?;
778    let min_size = rules.min_size.unwrap_or(0);
779
780    let tree = tree_oid
781        .attach(repo)
782        .object()
783        .map_err(|e| Error::Other(format!("{e}")))?
784        .into_tree();
785    let mut editor = repo
786        .empty_tree()
787        .edit()
788        .map_err(|e| Error::Other(format!("{e}")))?;
789
790    for entry_result in tree.iter() {
791        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
792        let name = entry.filename().to_str_lossy().to_string();
793
794        if name == "project" {
795            editor
796                .upsert(&name, entry.mode().kind(), entry.object_id())
797                .map_err(|e| Error::Other(format!("{e}")))?;
798            continue;
799        }
800
801        if entry.mode().is_tree() {
802            let subtree_oid = entry.object_id();
803
804            // Check min-size
805            if min_size > 0 {
806                let size = prune::compute_tree_size_for(repo, subtree_oid)?;
807                if size < min_size {
808                    editor
809                        .upsert(&name, entry.mode().kind(), subtree_oid)
810                        .map_err(|e| Error::Other(format!("{e}")))?;
811                    continue;
812                }
813            }
814
815            let pruned_oid = prune_target_type_tree(repo, subtree_oid, cutoff_ms, min_size, db)?;
816            let pruned_tree = pruned_oid
817                .attach(repo)
818                .object()
819                .map_err(|e| Error::Other(format!("{e}")))?
820                .into_tree();
821            if pruned_tree.iter().count() > 0 {
822                editor
823                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
824                    .map_err(|e| Error::Other(format!("{e}")))?;
825            }
826        } else {
827            editor
828                .upsert(&name, entry.mode().kind(), entry.object_id())
829                .map_err(|e| Error::Other(format!("{e}")))?;
830        }
831    }
832
833    Ok(editor
834        .write()
835        .map_err(|e| Error::Other(format!("{e}")))?
836        .detach())
837}
838
839fn prune_target_type_tree(
840    repo: &gix::Repository,
841    tree_oid: gix::ObjectId,
842    cutoff_ms: i64,
843    min_size: u64,
844    db: &Store,
845) -> Result<gix::ObjectId> {
846    let tree = tree_oid
847        .attach(repo)
848        .object()
849        .map_err(|e| Error::Other(format!("{e}")))?
850        .into_tree();
851    let mut editor = repo
852        .empty_tree()
853        .edit()
854        .map_err(|e| Error::Other(format!("{e}")))?;
855
856    for entry_result in tree.iter() {
857        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
858        let name = entry.filename().to_str_lossy().to_string();
859
860        if entry.mode().is_tree() {
861            let subtree_oid = entry.object_id();
862            let pruned_oid = prune_subtree_recursive(repo, subtree_oid, cutoff_ms, min_size, db)?;
863            let pruned_tree = pruned_oid
864                .attach(repo)
865                .object()
866                .map_err(|e| Error::Other(format!("{e}")))?
867                .into_tree();
868            if pruned_tree.iter().count() > 0 {
869                editor
870                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
871                    .map_err(|e| Error::Other(format!("{e}")))?;
872            }
873        } else {
874            editor
875                .upsert(&name, entry.mode().kind(), entry.object_id())
876                .map_err(|e| Error::Other(format!("{e}")))?;
877        }
878    }
879
880    Ok(editor
881        .write()
882        .map_err(|e| Error::Other(format!("{e}")))?
883        .detach())
884}
885
886fn prune_subtree_recursive(
887    repo: &gix::Repository,
888    tree_oid: gix::ObjectId,
889    cutoff_ms: i64,
890    _min_size: u64,
891    _db: &Store,
892) -> Result<gix::ObjectId> {
893    let tree = tree_oid
894        .attach(repo)
895        .object()
896        .map_err(|e| Error::Other(format!("{e}")))?
897        .into_tree();
898    let mut editor = repo
899        .empty_tree()
900        .edit()
901        .map_err(|e| Error::Other(format!("{e}")))?;
902
903    for entry_result in tree.iter() {
904        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
905        let name = entry.filename().to_str_lossy().to_string();
906
907        if entry.mode().is_tree() {
908            if name == "__list" {
909                let list_tree_oid = entry.object_id();
910                let pruned_oid = prune_list_tree(repo, list_tree_oid, cutoff_ms)?;
911                let pruned_tree = pruned_oid
912                    .attach(repo)
913                    .object()
914                    .map_err(|e| Error::Other(format!("{e}")))?
915                    .into_tree();
916                if pruned_tree.iter().count() > 0 {
917                    editor
918                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
919                        .map_err(|e| Error::Other(format!("{e}")))?;
920                }
921            } else if name == "__tombstones" {
922                let tomb_tree_oid = entry.object_id();
923                let pruned_oid = prune_tombstone_tree(repo, tomb_tree_oid, cutoff_ms)?;
924                let pruned_tree = pruned_oid
925                    .attach(repo)
926                    .object()
927                    .map_err(|e| Error::Other(format!("{e}")))?
928                    .into_tree();
929                if pruned_tree.iter().count() > 0 {
930                    editor
931                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
932                        .map_err(|e| Error::Other(format!("{e}")))?;
933                }
934            } else {
935                let subtree_oid = entry.object_id();
936                let pruned_oid =
937                    prune_subtree_recursive(repo, subtree_oid, cutoff_ms, _min_size, _db)?;
938                let pruned_tree = pruned_oid
939                    .attach(repo)
940                    .object()
941                    .map_err(|e| Error::Other(format!("{e}")))?
942                    .into_tree();
943                if pruned_tree.iter().count() > 0 {
944                    editor
945                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
946                        .map_err(|e| Error::Other(format!("{e}")))?;
947                }
948            }
949        } else {
950            editor
951                .upsert(&name, entry.mode().kind(), entry.object_id())
952                .map_err(|e| Error::Other(format!("{e}")))?;
953        }
954    }
955
956    Ok(editor
957        .write()
958        .map_err(|e| Error::Other(format!("{e}")))?
959        .detach())
960}
961
962fn prune_list_tree(
963    repo: &gix::Repository,
964    tree_oid: gix::ObjectId,
965    cutoff_ms: i64,
966) -> Result<gix::ObjectId> {
967    let tree = tree_oid
968        .attach(repo)
969        .object()
970        .map_err(|e| Error::Other(format!("{e}")))?
971        .into_tree();
972    let mut editor = repo
973        .empty_tree()
974        .edit()
975        .map_err(|e| Error::Other(format!("{e}")))?;
976
977    for entry_result in tree.iter() {
978        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
979        let name = entry.filename().to_str_lossy().to_string();
980        // Entry names are formatted as "{timestamp_ms}-{hash5}"
981        if let Some((ts_str, _)) = name.split_once('-') {
982            if let Ok(ts) = ts_str.parse::<i64>() {
983                if ts < cutoff_ms {
984                    continue; // Drop old entry
985                }
986            }
987        }
988        editor
989            .upsert(&name, entry.mode().kind(), entry.object_id())
990            .map_err(|e| Error::Other(format!("{e}")))?;
991    }
992
993    Ok(editor
994        .write()
995        .map_err(|e| Error::Other(format!("{e}")))?
996        .detach())
997}
998
999fn prune_tombstone_tree(
1000    repo: &gix::Repository,
1001    tree_oid: gix::ObjectId,
1002    cutoff_ms: i64,
1003) -> Result<gix::ObjectId> {
1004    let tree = tree_oid
1005        .attach(repo)
1006        .object()
1007        .map_err(|e| Error::Other(format!("{e}")))?
1008        .into_tree();
1009    let mut editor = repo
1010        .empty_tree()
1011        .edit()
1012        .map_err(|e| Error::Other(format!("{e}")))?;
1013
1014    for entry_result in tree.iter() {
1015        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
1016        let name = entry.filename().to_str_lossy().to_string();
1017
1018        if entry.mode().is_tree() {
1019            let subtree_oid = entry.object_id();
1020            let pruned_oid = prune_tombstone_tree(repo, subtree_oid, cutoff_ms)?;
1021            let pruned_tree = pruned_oid
1022                .attach(repo)
1023                .object()
1024                .map_err(|e| Error::Other(format!("{e}")))?
1025                .into_tree();
1026            if pruned_tree.iter().count() > 0 {
1027                editor
1028                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
1029                    .map_err(|e| Error::Other(format!("{e}")))?;
1030            }
1031        } else if entry.mode().is_blob() && name == "__deleted" {
1032            let blob = entry
1033                .object_id()
1034                .attach(repo)
1035                .object()
1036                .map_err(|e| Error::Other(format!("{e}")))?
1037                .into_blob();
1038            if let Ok(content) = std::str::from_utf8(&blob.data) {
1039                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
1040                    if let Some(ts) = parsed.get("timestamp").and_then(serde_json::Value::as_i64) {
1041                        if ts < cutoff_ms {
1042                            continue; // Drop old tombstone
1043                        }
1044                    }
1045                }
1046            }
1047            editor
1048                .upsert(&name, entry.mode().kind(), entry.object_id())
1049                .map_err(|e| Error::Other(format!("{e}")))?;
1050        } else {
1051            editor
1052                .upsert(&name, entry.mode().kind(), entry.object_id())
1053                .map_err(|e| Error::Other(format!("{e}")))?;
1054        }
1055    }
1056
1057    Ok(editor
1058        .write()
1059        .map_err(|e| Error::Other(format!("{e}")))?
1060        .detach())
1061}
1062
1063/// Count keys in original and pruned trees to produce stats.
1064///
1065/// Returns `(keys_dropped, keys_retained)`.
1066///
1067/// # Errors
1068///
1069/// Returns an error if Git object reads fail.
1070pub fn count_prune_stats(
1071    repo: &gix::Repository,
1072    original_oid: gix::ObjectId,
1073    pruned_oid: gix::ObjectId,
1074) -> Result<(u64, u64)> {
1075    let mut original_count = 0u64;
1076    count_all_blobs(repo, original_oid, &mut original_count)?;
1077
1078    let mut pruned_count = 0u64;
1079    count_all_blobs(repo, pruned_oid, &mut pruned_count)?;
1080
1081    let dropped = original_count.saturating_sub(pruned_count);
1082    Ok((dropped, pruned_count))
1083}
1084
1085fn count_all_blobs(repo: &gix::Repository, tree_oid: gix::ObjectId, count: &mut u64) -> Result<()> {
1086    let tree = tree_oid
1087        .attach(repo)
1088        .object()
1089        .map_err(|e| Error::Other(format!("{e}")))?
1090        .into_tree();
1091    for entry_result in tree.iter() {
1092        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
1093        if entry.mode().is_blob() {
1094            *count += 1;
1095        } else if entry.mode().is_tree() {
1096            count_all_blobs(repo, entry.object_id(), count)?;
1097        }
1098    }
1099    Ok(())
1100}