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
432#[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
470fn 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 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 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
612fn 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 let cleaned_oid = remove_subtrees(repo, existing_tree_oid, dirty_target_bases)?;
624
625 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 merge_dir_into_tree(repo, &root, cleaned_oid)
634}
635
636fn 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 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
702fn 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
756pub 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 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 if let Some((ts_str, _)) = name.split_once('-') {
983 if let Ok(ts) = ts_str.parse::<i64>() {
984 if ts < cutoff_ms {
985 continue; }
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; }
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
1064pub 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}