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.
451#[cfg(feature = "internal")]
452pub fn build_filtered_tree(
453    repo: &gix::Repository,
454    metadata_entries: &[SerializableEntry],
455    tombstone_entries: &[TombstoneRecord],
456    set_tombstone_entries: &[SetTombstoneRecord],
457    list_tombstone_entries: &[ListTombstoneRecord],
458) -> Result<gix::ObjectId> {
459    build_tree(
460        repo,
461        metadata_entries,
462        tombstone_entries,
463        set_tombstone_entries,
464        list_tombstone_entries,
465        None,
466        None,
467    )
468}
469
470/// Build a complete Git tree from all metadata entries.
471///
472/// When `existing_tree_oid` and `dirty_target_bases` are provided, only entries
473/// belonging to dirty targets are processed; unchanged subtrees are reused
474/// from the existing tree by OID (incremental mode).
475fn build_tree(
476    repo: &gix::Repository,
477    metadata_entries: &[SerializableEntry],
478    tombstone_entries: &[TombstoneRecord],
479    set_tombstone_entries: &[SetTombstoneRecord],
480    list_tombstone_entries: &[ListTombstoneRecord],
481    existing_tree_oid: Option<gix::ObjectId>,
482    dirty_target_bases: Option<&BTreeSet<String>>,
483) -> Result<gix::ObjectId> {
484    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
485
486    for e in metadata_entries {
487        let target = if e.target_type == TargetType::Project {
488            Target::parse("project")?
489        } else {
490            Target::parse(&format!("{}:{}", e.target_type, e.target_value))?
491        };
492
493        // Skip entries for clean targets -- their subtrees will be reused
494        if let Some(dirty) = dirty_target_bases {
495            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
496                continue;
497            }
498        }
499
500        match e.value_type {
501            ValueType::String => {
502                let full_path = tree_paths::tree_path(&target, &e.key)?;
503                if e.is_git_ref {
504                    let oid = gix::ObjectId::from_hex(e.value.as_bytes())
505                        .map_err(|e| Error::Other(format!("{e}")))?;
506                    let blob = oid
507                        .attach(repo)
508                        .object()
509                        .map_err(|e| Error::Other(format!("{e}")))?
510                        .into_blob();
511                    files.insert(full_path, blob.data.clone());
512                } else {
513                    let raw_value: String = match serde_json::from_str(&e.value) {
514                        Ok(s) => s,
515                        Err(_) => e.value.clone(),
516                    };
517                    files.insert(full_path, raw_value.into_bytes());
518                }
519            }
520            ValueType::List => {
521                let list_entries =
522                    parse_entries(&e.value).map_err(|e| Error::InvalidValue(format!("{e}")))?;
523                let list_dir_path = tree_paths::list_dir_path(&target, &e.key)?;
524                for entry in list_entries {
525                    let entry_name = make_entry_name(&entry);
526                    let full_path = format!("{list_dir_path}/{entry_name}");
527                    files.insert(full_path, entry.value.into_bytes());
528                }
529            }
530            ValueType::Set => {
531                let members: Vec<String> = serde_json::from_str(&e.value)
532                    .map_err(|e| Error::InvalidValue(format!("failed to decode set value: {e}")))?;
533                let set_dir_path = tree_paths::set_dir_path(&target, &e.key)?;
534                for member in members {
535                    let member_id = crate::types::set_member_id(&member);
536                    let full_path = format!("{set_dir_path}/{member_id}");
537                    files.insert(full_path, member.into_bytes());
538                }
539            }
540        }
541    }
542
543    for record in tombstone_entries {
544        let target = if record.target_type == TargetType::Project {
545            Target::parse("project")?
546        } else {
547            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
548        };
549
550        if let Some(dirty) = dirty_target_bases {
551            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
552                continue;
553            }
554        }
555
556        let full_path = tree_paths::tombstone_path(&target, &record.key)?;
557        let payload = serde_json::to_vec(&Tombstone {
558            timestamp: record.timestamp,
559            email: record.email.clone(),
560        })?;
561        files.insert(full_path, payload);
562    }
563
564    for record in set_tombstone_entries {
565        let target = if record.target_type == TargetType::Project {
566            Target::parse("project")?
567        } else {
568            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
569        };
570
571        if let Some(dirty) = dirty_target_bases {
572            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
573                continue;
574            }
575        }
576
577        let full_path =
578            tree_paths::set_member_tombstone_path(&target, &record.key, &record.member_id)?;
579        files.insert(full_path, record.value.as_bytes().to_vec());
580    }
581
582    for record in list_tombstone_entries {
583        let target = if record.target_type == TargetType::Project {
584            Target::parse("project")?
585        } else {
586            Target::parse(&format!("{}:{}", record.target_type, record.target_value))?
587        };
588
589        if let Some(dirty) = dirty_target_bases {
590            if !dirty.contains(&tree_paths::tree_base_path(&target)) {
591                continue;
592            }
593        }
594
595        let full_path =
596            tree_paths::list_entry_tombstone_path(&target, &record.key, &record.entry_name)?;
597        let payload = serde_json::to_vec(&Tombstone {
598            timestamp: record.timestamp,
599            email: record.email.clone(),
600        })?;
601        files.insert(full_path, payload);
602    }
603
604    // Build nested tree, reusing unchanged subtrees from existing tree
605    if let (Some(existing_oid), Some(dirty_bases)) = (existing_tree_oid, dirty_target_bases) {
606        build_tree_incremental(repo, existing_oid, &files, dirty_bases)
607    } else {
608        build_tree_from_paths(repo, &files)
609    }
610}
611
612/// Incrementally build a tree by patching an existing tree.
613///
614/// Only dirty target subtrees are rebuilt from `files`; all other subtrees
615/// are reused from the existing tree by OID.
616fn build_tree_incremental(
617    repo: &gix::Repository,
618    existing_tree_oid: gix::ObjectId,
619    files: &BTreeMap<String, Vec<u8>>,
620    dirty_target_bases: &BTreeSet<String>,
621) -> Result<gix::ObjectId> {
622    // Step 1: Remove dirty target subtrees from existing tree
623    let cleaned_oid = remove_subtrees(repo, existing_tree_oid, dirty_target_bases)?;
624
625    // Step 2: Build TreeDir from dirty files only
626    let mut root = TreeDir::default();
627    for (path, content) in files {
628        let parts: Vec<&str> = path.split('/').collect();
629        insert_path(&mut root, &parts, content.clone());
630    }
631
632    // Step 3: Merge new content into cleaned tree
633    merge_dir_into_tree(repo, &root, cleaned_oid)
634}
635
636/// Remove subtrees at specific paths from an existing tree.
637fn remove_subtrees(
638    repo: &gix::Repository,
639    tree_oid: gix::ObjectId,
640    paths: &BTreeSet<String>,
641) -> Result<gix::ObjectId> {
642    let mut grouped: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
643    let mut direct_removes: BTreeSet<String> = BTreeSet::new();
644
645    for path in paths {
646        if let Some((first, rest)) = path.split_once('/') {
647            grouped
648                .entry(first.to_string())
649                .or_default()
650                .insert(rest.to_string());
651        } else {
652            direct_removes.insert(path.clone());
653        }
654    }
655
656    let mut editor = repo
657        .edit_tree(tree_oid)
658        .map_err(|e| Error::Other(format!("{e}")))?;
659
660    for name in &direct_removes {
661        let _ = editor.remove(name);
662    }
663
664    // For grouped paths, recurse into subtrees
665    let tree = tree_oid
666        .attach(repo)
667        .object()
668        .map_err(|e| Error::Other(format!("{e}")))?
669        .into_tree();
670    for (name, sub_paths) in &grouped {
671        let entry = tree.iter().find_map(|e| {
672            let e = e.ok()?;
673            if e.filename().to_str_lossy() == *name && e.mode().is_tree() {
674                Some(e.object_id())
675            } else {
676                None
677            }
678        });
679        if let Some(subtree_oid) = entry {
680            let new_oid = remove_subtrees(repo, subtree_oid, sub_paths)?;
681            let new_tree = new_oid
682                .attach(repo)
683                .object()
684                .map_err(|e| Error::Other(format!("{e}")))?
685                .into_tree();
686            if new_tree.iter().count() > 0 {
687                editor
688                    .upsert(name, gix::objs::tree::EntryKind::Tree, new_oid)
689                    .map_err(|e| Error::Other(format!("{e}")))?;
690            } else {
691                let _ = editor.remove(name);
692            }
693        }
694    }
695
696    Ok(editor
697        .write()
698        .map_err(|e| Error::Other(format!("{e}")))?
699        .detach())
700}
701
702/// Merge a [`TreeDir`] structure into an existing tree.
703///
704/// Existing entries not present in `dir` are preserved.
705/// Entries in `dir` overwrite existing entries with the same name.
706fn merge_dir_into_tree(
707    repo: &gix::Repository,
708    dir: &TreeDir,
709    existing_oid: gix::ObjectId,
710) -> Result<gix::ObjectId> {
711    let mut editor = repo
712        .edit_tree(existing_oid)
713        .map_err(|e| Error::Other(format!("{e}")))?;
714
715    for (name, content) in &dir.files {
716        let blob_oid: gix::ObjectId = repo
717            .write_blob(content)
718            .map_err(|e| Error::Other(format!("{e}")))?
719            .into();
720        editor
721            .upsert(name, gix::objs::tree::EntryKind::Blob, blob_oid)
722            .map_err(|e| Error::Other(format!("{e}")))?;
723    }
724
725    let existing_tree = existing_oid
726        .attach(repo)
727        .object()
728        .map_err(|e| Error::Other(format!("{e}")))?
729        .into_tree();
730    for (name, child_dir) in &dir.dirs {
731        let existing_child_oid = existing_tree.iter().find_map(|e| {
732            let e = e.ok()?;
733            if e.filename().to_str_lossy() == *name && e.mode().is_tree() {
734                Some(e.object_id())
735            } else {
736                None
737            }
738        });
739
740        let child_oid = if let Some(existing_child) = existing_child_oid {
741            merge_dir_into_tree(repo, child_dir, existing_child)?
742        } else {
743            build_dir(repo, child_dir)?
744        };
745        editor
746            .upsert(name, gix::objs::tree::EntryKind::Tree, child_oid)
747            .map_err(|e| Error::Other(format!("{e}")))?;
748    }
749
750    Ok(editor
751        .write()
752        .map_err(|e| Error::Other(format!("{e}")))?
753        .detach())
754}
755
756/// Prune a serialized tree by dropping entries older than the cutoff.
757///
758/// Returns the OID of the new (possibly smaller) tree. If the tree would
759/// be unchanged, the same OID is returned.
760///
761/// # Parameters
762///
763/// - `repo`: the Git repository
764/// - `tree_oid`: the root tree to prune
765/// - `rules`: the prune rules to apply
766/// - `db`: the metadata store (for potential future use by prune helpers)
767///
768/// # Errors
769///
770/// Returns an error if Git object reads/writes fail or cutoff parsing fails.
771pub fn prune_tree(
772    repo: &gix::Repository,
773    tree_oid: gix::ObjectId,
774    rules: &PruneRules,
775    db: &Store,
776    now_ms: i64,
777) -> Result<gix::ObjectId> {
778    let cutoff_ms = prune::parse_since_to_cutoff_ms(&rules.since, now_ms)?;
779    let min_size = rules.min_size.unwrap_or(0);
780
781    let tree = tree_oid
782        .attach(repo)
783        .object()
784        .map_err(|e| Error::Other(format!("{e}")))?
785        .into_tree();
786    let mut editor = repo
787        .empty_tree()
788        .edit()
789        .map_err(|e| Error::Other(format!("{e}")))?;
790
791    for entry_result in tree.iter() {
792        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
793        let name = entry.filename().to_str_lossy().to_string();
794
795        if name == "project" {
796            editor
797                .upsert(&name, entry.mode().kind(), entry.object_id())
798                .map_err(|e| Error::Other(format!("{e}")))?;
799            continue;
800        }
801
802        if entry.mode().is_tree() {
803            let subtree_oid = entry.object_id();
804
805            // Check min-size
806            if min_size > 0 {
807                let size = prune::compute_tree_size_for(repo, subtree_oid)?;
808                if size < min_size {
809                    editor
810                        .upsert(&name, entry.mode().kind(), subtree_oid)
811                        .map_err(|e| Error::Other(format!("{e}")))?;
812                    continue;
813                }
814            }
815
816            let pruned_oid = prune_target_type_tree(repo, subtree_oid, cutoff_ms, min_size, db)?;
817            let pruned_tree = pruned_oid
818                .attach(repo)
819                .object()
820                .map_err(|e| Error::Other(format!("{e}")))?
821                .into_tree();
822            if pruned_tree.iter().count() > 0 {
823                editor
824                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
825                    .map_err(|e| Error::Other(format!("{e}")))?;
826            }
827        } else {
828            editor
829                .upsert(&name, entry.mode().kind(), entry.object_id())
830                .map_err(|e| Error::Other(format!("{e}")))?;
831        }
832    }
833
834    Ok(editor
835        .write()
836        .map_err(|e| Error::Other(format!("{e}")))?
837        .detach())
838}
839
840fn prune_target_type_tree(
841    repo: &gix::Repository,
842    tree_oid: gix::ObjectId,
843    cutoff_ms: i64,
844    min_size: u64,
845    db: &Store,
846) -> Result<gix::ObjectId> {
847    let tree = tree_oid
848        .attach(repo)
849        .object()
850        .map_err(|e| Error::Other(format!("{e}")))?
851        .into_tree();
852    let mut editor = repo
853        .empty_tree()
854        .edit()
855        .map_err(|e| Error::Other(format!("{e}")))?;
856
857    for entry_result in tree.iter() {
858        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
859        let name = entry.filename().to_str_lossy().to_string();
860
861        if entry.mode().is_tree() {
862            let subtree_oid = entry.object_id();
863            let pruned_oid = prune_subtree_recursive(repo, subtree_oid, cutoff_ms, min_size, db)?;
864            let pruned_tree = pruned_oid
865                .attach(repo)
866                .object()
867                .map_err(|e| Error::Other(format!("{e}")))?
868                .into_tree();
869            if pruned_tree.iter().count() > 0 {
870                editor
871                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
872                    .map_err(|e| Error::Other(format!("{e}")))?;
873            }
874        } else {
875            editor
876                .upsert(&name, entry.mode().kind(), entry.object_id())
877                .map_err(|e| Error::Other(format!("{e}")))?;
878        }
879    }
880
881    Ok(editor
882        .write()
883        .map_err(|e| Error::Other(format!("{e}")))?
884        .detach())
885}
886
887fn prune_subtree_recursive(
888    repo: &gix::Repository,
889    tree_oid: gix::ObjectId,
890    cutoff_ms: i64,
891    _min_size: u64,
892    _db: &Store,
893) -> Result<gix::ObjectId> {
894    let tree = tree_oid
895        .attach(repo)
896        .object()
897        .map_err(|e| Error::Other(format!("{e}")))?
898        .into_tree();
899    let mut editor = repo
900        .empty_tree()
901        .edit()
902        .map_err(|e| Error::Other(format!("{e}")))?;
903
904    for entry_result in tree.iter() {
905        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
906        let name = entry.filename().to_str_lossy().to_string();
907
908        if entry.mode().is_tree() {
909            if name == "__list" {
910                let list_tree_oid = entry.object_id();
911                let pruned_oid = prune_list_tree(repo, list_tree_oid, cutoff_ms)?;
912                let pruned_tree = pruned_oid
913                    .attach(repo)
914                    .object()
915                    .map_err(|e| Error::Other(format!("{e}")))?
916                    .into_tree();
917                if pruned_tree.iter().count() > 0 {
918                    editor
919                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
920                        .map_err(|e| Error::Other(format!("{e}")))?;
921                }
922            } else if name == "__tombstones" {
923                let tomb_tree_oid = entry.object_id();
924                let pruned_oid = prune_tombstone_tree(repo, tomb_tree_oid, cutoff_ms)?;
925                let pruned_tree = pruned_oid
926                    .attach(repo)
927                    .object()
928                    .map_err(|e| Error::Other(format!("{e}")))?
929                    .into_tree();
930                if pruned_tree.iter().count() > 0 {
931                    editor
932                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
933                        .map_err(|e| Error::Other(format!("{e}")))?;
934                }
935            } else {
936                let subtree_oid = entry.object_id();
937                let pruned_oid =
938                    prune_subtree_recursive(repo, subtree_oid, cutoff_ms, _min_size, _db)?;
939                let pruned_tree = pruned_oid
940                    .attach(repo)
941                    .object()
942                    .map_err(|e| Error::Other(format!("{e}")))?
943                    .into_tree();
944                if pruned_tree.iter().count() > 0 {
945                    editor
946                        .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
947                        .map_err(|e| Error::Other(format!("{e}")))?;
948                }
949            }
950        } else {
951            editor
952                .upsert(&name, entry.mode().kind(), entry.object_id())
953                .map_err(|e| Error::Other(format!("{e}")))?;
954        }
955    }
956
957    Ok(editor
958        .write()
959        .map_err(|e| Error::Other(format!("{e}")))?
960        .detach())
961}
962
963fn prune_list_tree(
964    repo: &gix::Repository,
965    tree_oid: gix::ObjectId,
966    cutoff_ms: i64,
967) -> Result<gix::ObjectId> {
968    let tree = tree_oid
969        .attach(repo)
970        .object()
971        .map_err(|e| Error::Other(format!("{e}")))?
972        .into_tree();
973    let mut editor = repo
974        .empty_tree()
975        .edit()
976        .map_err(|e| Error::Other(format!("{e}")))?;
977
978    for entry_result in tree.iter() {
979        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
980        let name = entry.filename().to_str_lossy().to_string();
981        // Entry names are formatted as "{timestamp_ms}-{hash5}"
982        if let Some((ts_str, _)) = name.split_once('-') {
983            if let Ok(ts) = ts_str.parse::<i64>() {
984                if ts < cutoff_ms {
985                    continue; // Drop old entry
986                }
987            }
988        }
989        editor
990            .upsert(&name, entry.mode().kind(), entry.object_id())
991            .map_err(|e| Error::Other(format!("{e}")))?;
992    }
993
994    Ok(editor
995        .write()
996        .map_err(|e| Error::Other(format!("{e}")))?
997        .detach())
998}
999
1000fn prune_tombstone_tree(
1001    repo: &gix::Repository,
1002    tree_oid: gix::ObjectId,
1003    cutoff_ms: i64,
1004) -> Result<gix::ObjectId> {
1005    let tree = tree_oid
1006        .attach(repo)
1007        .object()
1008        .map_err(|e| Error::Other(format!("{e}")))?
1009        .into_tree();
1010    let mut editor = repo
1011        .empty_tree()
1012        .edit()
1013        .map_err(|e| Error::Other(format!("{e}")))?;
1014
1015    for entry_result in tree.iter() {
1016        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
1017        let name = entry.filename().to_str_lossy().to_string();
1018
1019        if entry.mode().is_tree() {
1020            let subtree_oid = entry.object_id();
1021            let pruned_oid = prune_tombstone_tree(repo, subtree_oid, cutoff_ms)?;
1022            let pruned_tree = pruned_oid
1023                .attach(repo)
1024                .object()
1025                .map_err(|e| Error::Other(format!("{e}")))?
1026                .into_tree();
1027            if pruned_tree.iter().count() > 0 {
1028                editor
1029                    .upsert(&name, gix::objs::tree::EntryKind::Tree, pruned_oid)
1030                    .map_err(|e| Error::Other(format!("{e}")))?;
1031            }
1032        } else if entry.mode().is_blob() && name == "__deleted" {
1033            let blob = entry
1034                .object_id()
1035                .attach(repo)
1036                .object()
1037                .map_err(|e| Error::Other(format!("{e}")))?
1038                .into_blob();
1039            if let Ok(content) = std::str::from_utf8(&blob.data) {
1040                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
1041                    if let Some(ts) = parsed.get("timestamp").and_then(serde_json::Value::as_i64) {
1042                        if ts < cutoff_ms {
1043                            continue; // Drop old tombstone
1044                        }
1045                    }
1046                }
1047            }
1048            editor
1049                .upsert(&name, entry.mode().kind(), entry.object_id())
1050                .map_err(|e| Error::Other(format!("{e}")))?;
1051        } else {
1052            editor
1053                .upsert(&name, entry.mode().kind(), entry.object_id())
1054                .map_err(|e| Error::Other(format!("{e}")))?;
1055        }
1056    }
1057
1058    Ok(editor
1059        .write()
1060        .map_err(|e| Error::Other(format!("{e}")))?
1061        .detach())
1062}
1063
1064/// Count keys in original and pruned trees to produce stats.
1065///
1066/// Returns `(keys_dropped, keys_retained)`.
1067///
1068/// # Errors
1069///
1070/// Returns an error if Git object reads fail.
1071pub fn count_prune_stats(
1072    repo: &gix::Repository,
1073    original_oid: gix::ObjectId,
1074    pruned_oid: gix::ObjectId,
1075) -> Result<(u64, u64)> {
1076    let mut original_count = 0u64;
1077    count_all_blobs(repo, original_oid, &mut original_count)?;
1078
1079    let mut pruned_count = 0u64;
1080    count_all_blobs(repo, pruned_oid, &mut pruned_count)?;
1081
1082    let dropped = original_count.saturating_sub(pruned_count);
1083    Ok((dropped, pruned_count))
1084}
1085
1086fn count_all_blobs(repo: &gix::Repository, tree_oid: gix::ObjectId, count: &mut u64) -> Result<()> {
1087    let tree = tree_oid
1088        .attach(repo)
1089        .object()
1090        .map_err(|e| Error::Other(format!("{e}")))?
1091        .into_tree();
1092    for entry_result in tree.iter() {
1093        let entry = entry_result.map_err(|e| Error::Other(format!("{e}")))?;
1094        if entry.mode().is_blob() {
1095            *count += 1;
1096        } else if entry.mode().is_tree() {
1097            count_all_blobs(repo, entry.object_id(), count)?;
1098        }
1099    }
1100    Ok(())
1101}