Skip to main content

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}