obsidian_backups/actions/backup_manager.rs
1//! `BackupManager` is responsible for managing backup operations within a Git-based
2//! storage mechanism. It provides functionality to initialize a backup repository,
3//! create new backups, list existing backups, restore backups, and export backups
4//! as compressed archives.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use obsidian_backups::BackupManager;
10//!
11//! let store_dir = "./backup_store";
12//! let working_dir = "./my_data";
13//! let backup_manager = BackupManager::new(store_dir, working_dir)
14//! .expect("Failed to initialize BackupManager");
15//! ```
16//!
17//! # Fields
18//!
19//! * `repository` - The Git repository used for managing backups.
20use crate::data::backup_item::BackupItem;
21use crate::data::modified_file::ModifiedFile;
22use crate::log_stub::*;
23use anyhow::{Result, anyhow};
24use git2::{Oid, Repository, RepositoryInitOptions};
25use ignore::gitignore::{Gitignore, GitignoreBuilder};
26#[cfg(feature = "zip")]
27use sevenz_rust2::{ArchiveWriter, encoder_options};
28use std::fs;
29use std::path::Path;
30
31/// `BackupManager` is a struct responsible for managing backup operations.
32///
33/// This struct serves as a core component for creating, storing, and retrieving backups
34/// in the system. It encapsulates the `Repository` where backup data is managed,
35/// providing an interface to interact with the underlying repository for backup-related tasks.
36///
37/// # Fields
38/// - `repository`: The repository where backup data is stored and managed.
39///
40/// # Example
41/// ```rust
42/// use obsidian_backups::BackupManager;
43///
44/// let backup_manager = BackupManager::new("./backup_store", "./my_data")
45/// .expect("Failed to create BackupManager");
46/// ```
47pub struct BackupManager {
48 repository: Repository,
49 ignore_matcher: Option<Gitignore>,
50}
51
52impl BackupManager {
53 /// Helper function to check if a path should be excluded from backups using ignore patterns in `exclude.obak`
54 fn should_exclude(&self, path: &Path, is_dir: bool) -> bool {
55 // Always skip the Git metadata directory and common junk files
56 if let Some(name) = path.file_name().and_then(|n| n.to_str())
57 && (name == ".git"
58 || matches!(
59 name,
60 ".DS_Store"
61 | "Thumbs.db"
62 | "desktop.ini"
63 | ".Spotlight-V100"
64 | ".Trashes"
65 | "ehthumbs.db"
66 | "ehthumbs_vista.db"
67 | "$RECYCLE.BIN"
68 )
69 || name.starts_with("~$")
70 || name.ends_with(".tmp")
71 || name.ends_with(".swp")
72 || name.ends_with("~")
73 || name == "__pycache__")
74 {
75 return true;
76 }
77
78 if let Some(matcher) = &self.ignore_matcher {
79 let m = matcher.matched(path, is_dir);
80 if m.is_ignore() {
81 return true;
82 }
83 }
84 false
85 }
86
87 /// Helper function to recursively add files from a directory to the git index
88 #[allow(clippy::only_used_in_recursion)]
89 fn add_directory_to_index(
90 &self,
91 index: &mut git2::Index,
92 dir_path: &Path,
93 base_path: &Path,
94 ) -> Result<()> {
95 for entry in fs::read_dir(dir_path)? {
96 let entry = entry?;
97 let path = entry.path();
98
99 let file_type = entry.file_type()?;
100
101 // Skip excluded files and directories
102 if self.should_exclude(&path, file_type.is_dir()) {
103 debug!("Skipping excluded path: {:?}", path);
104 continue;
105 }
106
107 if file_type.is_dir() {
108 // Recursively add subdirectory
109 self.add_directory_to_index(index, &path, base_path)?;
110 } else if file_type.is_file() {
111 // Calculate relative path from base_path
112 let relative_path = path.strip_prefix(base_path)?;
113 debug!("Adding file to index: {:?}", relative_path);
114 index.add_path(relative_path)?;
115 }
116 }
117 Ok(())
118 }
119
120 /// Creates a new instance of `BackupManager`.
121 ///
122 /// This function initializes a `BackupManager` by setting up a new Git repository
123 /// in the specified `store_directory` with the specified `working_directory` as
124 /// the working directory for the repository.
125 ///
126 /// # Arguments
127 ///
128 /// * `store_directory` - A reference to a path where the repository data will be stored.
129 /// * `working_directory` - A reference to a path that will serve as the working directory for the repository.
130 ///
131 /// Both arguments accept types that can be converted into a `PathBuf`.
132 ///
133 /// # Returns
134 ///
135 /// Returns `Ok(Self)` with the initialized `BackupManager` if successful, or an error
136 /// if the Git repository initialization fails.
137 ///
138 /// # Logging
139 ///
140 /// * Logs an informational message when starting and successfully completing the initialization process.
141 /// * Logs debug messages showing the resolved paths and steps during initialization.
142 ///
143 /// # Errors
144 ///
145 /// Returns an error if repository initialization fails. This typically occurs
146 /// due to invalid paths, insufficient permissions, or issues with the Git backend.
147 ///
148 /// # Example
149 ///
150 /// ```
151 /// use obsidian_backup_system::BackupManager;
152 ///
153 /// let manager = BackupManager::new("./backup_store", "./my_data")
154 /// .expect("Failed to initialize BackupManager");
155 /// ```
156 ///
157 /// Note: Ensure that the provided paths are valid and writable for the process.
158 pub fn new(
159 store_directory: impl AsRef<Path>,
160 working_directory: impl AsRef<Path>,
161 ) -> Result<Self> {
162 info!("Initializing BackupManager");
163
164 // Convert to absolute paths to avoid path resolution issues
165 let store_directory = if store_directory.as_ref().is_absolute() {
166 store_directory.as_ref().to_path_buf()
167 } else {
168 std::env::current_dir()?.join(store_directory.as_ref())
169 };
170
171 let working_directory = if working_directory.as_ref().is_absolute() {
172 working_directory.as_ref().to_path_buf()
173 } else {
174 std::env::current_dir()?.join(working_directory.as_ref())
175 };
176
177 debug!("Store directory (absolute): {:?}", store_directory);
178 debug!("Working directory (absolute): {:?}", working_directory);
179
180 let mut opts = RepositoryInitOptions::new();
181 opts.workdir_path(&working_directory);
182 opts.no_dotgit_dir(true);
183
184 debug!("Initializing git repository with options");
185 let repository = Repository::init_opts(&store_directory, &opts)?;
186
187 info!("BackupManager initialized successfully");
188 Ok(Self {
189 repository,
190 ignore_matcher: None,
191 })
192 }
193
194 /// Sets up a `.gitignore`-style ignore file for the repository using the provided file path.
195 /// This function configures an ignore matcher to exclude specified paths or patterns.
196 ///
197 /// # Arguments
198 /// * `ignore_file` - A path-like object referencing the ignore file to process. The file should follow `.gitignore` syntax.
199 ///
200 /// # Returns
201 /// * `Result<()>` - Returns `Ok(())` if the ignore matcher is successfully built and configured.
202 /// Returns an error if the ignore matcher cannot be built or if the ignore file causes an issue.
203 ///
204 /// # Behavior
205 /// 1. Locates the working directory of the repository. Defaults to `./` if the repository has no working directory.
206 /// 2. Initializes a `GitignoreBuilder` using the repository's working directory.
207 /// 3. Checks whether the provided ignore file exists:
208 /// - If the file exists, attempts to add it to the builder. Logs a warning if there's an issue while adding the file.
209 /// 4. Attempts to construct the ignore matcher from the builder:
210 /// - If successful, stores the ignore matcher in `self.ignore_matcher`.
211 /// - If unsuccessful, logs an error message and returns an error.
212 ///
213 /// # Errors
214 /// * Returns an error if:
215 /// - The ignore file could not be properly parsed or added.
216 /// - The ignore matcher fails to build successfully.
217 ///
218 /// # Logging
219 /// - Logs a warning message if the function fails to add the ignore file to the builder.
220 /// - Logs an error message if the function fails to build the ignore matcher.
221 ///
222 /// # Example Usage
223 /// ```rust
224 /// use std::path::Path;
225 /// use obsidian_backup_system::BackupManager;
226 ///
227 /// let mut backup_manager = BackupManager::new("./backup_store", "./my_data")?;
228 /// backup_manager.setup_ignore_file(".my_ignore_file")?;
229 /// ```
230 pub fn setup_ignore_file(&mut self, ignore_file: impl AsRef<Path>) -> Result<()> {
231 let working_directory = self.repository.workdir().unwrap_or(Path::new("./"));
232 let mut builder = GitignoreBuilder::new(working_directory);
233
234 let ignore_file = ignore_file.as_ref();
235
236 if ignore_file.exists()
237 && let Some(e) = builder.add(ignore_file)
238 {
239 warn!("Failed to add ignore file {ignore_file:?}: {e}");
240 }
241 match builder.build() {
242 Ok(ignore_matcher) => {
243 self.ignore_matcher = Some(ignore_matcher);
244 Ok(())
245 }
246 Err(e) => {
247 error!("Failed to build ignore matcher: {e}");
248 Err(anyhow!("Failed to build ignore matcher: {e}"))
249 }
250 }
251 }
252
253 /// Lists all backup items available in the repository.
254 ///
255 /// The method traverses the commit history of the repository, collects metadata
256 /// for each commit, and returns a list of items representing the backup points. Each
257 /// item includes the commit ID, timestamp, and commit message.
258 ///
259 /// # Process
260 /// - Logs an informational message indicating the start of the operation.
261 /// - Initializes a revision walk over the repository to retrieve commit objects.
262 /// - Iterates through each commit, retrieves its metadata, and constructs a `BackupItem` instance.
263 /// - Each created `BackupItem` is logged at the trace level, and the total count is logged at the end.
264 ///
265 /// # Returns
266 /// A `Result` containing a vector of `BackupItem` instances if the operation succeeds, or an error
267 /// if any repository operation fails.
268 ///
269 /// # Errors
270 /// Returns an error if:
271 /// - The revision walk initialization fails.
272 /// - Retrieving an individual commit in the history fails.
273 /// - Any other repository-related operation encounters an error.
274 ///
275 /// # Logging
276 /// - Logs informational messages about the start and result of the operation.
277 /// - Logs debug messages about processing individual commits.
278 /// - Logs trace messages with details of each created `BackupItem`.
279 ///
280 /// # Example
281 /// ```
282 /// use obsidian_backup_system::BackupManager;
283 ///
284 /// let manager = BackupManager::new("./backup_store", "./my_data")
285 /// .expect("Failed to initialize BackupManager");
286 ///
287 /// match manager.list() {
288 /// Ok(backup_items) => {
289 /// for item in backup_items {
290 /// println!("Backup ID: {}, Timestamp: {}, Description: {}",
291 /// item.id, item.timestamp, item.description);
292 /// }
293 /// },
294 /// Err(e) => eprintln!("Error listing backup items: {}", e),
295 /// }
296 /// ```
297 ///
298 /// # Note
299 /// The method assumes that commit messages are UTF-8 encoded. If a commit has
300 /// no message, an empty string is used as the description.
301 ///
302 /// # Dependencies
303 /// - Requires the repository to be properly initialized and accessible.
304 /// - Relies on the `BackupItem` struct to hold commit metadata.
305 pub fn list(&self) -> Result<Vec<BackupItem>> {
306 info!("Listing backup items");
307 debug!("Starting revision walk");
308 let mut items = Vec::new();
309 let ids = self.list_ids()?;
310 debug!("Found {} commit IDs", ids.len());
311
312 for commit_id in ids {
313 debug!("Processing commit: {}", commit_id);
314 let oid = match Oid::from_str(&commit_id) {
315 Ok(oid) => oid,
316 Err(e) => {
317 warn!("Skipping invalid commit id {}: {}", commit_id, e);
318 continue;
319 }
320 };
321 match self.repository.find_commit(oid) {
322 Ok(commit) => {
323 let item = BackupItem {
324 id: commit_id,
325 timestamp: chrono::DateTime::from_timestamp_secs(commit.time().seconds())
326 .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC),
327 description: commit
328 .message()
329 .unwrap_or("No description was provided")
330 .to_string(),
331 };
332 trace!(
333 "Created backup item: id={}, timestamp={}, description={:?}",
334 item.id, item.timestamp, item.description
335 );
336 items.push(item);
337 }
338 Err(e) => {
339 warn!("Skipping missing or unreadable commit {}: {}", commit_id, e);
340 continue;
341 }
342 }
343 }
344
345 info!("Found {} backup items", items.len());
346 Ok(items)
347 }
348
349 fn list_ids(&self) -> Result<Vec<String>> {
350 let mut rev_walk = self.repository.revwalk()?;
351 // Try HEAD first; if it fails, fall back to any available reference target.
352 let mut pushed = false;
353 if let Ok(head) = self.repository.head()
354 && let Some(oid) = head.target()
355 && rev_walk.push(oid).is_ok()
356 {
357 pushed = true;
358 }
359 if !pushed && let Ok(refs) = self.repository.references() {
360 for r in refs {
361 if let Ok(r) = r
362 && let Some(oid) = r.target()
363 && rev_walk.push(oid).is_ok()
364 {
365 pushed = true;
366 break;
367 }
368 }
369 }
370 if !pushed {
371 // No references to walk; return empty list rather than erroring
372 return Ok(Vec::new());
373 }
374
375 let mut ids = Vec::new();
376 for oid in rev_walk.flatten() {
377 ids.push(oid.to_string());
378 }
379 Ok(ids)
380 }
381
382 /// Creates a backup by committing the current state of the repository.
383 ///
384 /// This method stages all changes, creates a commit with the given description, and returns the ID
385 /// of the newly created commit. If no description is provided, a default description of "No description
386 /// provided" is used. It also ensures proper handling for creating an initial commit if the repository
387 /// does not have an existing HEAD.
388 ///
389 /// # Arguments
390 ///
391 /// * `description` - An optional string containing a description for the backup commit.
392 ///
393 /// # Returns
394 ///
395 /// Returns a `Result<String>` which contains:
396 /// * On success: The ID of the newly created commit as a string.
397 /// * On failure: An error indicating the cause of the failure.
398 ///
399 /// # Errors
400 ///
401 /// This function will return an error if:
402 /// * There is an issue accessing or writing the repository index.
403 /// * There is an issue creating a new tree or finding the tree object in the repository.
404 /// * The repository signature (user name and email) is invalid or not set.
405 /// * The commit operation fails due to any Git-related error.
406 ///
407 /// # Logging
408 ///
409 /// This method emits the following log messages:
410 /// * `info` logs for the overall operation (`Creating backup`, `Backup created successfully`).
411 /// * `debug` logs for intermediate steps, such as getting the index, adding files, writing the tree, finding
412 /// parents, creating signatures, and creating the commit.
413 ///
414 /// # Example
415 ///
416 /// ```rust
417 /// use obsidian_backup_system::BackupManager;
418 ///
419 /// let manager = BackupManager::new("./backup_store", "./my_data")
420 /// .expect("Failed to initialize BackupManager");
421 ///
422 /// let description = Some("Backup before deployment".to_string());
423 /// match manager.backup(description) {
424 /// Ok(commit_id) => println!("Backup created with ID: {}", commit_id),
425 /// Err(e) => eprintln!("Failed to create backup: {}", e),
426 /// }
427 /// ```
428 ///
429 /// # Notes
430 ///
431 /// * This method assumes that the caller has already initialized the repository (`self.repository`) and has
432 /// proper permissions to write to it.
433 /// * If no HEAD exists (e.g., for an empty repository), it creates an initial commit without parent commits.
434 pub fn backup(&self, description: Option<String>) -> Result<String> {
435 info!("Creating backup with description: {:?}", description);
436
437 debug!("Getting repository index");
438 let mut index = self.repository.index()?;
439
440 // Get the working directory
441 let workdir = self
442 .repository
443 .workdir()
444 .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
445
446 debug!("Working directory: {:?}", workdir);
447
448 // Clear the index first to handle deleted files
449 debug!("Clearing index");
450 index.clear()?;
451
452 debug!("Adding all files from working directory to index");
453 self.add_directory_to_index(&mut index, workdir, workdir)?;
454
455 debug!("Writing index");
456 index.write()?;
457
458 debug!("Creating tree from index");
459 let tree_id = index.write_tree()?;
460 debug!("Tree created with ID: {}", tree_id);
461
462 let tree = self.repository.find_tree(tree_id)?;
463 let head = self.repository.head();
464
465 // Create and own the parent_commit outside the if scope
466 let parent_commit = if let Ok(head) = head {
467 debug!("Found existing HEAD, using as parent commit");
468 Some(head.peel_to_commit()?)
469 } else {
470 debug!("No existing HEAD found, creating initial commit");
471 None
472 };
473
474 // Build the parent's vector using references to the owned commit
475 let parents = match &parent_commit {
476 Some(commit) => {
477 debug!("Using parent commit: {}", commit.id());
478 vec![commit]
479 }
480 None => {
481 debug!("No parent commits");
482 vec![]
483 }
484 };
485
486 debug!("Getting repository signature");
487 let sig = self.repository.signature()?;
488 debug!(
489 "Signature: {} <{}>",
490 sig.name().unwrap_or("unknown"),
491 sig.email().unwrap_or("unknown")
492 );
493
494 debug!("Creating commit");
495 let commit_id = self.repository.commit(
496 Some("HEAD"),
497 &sig,
498 &sig,
499 description
500 .unwrap_or("No description provided".to_string())
501 .as_ref(),
502 &tree,
503 &parents,
504 )?;
505
506 info!("Backup created successfully with ID: {}", commit_id);
507 Ok(commit_id.to_string())
508 }
509
510 /// Restores a backup by its ID and checks out the associated commit.
511 ///
512 /// # Arguments
513 ///
514 /// * `backup_id` - A reference to a string that uniquely identifies the backup.
515 /// This ID is parsed as a git object ID.
516 ///
517 /// # Returns
518 ///
519 /// * `Result<()>` - Returns `Ok(())` if the backup was successfully restored,
520 /// or an error if the operation fails at any stage.
521 ///
522 /// # Process
523 ///
524 /// 1. The backup ID is parsed as a git object ID (OID).
525 /// 2. The associated git commit is retrieved using the OID.
526 /// 3. The commit's tree is accessed, and its contents are checked out in the repository.
527 /// 4. If the repository is configured with a working directory:
528 /// * The contents of the current working directory are removed.
529 /// * A new working directory is created.
530 /// * HEAD is checked out into the working directory.
531 /// 5. Logs are generated at various points to provide insights into the restoration process.
532 ///
533 /// # Logs
534 ///
535 /// * **Info** logs are used to indicate the start and successful completion of the restore operation.
536 /// * **Debug** logs provide detailed information about each step of the process, such as parsing the backup ID,
537 /// working with git objects, and modifying the working directory.
538 /// * **Warning** logs occur if no working directory is configured for the repository.
539 ///
540 /// # Errors
541 ///
542 /// Returns an error if any of the following occurs:
543 ///
544 /// * The backup ID cannot be parsed as a valid git OID.
545 /// * The associated commit cannot be found in the repository.
546 /// * The commit's tree cannot be accessed.
547 /// * Checking out the tree in the repository fails.
548 /// * File system operations, such as removing or creating the working directory, encounter errors.
549 ///
550 /// # Example Usage
551 ///
552 /// ```no_run
553 /// use obsidian_backup_system::BackupManager;
554 ///
555 /// let manager = BackupManager::new("./backup_store", "./my_data")
556 /// .expect("Failed to initialize BackupManager");
557 ///
558 /// let backup_id = "abcdef1234567890";
559 /// if let Err(err) = manager.restore(backup_id) {
560 /// eprintln!("Failed to restore backup: {}", err);
561 /// } else {
562 /// println!("Backup restored successfully!");
563 /// }
564 /// ```
565 pub fn restore(&self, backup_id: impl AsRef<str>) -> Result<()> {
566 let backup_id = backup_id.as_ref();
567 info!("Restoring backup with ID: {}", backup_id);
568
569 debug!("Parsing backup ID as git OID");
570 let oid = Oid::from_str(backup_id)?;
571
572 debug!("Finding commit for OID: {}", oid);
573 let commit = self.repository.find_commit(oid)?;
574
575 debug!("Getting tree from commit");
576 let tree = commit.tree()?;
577 debug!("Tree ID: {}", tree.id());
578
579 if let Some(ref workdir) = self.repository.workdir() {
580 debug!("Working directory found: {:?}", workdir);
581
582 // Use safer restore approach with temporary directory
583 let temp_dir = workdir
584 .parent()
585 .ok_or_else(|| {
586 anyhow::anyhow!("Cannot determine parent directory for working directory")
587 })?
588 .join(format!(
589 "{}_restore_tmp",
590 workdir
591 .file_name()
592 .and_then(|n| n.to_str())
593 .unwrap_or("workdir")
594 ));
595
596 debug!("Using temporary directory: {:?}", temp_dir);
597
598 // Clean up temp directory if it exists from a previous failed restore
599 if temp_dir.exists() {
600 debug!("Cleaning up existing temporary directory");
601 fs::remove_dir_all(&temp_dir)?;
602 }
603
604 // Create temp directory
605 debug!("Creating temporary directory");
606 fs::create_dir_all(&temp_dir)?;
607
608 // Checkout to temp location
609 debug!("Checking out tree to temporary directory");
610 let mut checkout_opts = git2::build::CheckoutBuilder::new();
611 checkout_opts.target_dir(&temp_dir);
612 checkout_opts.force();
613 checkout_opts.remove_untracked(true);
614 self.repository
615 .checkout_tree(tree.as_object(), Some(&mut checkout_opts))?;
616
617 // At this point, the checkout succeeded. Now perform the swap.
618 debug!("Checkout successful, swapping directories");
619
620 // Create a backup of the old working directory
621 let backup_dir = workdir
622 .parent()
623 .ok_or_else(|| {
624 anyhow::anyhow!("Cannot determine parent directory for working directory")
625 })?
626 .join(format!(
627 "{}_old_backup",
628 workdir
629 .file_name()
630 .and_then(|n| n.to_str())
631 .unwrap_or("workdir")
632 ));
633
634 // Clean up old backup if it exists
635 if backup_dir.exists() {
636 debug!("Cleaning up old backup directory");
637 fs::remove_dir_all(&backup_dir)?;
638 }
639
640 // Move current workdir to backup location
641 debug!("Moving current working directory to backup location");
642 fs::rename(workdir, &backup_dir)?;
643
644 // Move temp directory to workdir location
645 debug!("Moving temporary directory to working directory location");
646 match fs::rename(&temp_dir, workdir) {
647 Ok(_) => {
648 debug!("Restore completed successfully, cleaning up old backup");
649 // Only remove the old backup if the restore succeeded
650 let _ = fs::remove_dir_all(&backup_dir);
651 }
652 Err(e) => {
653 // If rename fails, try to restore the original
654 error!("Failed to move temp directory: {}", e);
655 debug!("Attempting to restore original working directory");
656 if let Err(_restore_err) = fs::rename(&backup_dir, workdir) {
657 error!("Failed to restore original directory: {}", _restore_err);
658 return Err(anyhow::anyhow!(
659 "Restore failed and could not recover original directory. Original backed up at: {:?}",
660 backup_dir
661 ));
662 }
663 return Err(anyhow::anyhow!("Failed to complete restore: {}", e));
664 }
665 }
666 } else {
667 warn!("No working directory configured for repository");
668 // For bare repositories, just update HEAD
669 debug!("Checking out tree in bare repository");
670 self.repository.checkout_tree(tree.as_object(), None)?;
671 }
672
673 info!("Backup restored successfully");
674 Ok(())
675 }
676
677 /// Exports a backup identified by its ID into a compressed archive.
678 ///
679 /// This function retrieves a backup commit from the Git repository using the provided `backup_id`,
680 /// packages its content into a compressed archive, and writes the result to the specified `output_path`.
681 ///
682 /// # Parameters
683 ///
684 /// * `backup_id` - A string-like identifier of the backup to export. This must correspond to a valid Git object ID (OID) in the repository.
685 /// * `output_path` - The destination path for the created archive. This must be a valid filesystem path.
686 /// * `level` - Compression level (0-9, clamped to this range). The value determines the trade-off between compression size and speed.
687 ///
688 /// # Returns
689 ///
690 /// * `Result<()>` - Returns `Ok(())` if the archive is successfully created, or an error if any step in the process fails.
691 ///
692 /// # Errors
693 ///
694 /// This function can fail for several reasons, including (but not limited to):
695 ///
696 /// 1. The provided `backup_id` is not a valid Git OID.
697 /// 2. The backup commit or its associated tree cannot be found within the repository.
698 /// 3. Issues encountered while creating the archive writer or writing to the output path.
699 /// 4. Any errors arising from compression settings or file operations during the archive creation process.
700 ///
701 /// # Logging
702 ///
703 /// - Logs the progress of the backup export process at `info` and `debug` levels.
704 /// - Logs errors if any step in the process fails.
705 ///
706 /// # Example
707 ///
708 /// ```rust
709 /// use obsidian_backup_system::BackupManager;
710 ///
711 /// let manager = BackupManager::new("./backup_store", "./my_data")
712 /// .expect("Failed to initialize BackupManager");
713 ///
714 /// let last_backup = manager
715 /// .last()
716 /// .expect("Failed to get last backup")
717 /// .expect("No backups found");
718 ///
719 /// manager.export(&last_backup.id, "backup.7z", 5)
720 /// .expect("Failed to export backup");
721 /// ```
722 ///
723 /// In this example, the specified backup ID is packed into a `.7z` archive
724 /// with medium compression level (5) and saved to the given output path.
725 #[cfg(feature = "zip")]
726 pub fn export(
727 &self,
728 backup_id: impl AsRef<str>,
729 output_path: impl AsRef<Path>,
730 level: u8,
731 ) -> Result<()> {
732 // Validate and clamp compression level to 0-9 range
733 let level = level.clamp(0, 9);
734
735 let mut writer = ArchiveWriter::create(output_path)?;
736 writer.set_content_methods(vec![
737 encoder_options::Lzma2Options::from_level(level as u32).into(),
738 ]);
739
740 let backup_id = backup_id.as_ref();
741 info!("Exporting backup with ID: {} to archive", backup_id);
742 let oid = Oid::from_str(backup_id)?;
743 let commit = self.repository.find_commit(oid)?;
744 let tree = commit.tree()?;
745
746 // Walk the tree recursively and add files to the archive
747 self.add_tree_to_archive(&mut writer, &tree, "")?;
748
749 debug!("Finalizing archive");
750 writer.finish()?;
751
752 info!("Archive created successfully");
753 Ok(())
754 }
755
756 /// Exports a backup identified by its ID into a compressed archive stream.
757 ///
758 /// This function retrieves a backup commit from the Git repository using the provided `backup_id`,
759 /// packages its content into a compressed archive, and writes the result to the provided writer stream.
760 /// This is useful for scenarios where you want to stream the archive directly to an in-memory buffer,
761 /// or any other seekable destination without creating an intermediate file.
762 ///
763 /// # Parameters
764 ///
765 /// * `backup_id` - A string-like identifier of the backup to export. This must correspond to a valid Git object ID (OID) in the repository.
766 /// * `writer` - A writer implementing both `Write` and `Seek` where the archive will be written to. The 7z format requires seeking to write headers and metadata.
767 /// * `level` - Compression level (0-9, clamped to this range). The value determines the trade-off between compression size and speed.
768 ///
769 /// # Returns
770 ///
771 /// * `Result<()>` - Returns `Ok(())` if the archive is successfully created and written to the stream, or an error if any step in the process fails.
772 ///
773 /// # Errors
774 ///
775 /// This function can fail for several reasons, including (but not limited to):
776 ///
777 /// 1. The provided `backup_id` is not a valid Git OID.
778 /// 2. The backup commit or its associated tree cannot be found within the repository.
779 /// 3. Issues encountered while creating the archive writer or writing to the output stream.
780 /// 4. Any errors arising from compression settings or file operations during the archive creation process.
781 ///
782 /// # Logging
783 ///
784 /// - Logs the progress of the backup export process at `info` and `debug` levels.
785 /// - Logs errors if any step in the process fails.
786 ///
787 /// # Example
788 ///
789 /// ```rust
790 /// use obsidian_backup_system::BackupManager;
791 /// use std::io::Cursor;
792 ///
793 /// let manager = BackupManager::new("./backup_store", "./my_data")
794 /// .expect("Failed to initialize BackupManager");
795 ///
796 /// let last_backup = manager
797 /// .last()
798 /// .expect("Failed to get last backup")
799 /// .expect("No backups found");
800 ///
801 /// // Export to an in-memory buffer
802 /// let mut buffer = Cursor::new(Vec::new());
803 /// manager.export_to_stream(&last_backup.id, &mut buffer, 5)
804 /// .expect("Failed to export backup to stream");
805 ///
806 /// let archive_bytes = buffer.into_inner();
807 /// println!("Archive size: {} bytes", archive_bytes.len());
808 /// ```
809 ///
810 /// In this example, the specified backup ID is packed into a `.7z` archive
811 /// with medium compression level (5) and written to the provided stream.
812 #[cfg(feature = "zip")]
813 pub fn export_to_stream<W: std::io::Write + std::io::Seek>(
814 &self,
815 backup_id: impl AsRef<str>,
816 writer: W,
817 level: u8,
818 ) -> Result<()> {
819 // Validate and clamp compression level to 0-9 range
820 let level = level.clamp(0, 9);
821
822 let mut archive_writer = ArchiveWriter::new(writer)?;
823 archive_writer.set_content_methods(vec![
824 encoder_options::Lzma2Options::from_level(level as u32).into(),
825 ]);
826
827 let backup_id = backup_id.as_ref();
828 info!("Exporting backup with ID: {} to stream", backup_id);
829 let oid = Oid::from_str(backup_id)?;
830 let commit = self.repository.find_commit(oid)?;
831 let tree = commit.tree()?;
832
833 // Walk the tree recursively and add files to the archive
834 self.add_tree_to_archive(&mut archive_writer, &tree, "")?;
835
836 debug!("Finalizing archive stream");
837 archive_writer.finish()?;
838
839 info!("Archive stream created successfully");
840 Ok(())
841 }
842
843 /// Computes the list of files that were modified (added, updated, or deleted)
844 /// in the specified backup/commit within the repository.
845 ///
846 /// # Arguments
847 ///
848 /// * `backup_id` - A string-like identifier for the backup or commit to compute
849 /// the modified files against its parent commit. The function
850 /// expects this to be in the format of a valid Git object ID.
851 ///
852 /// # Returns
853 ///
854 /// A `Result` containing:
855 /// * `Ok(Vec<ModifiedFile>)` - A vector of `ModifiedFile` objects, each representing
856 /// a file that was added, updated, or deleted. Each `ModifiedFile`
857 /// includes:
858 /// - `path`: The path of the file.
859 /// - `content_before`: The file's content before modification (if applicable, `Some` if the file existed, otherwise `None`).
860 /// - `content_after`: The file's content after modification (if applicable, `Some` if the file exists, otherwise `None` for deletions).
861 /// * `Err(git2::Error)` - In case of any error during Git repository or commit/tree operations.
862 ///
863 /// # Details
864 ///
865 /// * The function computes the difference between the specified commit/tree and its
866 /// immediate parent (if available). If there is no parent commit (e.g., for the first commit),
867 /// only the newly added files will appear in the output list.
868 /// * For each file in the current tree:
869 /// - If a corresponding file exists in the parent tree, the function checks for modifications.
870 /// - If the file does not exist in the parent tree, it is marked as newly added.
871 /// * For files that existed in the parent tree but are absent in the current tree,
872 /// the function marks them as deleted.
873 ///
874 /// # Errors
875 ///
876 /// This function can return an `Err` in the following situations:
877 /// * If the provided `backup_id` is not a valid Git commit or tree object ID.
878 /// * If the repository cannot find the commit or tree corresponding to `backup_id`.
879 /// * If there are errors while retrieving or processing blobs within the trees.
880 ///
881 /// # Example
882 ///
883 /// ```rust
884 /// use obsidian_backup_system::BackupManager;
885 ///
886 /// let manager = BackupManager::new("./backup_store", "./my_data")
887 /// .expect("Failed to initialize BackupManager");
888 ///
889 /// let backup_id = "abcd1234";
890 /// let modified_files = manager.diff(backup_id)
891 /// .expect("Failed to get diff");
892 ///
893 /// for file in modified_files {
894 /// println!("Path: {}", file.path);
895 /// match (&file.content_before, &file.content_after) {
896 /// (Some(before), Some(after)) => {
897 /// println!("File was modified. Before size: {}, After size: {}", before.len(), after.len());
898 /// }
899 /// (None, Some(after)) => {
900 /// println!("File was added. Size: {}", after.len());
901 /// }
902 /// (Some(before), None) => {
903 /// println!("File was deleted. Previous size: {}", before.len());
904 /// }
905 /// _ => {}
906 /// }
907 /// }
908 /// ```
909 ///
910 /// # Structs Used
911 ///
912 /// * `ModifiedFile`: A struct representing a modified file, with the following fields:
913 /// - `path`: The file's path as a `String`.
914 /// - `content_before`: An optional `Vec<u8>` containing the file's content in the parent revision (if it existed).
915 /// - `content_after`: An optional `Vec<u8>` containing the file's content in the current revision (if it exists).
916 ///
917 /// # Note
918 ///
919 /// * This function assumes text or binary files are stored as blobs in the Git repository.
920 /// * Files that are not blobs (e.g., submodules or symlinks) are ignored.
921 pub fn diff(&self, backup_id: impl AsRef<str>) -> Result<Vec<ModifiedFile>> {
922 let backup_id = backup_id.as_ref();
923 let mut files = Vec::new();
924 let oid = Oid::from_str(backup_id)?;
925 let commit = self.repository.find_commit(oid)?;
926 let tree = commit.tree()?;
927
928 // Get the parent commit tree (if exists) to compare against
929 let parent_tree = if commit.parent_count() > 0 {
930 Some(commit.parent(0)?.tree()?)
931 } else {
932 None
933 };
934
935 // Recursively diff trees
936 self.diff_trees_recursive(&tree, parent_tree.as_ref(), "", &mut files)?;
937
938 Ok(files)
939 }
940
941 /// Helper method to recursively diff two trees
942 fn diff_trees_recursive(
943 &self,
944 tree: &git2::Tree,
945 parent_tree: Option<&git2::Tree>,
946 path_prefix: &str,
947 files: &mut Vec<ModifiedFile>,
948 ) -> Result<()> {
949 // Check files in current tree (for added/modified files)
950 for entry in tree.iter() {
951 let name = entry.name().unwrap_or("");
952 let full_path = if path_prefix.is_empty() {
953 name.to_string()
954 } else {
955 format!("{}/{}", path_prefix, name)
956 };
957
958 match entry.kind() {
959 Some(git2::ObjectType::Blob) => {
960 // It's a file
961 let blob = self.repository.find_blob(entry.id())?;
962 let content_after = blob.content().to_vec();
963
964 // Try to get the content before from parent commit
965 let content_before = if let Some(parent_tree) = parent_tree {
966 parent_tree
967 .get_name(name)
968 .and_then(|parent_entry| {
969 if let Some(git2::ObjectType::Blob) = parent_entry.kind() {
970 self.repository.find_blob(parent_entry.id()).ok()
971 } else {
972 None
973 }
974 })
975 .map(|parent_blob| parent_blob.content().to_vec())
976 } else {
977 None
978 };
979
980 // Only add if file was added or modified
981 if let Some(before_content) = content_before {
982 // File existed before - check if it was modified
983 if before_content != content_after {
984 files.push(ModifiedFile {
985 path: full_path,
986 content_before: Some(before_content),
987 content_after: Some(content_after),
988 });
989 }
990 // If content is the same, don't add to results
991 } else {
992 // File was added
993 files.push(ModifiedFile {
994 path: full_path,
995 content_before: None,
996 content_after: Some(content_after),
997 });
998 }
999 }
1000 Some(git2::ObjectType::Tree) => {
1001 // It's a directory, recurse into it
1002 let subtree = self.repository.find_tree(entry.id())?;
1003 let parent_subtree =
1004 parent_tree.and_then(|pt| pt.get_name(name)).and_then(|e| {
1005 if let Some(git2::ObjectType::Tree) = e.kind() {
1006 self.repository.find_tree(e.id()).ok()
1007 } else {
1008 None
1009 }
1010 });
1011 self.diff_trees_recursive(
1012 &subtree,
1013 parent_subtree.as_ref(),
1014 &full_path,
1015 files,
1016 )?;
1017 }
1018 _ => {
1019 // Skip other object types
1020 }
1021 }
1022 }
1023
1024 // Check for files/directories that were deleted (existed in parent but not in current)
1025 if let Some(parent_tree) = parent_tree {
1026 for parent_entry in parent_tree.iter() {
1027 let name = parent_entry.name().unwrap_or("");
1028 let full_path = if path_prefix.is_empty() {
1029 name.to_string()
1030 } else {
1031 format!("{}/{}", path_prefix, name)
1032 };
1033
1034 // If this entry doesn't exist in the current tree, it was deleted
1035 if tree.get_name(name).is_none() {
1036 match parent_entry.kind() {
1037 Some(git2::ObjectType::Blob) => {
1038 // File was deleted
1039 let parent_blob = self.repository.find_blob(parent_entry.id())?;
1040 let content_before = parent_blob.content().to_vec();
1041
1042 files.push(ModifiedFile {
1043 path: full_path,
1044 content_before: Some(content_before),
1045 content_after: None,
1046 });
1047 }
1048 Some(git2::ObjectType::Tree) => {
1049 // Directory was deleted - recursively add all files as deleted
1050 let parent_subtree = self.repository.find_tree(parent_entry.id())?;
1051 self.diff_trees_recursive(
1052 &parent_subtree,
1053 Some(&parent_subtree),
1054 &full_path,
1055 &mut Vec::new(),
1056 )?;
1057 // Mark all files in the deleted directory
1058 self.mark_tree_as_deleted(&parent_subtree, &full_path, files)?;
1059 }
1060 _ => {}
1061 }
1062 }
1063 }
1064 }
1065
1066 Ok(())
1067 }
1068
1069 /// Helper method to mark all files in a tree as deleted
1070 fn mark_tree_as_deleted(
1071 &self,
1072 tree: &git2::Tree,
1073 path_prefix: &str,
1074 files: &mut Vec<ModifiedFile>,
1075 ) -> Result<()> {
1076 for entry in tree.iter() {
1077 let name = entry.name().unwrap_or("");
1078 let full_path = if path_prefix.is_empty() {
1079 name.to_string()
1080 } else {
1081 format!("{}/{}", path_prefix, name)
1082 };
1083
1084 match entry.kind() {
1085 Some(git2::ObjectType::Blob) => {
1086 let blob = self.repository.find_blob(entry.id())?;
1087 files.push(ModifiedFile {
1088 path: full_path,
1089 content_before: Some(blob.content().to_vec()),
1090 content_after: None,
1091 });
1092 }
1093 Some(git2::ObjectType::Tree) => {
1094 let subtree = self.repository.find_tree(entry.id())?;
1095 self.mark_tree_as_deleted(&subtree, &full_path, files)?;
1096 }
1097 _ => {}
1098 }
1099 }
1100 Ok(())
1101 }
1102 pub fn last(&self) -> Result<Option<BackupItem>> {
1103 // Check if HEAD exists first
1104 if self.repository.head().is_err() {
1105 return Ok(None); // No backups yet
1106 }
1107
1108 let mut rev_walk = self.repository.revwalk()?;
1109 rev_walk.push_head()?;
1110 if let Some(oid) = rev_walk.next() {
1111 let oid = oid?;
1112 let commit = self.repository.find_commit(oid)?;
1113 let item = BackupItem {
1114 id: oid.to_string(),
1115 timestamp: chrono::DateTime::from_timestamp_secs(commit.time().seconds())
1116 .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC),
1117 description: commit
1118 .message()
1119 .unwrap_or("No description was provided")
1120 .to_string(),
1121 };
1122 Ok(Some(item))
1123 } else {
1124 Ok(None)
1125 }
1126 }
1127
1128 #[cfg(feature = "zip")]
1129 fn add_tree_to_archive<W: std::io::Write + std::io::Seek>(
1130 &self,
1131 writer: &mut ArchiveWriter<W>,
1132 tree: &git2::Tree,
1133 path_prefix: &str,
1134 ) -> Result<()> {
1135 for entry in tree.iter() {
1136 let name = entry.name().unwrap_or("");
1137 let full_path = if path_prefix.is_empty() {
1138 name.to_string()
1139 } else {
1140 format!("{}/{}", path_prefix, name)
1141 };
1142
1143 match entry.kind() {
1144 Some(git2::ObjectType::Blob) => {
1145 // It's a file
1146 debug!("Adding file to archive: {}", full_path);
1147 let blob = self.repository.find_blob(entry.id())?;
1148 let content = blob.content();
1149
1150 writer.push_archive_entry(
1151 sevenz_rust2::ArchiveEntry::new_file(&full_path),
1152 Some(content),
1153 )?;
1154 }
1155 Some(git2::ObjectType::Tree) => {
1156 // It's a directory, recurse into it
1157 debug!("Entering directory: {}", full_path);
1158 let subtree = self.repository.find_tree(entry.id())?;
1159 self.add_tree_to_archive(writer, &subtree, &full_path)?;
1160 }
1161 _ => {
1162 // Skip other object types (commits, tags, etc.)
1163 debug!("Skipping object type: {:?} for {}", entry.kind(), full_path);
1164 }
1165 }
1166 }
1167 Ok(())
1168 }
1169
1170 pub fn purge_backups_over_count(&self, count: usize) -> Result<()> {
1171 info!("Purging backups over count: {}", count);
1172
1173 // Get all commit IDs
1174 let ids = self.list_ids()?;
1175
1176 if ids.len() <= count {
1177 info!(
1178 "Number of backups ({}) is within limit ({})",
1179 ids.len(),
1180 count
1181 );
1182 return Ok(());
1183 }
1184
1185 // Keep the most recent 'count' commits
1186 let commits_to_keep = &ids[..count];
1187 let oldest_commit_to_keep = &ids[count - 1];
1188
1189 debug!("Keeping {} most recent commits", count);
1190 debug!("Oldest commit to keep: {}", oldest_commit_to_keep);
1191
1192 // Get the tree of the oldest commit we want to keep
1193 let oldest_oid = Oid::from_str(oldest_commit_to_keep)?;
1194 let oldest_commit = self.repository.find_commit(oldest_oid)?;
1195 let oldest_tree = oldest_commit.tree()?;
1196
1197 // Create a new initial commit with this tree
1198 let sig = self.repository.signature()?;
1199 let new_base_oid = self.repository.commit(
1200 None, // Don't update any reference yet
1201 &sig,
1202 &sig,
1203 &format!(
1204 "Consolidated backup prior to {}",
1205 oldest_commit.time().seconds()
1206 ),
1207 &oldest_tree,
1208 &[], // No parents - this becomes the new root
1209 )?;
1210
1211 debug!("Created new base commit: {}", new_base_oid);
1212
1213 // Now we need to rewrite the remaining commits to use this new base
1214 self.rewrite_commit_chain(&commits_to_keep[..commits_to_keep.len() - 1], new_base_oid)?;
1215
1216 // Force garbage collection to remove unreferenced objects
1217 self.cleanup_orphaned_commits()?;
1218
1219 info!("Successfully purged {} old backups", ids.len() - count);
1220 Ok(())
1221 }
1222
1223 pub fn purge_backups_older_than(&self, period: chrono::Duration) -> Result<()> {
1224 info!("Purging backups older than {:?}", period);
1225
1226 let now = chrono::Utc::now();
1227 let cutoff_time = now - period;
1228 let cutoff_timestamp = cutoff_time.timestamp();
1229
1230 debug!("Cutoff timestamp: {}", cutoff_timestamp);
1231
1232 // Get all commits
1233 let ids = self.list_ids()?;
1234 let mut commits_to_keep = Vec::new();
1235 let mut oldest_commit_to_delete = None;
1236
1237 for commit_id in &ids {
1238 let oid = Oid::from_str(commit_id)?;
1239 let commit = self.repository.find_commit(oid)?;
1240 let commit_time = commit.time().seconds();
1241
1242 if commit_time >= cutoff_timestamp {
1243 commits_to_keep.push(commit_id.clone());
1244 } else {
1245 // Track the oldest commit we're deleting (youngest of the ones to delete)
1246 if oldest_commit_to_delete.is_none() {
1247 oldest_commit_to_delete = Some((commit_id.clone(), commit));
1248 }
1249 }
1250 }
1251
1252 if commits_to_keep.len() == ids.len() {
1253 info!("No backups to purge");
1254 return Ok(());
1255 }
1256
1257 if commits_to_keep.is_empty() {
1258 return Err(anyhow::anyhow!("Cannot purge all backups"));
1259 }
1260
1261 // Create a consolidated base commit from the oldest commit to keep
1262 let oldest_to_keep = &commits_to_keep[commits_to_keep.len() - 1];
1263 let oldest_oid = Oid::from_str(oldest_to_keep)?;
1264 let oldest_commit = self.repository.find_commit(oldest_oid)?;
1265 let oldest_tree = oldest_commit.tree()?;
1266
1267 let sig = self.repository.signature()?;
1268 let new_base_oid = self.repository.commit(
1269 None,
1270 &sig,
1271 &sig,
1272 &format!(
1273 "Consolidated backup prior to {}",
1274 chrono::DateTime::from_timestamp_secs(oldest_commit.time().seconds()).unwrap()
1275 ),
1276 &oldest_tree,
1277 &[],
1278 )?;
1279
1280 debug!("Created new base commit: {}", new_base_oid);
1281
1282 // Rewrite remaining commits
1283 if commits_to_keep.len() > 1 {
1284 self.rewrite_commit_chain(&commits_to_keep[..commits_to_keep.len() - 1], new_base_oid)?;
1285 } else {
1286 // Only one commit to keep, just update HEAD to the new base
1287 self.repository.reference(
1288 "refs/heads/master",
1289 new_base_oid,
1290 true,
1291 "Purged old backups",
1292 )?;
1293 self.repository.set_head("refs/heads/master")?;
1294 }
1295
1296 self.cleanup_orphaned_commits()?;
1297
1298 info!("Successfully purged backups older than {:?}", period);
1299 Ok(())
1300 }
1301
1302 pub fn purge_backups_over_size(&self, size: usize) -> Result<()> {
1303 info!(
1304 "Purging backups to reduce repository size below {} bytes",
1305 size
1306 );
1307
1308 // Get current repository size
1309 let repo_path = self.repository.path();
1310 let current_size = self.calculate_repo_size(repo_path)?;
1311
1312 debug!("Current repository size: {} bytes", current_size);
1313
1314 if current_size <= size {
1315 info!("Repository size is within limit");
1316 return Ok(());
1317 }
1318
1319 // Strategy: Remove oldest commits one by one until size is acceptable
1320 let ids = self.list_ids()?;
1321
1322 if ids.len() <= 1 {
1323 return Err(anyhow::anyhow!(
1324 "Cannot reduce size further without removing all backups"
1325 ));
1326 }
1327
1328 // Binary search for the right number of commits to keep
1329 let mut keep_count = ids.len();
1330
1331 while keep_count > 1 {
1332 keep_count /= 2;
1333
1334 // Estimate if this would be enough by checking
1335 // We'll need to actually try purging to get accurate size
1336 debug!("Trying to keep {} commits", keep_count);
1337
1338 // For now, just use purge_backups_over_count approach
1339 // In production, you might want a more sophisticated size estimation
1340 self.purge_backups_over_count(keep_count)?;
1341
1342 let new_size = self.calculate_repo_size(repo_path)?;
1343 debug!("New repository size: {} bytes", new_size);
1344
1345 if new_size <= size {
1346 info!("Successfully reduced repository size to {} bytes", new_size);
1347 return Ok(());
1348 }
1349 }
1350
1351 Err(anyhow::anyhow!(
1352 "Could not reduce repository size below {} bytes",
1353 size
1354 ))
1355 }
1356
1357 /// Helper function to rewrite a chain of commits with a new parent
1358 fn rewrite_commit_chain(&self, commit_ids: &[String], new_parent_oid: Oid) -> Result<()> {
1359 debug!("Rewriting commit chain with {} commits", commit_ids.len());
1360
1361 let mut current_parent = new_parent_oid;
1362 let mut new_head = None;
1363
1364 // Iterate through commits from oldest to newest (reverse order)
1365 for commit_id in commit_ids.iter().rev() {
1366 let old_oid = Oid::from_str(commit_id)?;
1367 let old_commit = self.repository.find_commit(old_oid)?;
1368
1369 debug!("Rewriting commit: {}", commit_id);
1370
1371 let parent_commit = self.repository.find_commit(current_parent)?;
1372
1373 // Create new commit with same tree but new parent
1374 let new_oid = self.repository.commit(
1375 None,
1376 &old_commit.author(),
1377 &old_commit.committer(),
1378 old_commit.message().unwrap_or("No description provided"),
1379 &old_commit.tree()?,
1380 &[&parent_commit],
1381 )?;
1382
1383 debug!("Created new commit: {} (was: {})", new_oid, old_oid);
1384
1385 current_parent = new_oid;
1386 new_head = Some(new_oid);
1387 }
1388
1389 // Update HEAD to point to the new chain
1390 if let Some(head_oid) = new_head {
1391 debug!("Updating HEAD to: {}", head_oid);
1392 self.repository.reference(
1393 "refs/heads/master",
1394 head_oid,
1395 true,
1396 "Restructured commit history",
1397 )?;
1398 self.repository.set_head("refs/heads/master")?;
1399 }
1400
1401 Ok(())
1402 }
1403
1404 /// Clean up orphaned commits and run garbage collection
1405 ///
1406 /// This implements a standalone garbage collection mechanism that:
1407 /// 1. Expires reflog entries
1408 /// 2. Identifies all reachable objects from refs
1409 /// 3. Removes unreachable loose objects
1410 /// 4. Packs remaining loose objects into packfiles
1411 fn cleanup_orphaned_commits(&self) -> Result<()> {
1412 info!("Starting comprehensive garbage collection");
1413
1414 // Step 1: Expire reflog entries immediately
1415 debug!("Expiring reflog entries");
1416 self.expire_reflogs()?;
1417
1418 // Step 2: Collect all reachable objects
1419 debug!("Identifying reachable objects");
1420 let reachable_oids = self.find_reachable_objects()?;
1421 info!("Found {} reachable objects", reachable_oids.len());
1422
1423 // Step 3: Remove unreachable loose objects
1424 debug!("Pruning unreachable objects");
1425 let _pruned_count = self.prune_unreachable_objects(&reachable_oids)?;
1426 info!("Pruned {} unreachable objects", _pruned_count);
1427
1428 // Step 4: Pack loose objects
1429 debug!("Packing loose objects");
1430 let _packed_count = self.pack_loose_objects()?;
1431 info!("Packed {} loose objects", _packed_count);
1432
1433 // Step 5: Pack references
1434 debug!("Packing references");
1435 self.pack_references()?;
1436
1437 info!("Garbage collection completed successfully");
1438 Ok(())
1439 }
1440
1441 /// Expire all reflog entries
1442 fn expire_reflogs(&self) -> Result<()> {
1443 let reflog_refs = vec!["HEAD", "refs/heads/master"];
1444
1445 for ref_name in reflog_refs {
1446 if let Ok(mut reflog) = self.repository.reflog(ref_name) {
1447 // Clear all reflog entries
1448 while !reflog.is_empty() {
1449 reflog.remove(0, false)?;
1450 }
1451 reflog.write()?;
1452 }
1453 }
1454
1455 Ok(())
1456 }
1457
1458 /// Find all objects reachable from current refs
1459 fn find_reachable_objects(&self) -> Result<std::collections::HashSet<Oid>> {
1460 use std::collections::{HashSet, VecDeque};
1461
1462 let mut reachable = HashSet::new();
1463 let mut to_visit = VecDeque::new();
1464
1465 // Start from all references
1466 for reference in self.repository.references()? {
1467 let reference = reference?;
1468 if let Some(oid) = reference.target() {
1469 to_visit.push_back(oid);
1470 reachable.insert(oid);
1471 }
1472 }
1473
1474 // Also include HEAD if it exists
1475 if let Ok(head) = self.repository.head()
1476 && let Some(oid) = head.target()
1477 {
1478 to_visit.push_back(oid);
1479 reachable.insert(oid);
1480 }
1481
1482 // Traverse the object graph
1483 while let Some(oid) = to_visit.pop_front() {
1484 // Try to read the object and find its dependencies
1485 if let Ok(obj) = self.repository.find_object(oid, None) {
1486 match obj.kind() {
1487 Some(git2::ObjectType::Commit) => {
1488 if let Some(commit) = obj.as_commit() {
1489 // Add the tree
1490 let tree_oid = commit.tree_id();
1491 if reachable.insert(tree_oid) {
1492 to_visit.push_back(tree_oid);
1493 }
1494
1495 // Add all parents
1496 for parent in commit.parents() {
1497 let parent_oid = parent.id();
1498 if reachable.insert(parent_oid) {
1499 to_visit.push_back(parent_oid);
1500 }
1501 }
1502 }
1503 }
1504 Some(git2::ObjectType::Tree) => {
1505 if let Some(tree) = obj.as_tree() {
1506 for entry in tree.iter() {
1507 let entry_oid = entry.id();
1508 if reachable.insert(entry_oid) {
1509 to_visit.push_back(entry_oid);
1510 }
1511 }
1512 }
1513 }
1514 Some(git2::ObjectType::Tag) => {
1515 if let Some(tag) = obj.as_tag() {
1516 let target_id = tag.target_id();
1517 if reachable.insert(target_id) {
1518 to_visit.push_back(target_id);
1519 }
1520 }
1521 }
1522 _ => {
1523 // Blobs have no dependencies
1524 }
1525 }
1526 }
1527 }
1528
1529 Ok(reachable)
1530 }
1531
1532 /// Remove unreachable loose objects from the object database
1533 fn prune_unreachable_objects(
1534 &self,
1535 reachable_oids: &std::collections::HashSet<Oid>,
1536 ) -> Result<usize> {
1537 let objects_dir = self.repository.path().join("objects");
1538 let mut pruned_count = 0;
1539
1540 // Iterate through loose object directories (00-ff)
1541 for i in 0..256 {
1542 let dir_name = format!("{:02x}", i);
1543 let dir_path = objects_dir.join(&dir_name);
1544
1545 if !dir_path.exists() {
1546 continue;
1547 }
1548
1549 for entry in fs::read_dir(&dir_path)? {
1550 let entry = entry?;
1551 let file_name = entry.file_name();
1552 let file_name_str = file_name.to_string_lossy();
1553
1554 // Skip pack and idx files
1555 if file_name_str == "pack" || file_name_str == "info" {
1556 continue;
1557 }
1558
1559 // Construct the full OID from directory and filename
1560 let oid_str = format!("{}{}", dir_name, file_name_str);
1561
1562 if let Ok(oid) = Oid::from_str(&oid_str) {
1563 // If this object is not reachable, delete it
1564 if !reachable_oids.contains(&oid) {
1565 let file_path = entry.path();
1566 debug!("Pruning unreachable object: {}", oid);
1567 fs::remove_file(&file_path)?;
1568 pruned_count += 1;
1569
1570 // Remove directory if it's now empty
1571 if let Ok(mut entries) = fs::read_dir(&dir_path)
1572 && entries.next().is_none()
1573 {
1574 let _ = fs::remove_dir(&dir_path);
1575 }
1576 }
1577 }
1578 }
1579 }
1580
1581 Ok(pruned_count)
1582 }
1583
1584 /// Pack loose objects into packfiles
1585 fn pack_loose_objects(&self) -> Result<usize> {
1586 let objects_dir = self.repository.path().join("objects");
1587 let mut loose_oids = Vec::new();
1588
1589 // Collect all loose object OIDs
1590 for i in 0..256 {
1591 let dir_name = format!("{:02x}", i);
1592 let dir_path = objects_dir.join(&dir_name);
1593
1594 if !dir_path.exists() {
1595 continue;
1596 }
1597
1598 for entry in fs::read_dir(&dir_path)? {
1599 let entry = entry?;
1600 let file_name = entry.file_name();
1601 let file_name_str = file_name.to_string_lossy();
1602
1603 // Skip pack and idx files
1604 if file_name_str == "pack" || file_name_str == "info" {
1605 continue;
1606 }
1607
1608 // Construct the full OID
1609 let oid_str = format!("{}{}", dir_name, file_name_str);
1610 if let Ok(oid) = Oid::from_str(&oid_str) {
1611 loose_oids.push(oid);
1612 }
1613 }
1614 }
1615
1616 let loose_count = loose_oids.len();
1617
1618 if loose_oids.is_empty() {
1619 debug!("No loose objects to pack");
1620 return Ok(0);
1621 }
1622
1623 debug!("Packing {} loose objects", loose_count);
1624
1625 // Create a packbuilder
1626 let mut packbuilder = self.repository.packbuilder()?;
1627
1628 // Add all loose objects to the pack
1629 for oid in &loose_oids {
1630 if let Err(_e) = packbuilder.insert_object(*oid, None) {
1631 debug!("Failed to insert object {} into pack: {}", oid, _e);
1632 // Continue with other objects
1633 }
1634 }
1635
1636 // Write the packfile
1637 let pack_dir = objects_dir.join("pack");
1638 fs::create_dir_all(&pack_dir)?;
1639
1640 // Generate a unique pack name based on timestamp
1641 let timestamp = std::time::SystemTime::now()
1642 .duration_since(std::time::UNIX_EPOCH)?
1643 .as_secs();
1644 let pack_path = pack_dir.join(format!("pack-{:x}.pack", timestamp));
1645
1646 debug!("Writing packfile to: {:?}", pack_path);
1647 let mut buf = git2::Buf::new();
1648 packbuilder.write_buf(&mut buf)?;
1649 fs::write::<&std::path::PathBuf, &[u8]>(&pack_path, buf.as_ref())?;
1650
1651 // After successful packing, remove the loose objects
1652 for oid in &loose_oids {
1653 let oid_str = oid.to_string();
1654 let dir_name = &oid_str[..2];
1655 let file_name = &oid_str[2..];
1656 let file_path = objects_dir.join(dir_name).join(file_name);
1657
1658 if file_path.exists() {
1659 let _ = fs::remove_file(&file_path);
1660 }
1661 }
1662
1663 // Clean up empty directories
1664 for i in 0..256 {
1665 let dir_name = format!("{:02x}", i);
1666 let dir_path = objects_dir.join(&dir_name);
1667
1668 if dir_path.exists()
1669 && let Ok(mut entries) = fs::read_dir(&dir_path)
1670 && entries.next().is_none()
1671 {
1672 let _ = fs::remove_dir(&dir_path);
1673 }
1674 }
1675
1676 Ok(loose_count)
1677 }
1678
1679 /// Pack references into packed-refs file
1680 fn pack_references(&self) -> Result<()> {
1681 // Get all references
1682 let mut refs_to_pack = Vec::new();
1683
1684 for reference in self.repository.references()? {
1685 let reference = reference?;
1686 let name = reference.name().unwrap_or("");
1687
1688 // Only pack refs under refs/ (not HEAD or other special refs)
1689 if name.starts_with("refs/")
1690 && let Some(target) = reference.target()
1691 {
1692 refs_to_pack.push((name.to_string(), target));
1693 }
1694 }
1695
1696 if refs_to_pack.is_empty() {
1697 debug!("No references to pack");
1698 return Ok(());
1699 }
1700
1701 // Write packed-refs file
1702 let packed_refs_path = self.repository.path().join("packed-refs");
1703 let mut content = String::from("# pack-refs with: peeled fully-peeled sorted\n");
1704
1705 for (name, oid) in &refs_to_pack {
1706 content.push_str(&format!("{} {}\n", oid, name));
1707 }
1708
1709 fs::write(&packed_refs_path, content)?;
1710
1711 // Remove individual ref files
1712 for (name, _) in &refs_to_pack {
1713 let ref_path = self.repository.path().join(name);
1714 if ref_path.exists() {
1715 let _ = fs::remove_file(&ref_path);
1716 }
1717 }
1718
1719 debug!("Packed {} references", refs_to_pack.len());
1720 Ok(())
1721 }
1722
1723 /// Calculate the total size of the repository
1724 fn calculate_repo_size(&self, repo_path: &Path) -> Result<usize> {
1725 let mut total_size = 0;
1726
1727 fn visit_dirs(dir: &Path, total: &mut usize) -> Result<()> {
1728 if dir.is_dir() {
1729 for entry in fs::read_dir(dir)? {
1730 let entry = entry?;
1731 let path = entry.path();
1732 if path.is_dir() {
1733 visit_dirs(&path, total)?;
1734 } else {
1735 *total += fs::metadata(&path)?.len() as usize;
1736 }
1737 }
1738 }
1739 Ok(())
1740 }
1741
1742 visit_dirs(repo_path, &mut total_size)?;
1743 Ok(total_size)
1744 }
1745
1746 /// Exports a backup identified by its ID into a compressed ZIP archive stream (async).
1747 ///
1748 /// This function retrieves a backup commit from the Git repository using the provided `backup_id`,
1749 /// packages its content into a compressed ZIP archive, and streams the result to the provided async writer.
1750 /// This is designed for use with async I/O systems like Tokio, enabling efficient streaming of large
1751 /// backups without loading them entirely into memory.
1752 ///
1753 /// # Parameters
1754 ///
1755 /// * `backup_id` - A string-like identifier of the backup to export. This must correspond to a valid Git object ID (OID) in the repository.
1756 /// * `writer` - An async writer implementing `AsyncWrite` where the ZIP archive will be streamed to.
1757 /// * `level` - Compression level (0-9, clamped to this range). The value determines the trade-off between compression size and speed.
1758 ///
1759 /// # Returns
1760 ///
1761 /// * `Result<()>` - Returns `Ok(())` if the archive is successfully created and streamed, or an error if any step in the process fails.
1762 ///
1763 /// # Errors
1764 ///
1765 /// This function can fail for several reasons, including (but not limited to):
1766 ///
1767 /// 1. The provided `backup_id` is not a valid Git OID.
1768 /// 2. The backup commit or its associated tree cannot be found within the repository.
1769 /// 3. Issues encountered while creating the archive writer or writing to the output stream.
1770 /// 4. Any errors arising from compression settings or file operations during the archive creation process.
1771 ///
1772 /// # Logging
1773 ///
1774 /// - Logs the progress of the backup export process at `info` and `debug` levels.
1775 /// - Logs errors if any step in the process fails.
1776 ///
1777 /// # Example
1778 ///
1779 /// ```rust,ignore
1780 /// use obsidian_backups::BackupManager;
1781 /// use tokio::fs::File;
1782 ///
1783 /// let manager = BackupManager::new("./backup_store", "./my_data")
1784 /// .expect("Failed to initialize BackupManager");
1785 ///
1786 /// let last_backup = manager
1787 /// .last()
1788 /// .expect("Failed to get last backup")
1789 /// .expect("No backups found");
1790 ///
1791 /// // Export to an async file
1792 /// let mut file = File::create("backup.zip").await.expect("Failed to create file");
1793 /// manager.export_to_stream_async(&last_backup.id, &mut file, 6).await
1794 /// .expect("Failed to export backup to stream");
1795 /// ```
1796 ///
1797 /// In this example, the specified backup ID is packed into a ZIP archive
1798 /// with compression level 6 and streamed to the provided async writer.
1799 #[cfg(feature = "async-stream")]
1800 pub async fn export_to_stream_async<W: tokio::io::AsyncWrite + Unpin + Send>(
1801 &self,
1802 backup_id: impl AsRef<str>,
1803 writer: W,
1804 level: u8,
1805 ) -> Result<()> {
1806 use archflow::compress::tokio::archive::ZipArchive;
1807
1808 // Validate and clamp compression level to 0-9 range
1809 let level = level.clamp(0, 9);
1810
1811 let backup_id = backup_id.as_ref();
1812 info!("Exporting backup with ID: {} to async stream", backup_id);
1813
1814 let oid = git2::Oid::from_str(backup_id)?;
1815 let commit = self.repository.find_commit(oid)?;
1816 let tree = commit.tree()?;
1817
1818 // Create ZIP archive with streaming support
1819 let mut archive = ZipArchive::new_streamable(writer);
1820
1821 // Set compression method
1822 let compression_type: archflow::compression::CompressionMethod = match level {
1823 0 => archflow::compression::CompressionMethod::Store(),
1824 _ => archflow::compression::CompressionMethod::Deflate(),
1825 };
1826
1827 // Walk the tree recursively and add files to the archive
1828 self.add_tree_to_zip_archive_async(&mut archive, &tree, "", compression_type)
1829 .await?;
1830
1831 debug!("Finalizing archive stream");
1832 archive.finalize().await.map_err(|e| anyhow!("Failed to finalize archive: {}", e))?;
1833
1834 info!("Archive stream created successfully");
1835 Ok(())
1836 }
1837
1838 /// Helper method to recursively add files from a git tree to a ZIP archive (async)
1839 #[cfg(feature = "async-stream")]
1840 async fn add_tree_to_zip_archive_async<W: tokio::io::AsyncWrite + Unpin + Send>(
1841 &self,
1842 archive: &mut archflow::compress::tokio::archive::ZipArchive<'_, W>,
1843 tree: &git2::Tree<'_>,
1844 path_prefix: &str,
1845 compress_method: archflow::compression::CompressionMethod,
1846 ) -> Result<()> {
1847 use archflow::compress::FileOptions;
1848
1849 for entry in tree.iter() {
1850 let name = entry.name().unwrap_or("");
1851 let full_path = if path_prefix.is_empty() {
1852 name.to_string()
1853 } else {
1854 format!("{}/{}", path_prefix, name)
1855 };
1856
1857 match entry.kind() {
1858 Some(git2::ObjectType::Blob) => {
1859 // It's a file
1860 debug!("Adding file to archive: {}", full_path);
1861 let blob = self.repository.find_blob(entry.id())?;
1862 let content = blob.content();
1863
1864 // Create file options with compression method
1865 let options = FileOptions::default().compression_method(compress_method);
1866
1867 // Create a cursor for the content
1868 let mut cursor = std::io::Cursor::new(content);
1869
1870 // Append file to the archive
1871 archive
1872 .append(&full_path, &options, &mut cursor)
1873 .await
1874 .map_err(|e| anyhow!("Failed to append file to archive: {}", e))?;
1875 }
1876 Some(git2::ObjectType::Tree) => {
1877 // It's a directory, recurse into it
1878 debug!("Entering directory: {}", full_path);
1879 let subtree = self.repository.find_tree(entry.id())?;
1880 Box::pin(self.add_tree_to_zip_archive_async(
1881 archive,
1882 &subtree,
1883 &full_path,
1884 compress_method,
1885 ))
1886 .await?;
1887 }
1888 _ => {
1889 // Skip other object types (commits, tags, etc.)
1890 debug!("Skipping object type: {:?} for {}", entry.kind(), full_path);
1891 }
1892 }
1893 }
1894 Ok(())
1895 }
1896}