1use 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
30const MAX_COMMIT_CHANGES: usize = 1000;
32
33#[must_use]
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct SerializeOutput {
40 pub changes: usize,
42 pub refs_written: Vec<String>,
44 pub pruned: u64,
46}
47
48pub 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 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 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 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 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 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 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 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 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
408fn 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
432pub 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
469fn 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 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 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
611fn 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 let cleaned_oid = remove_subtrees(repo, existing_tree_oid, dirty_target_bases)?;
623
624 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 merge_dir_into_tree(repo, &root, cleaned_oid)
633}
634
635fn 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 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
701fn 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
755pub 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 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 if let Some((ts_str, _)) = name.split_once('-') {
982 if let Ok(ts) = ts_str.parse::<i64>() {
983 if ts < cutoff_ms {
984 continue; }
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; }
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
1063pub 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}