diff --git a/README.md b/README.md index 17fa36f..2961922 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ Backup and restore tool for Hermes Agent + OpenClaw installations. ## Features -- **Incremental-aware discovery**: Backs up `~/work/hermes-agent`, `~/work/openclaw`, `~/.hermes`, `~/.openclaw`, systemd units, and more +- **Incremental-aware discovery**: Backs up `~/.hermes`, `~/.openclaw`, systemd units, and more - **SQLite-safe**: Uses `sqlite3 .backup` for live database copies - **Compressed archives**: `tar.zst` format with zstd compression - **Google Drive upload**: Via `rclone` integration - **Cross-platform home dir**: Uses `dirs` crate — no hardcoded paths -- **Auto workspace discovery**: Scans `~/.openclaw/workspace*` dynamically +- **Auto workspace discovery**: Users can add workspace paths via `[paths] extra` in the config file ## Install @@ -85,6 +85,12 @@ Configure `~/.config/hbackup/config.toml`: destination = "user@server:/backups/" # scp/rsync drive_remote = "gdrive" # Google Drive remote name drive_folder = "backups/hermes" # Google Drive folder + +[paths] +# Additional directories to include in every backup +extra = ["~/work/my-project"] +# Directories to exclude entirely from every backup +exclude = ["~/work/hermes-agent"] ``` ## Cron Setup @@ -107,26 +113,33 @@ destination = "user@backup-server:/backups/" # For Google Drive upload: drive_remote = "gdrive" drive_folder = "backups/hermes" + +[paths] +# Extra directories to add to every backup (supports ~) +extra = ["~/work/my-project", "~/documents/configs"] +# Directories to exclude entirely from every backup (supports ~) +exclude = ["~/work/hermes-agent"] ``` ## What Gets Backed Up | Path | Description | |------|-------------| -| `~/work/hermes-agent` | Hermes Agent source and config | -| `~/work/openclaw` | OpenClaw workspace | | `~/.hermes` | Hermes state, logs, cache | +| `~/work/openclaw` | OpenClaw workspace | | `~/.openclaw` | OpenClaw config, workspaces (auto-discovered) | | `~/.config/systemd/user/` | User systemd units | | SQLite databases | Copied safely via `sqlite3 .backup` | +Extra paths can be added via the `[paths]` section of the config file. + ## Open Source Notes This tool was extracted from a personal setup. Before publishing: - All hardcoded paths removed (uses `dirs::home_dir()`) - Workspace names auto-discovered from `~/.openclaw/workspace*` -- No user-specific paths remain +- No user-specific paths remain; extra paths configurable via `[paths]` in `~/.config/hbackup/config.toml` ## License diff --git a/src/backup.rs b/src/backup.rs index 3e9c7e0..a4d2029 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -14,6 +14,8 @@ pub struct BackupOptions { pub dry_run: bool, pub excludes: Vec, pub output: Option, + pub extra_paths: Vec, + pub exclude_paths: Vec, } pub fn run_backup(opts: &BackupOptions) -> Result { @@ -22,7 +24,7 @@ pub fn run_backup(opts: &BackupOptions) -> Result { // Discover files eprintln!("Discovering files..."); - let mut files = bp.discover(&opts.excludes); + let mut files = bp.discover(&opts.excludes, &opts.extra_paths, &opts.exclude_paths); files.extend(bp.discover_systemd_units()); files.sort(); files.dedup(); diff --git a/src/main.rs b/src/main.rs index 74d6a35..74018f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,17 @@ enum Commands { #[derive(Deserialize)] struct Config { upload: Option, + paths: Option, +} + +#[derive(Deserialize, Default, Clone)] +struct PathsConfig { + /// Additional paths to include in every backup. + #[serde(default)] + extra: Vec, + /// Paths to exclude from every backup (entire directory trees). + #[serde(default)] + exclude: Vec, } #[derive(Deserialize)] @@ -103,12 +114,33 @@ struct UploadConfig { drive_folder: String, } +fn expand_path(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(s) +} + +fn paths_config_to_vecs(pc: Option) -> (Vec, Vec) { + match pc { + None => (Vec::new(), Vec::new()), + Some(pc) => ( + pc.extra.iter().map(|s| expand_path(s)).collect(), + pc.exclude.iter().map(|s| expand_path(s)).collect(), + ), + } +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Backup { dry_run, excludes, output } => { - let opts = BackupOptions { dry_run, excludes, output }; + let config = load_config(); + let (extra_paths, exclude_paths) = paths_config_to_vecs(config.and_then(|c| c.paths)); + let opts = BackupOptions { dry_run, excludes, output, extra_paths, exclude_paths }; backup::run_backup(&opts)?; } Commands::Restore { archive, dry_run, force } => { @@ -221,10 +253,15 @@ fn cmd_upload(archive: &PathBuf, destination: Option<&str>) -> Result<()> { } fn cmd_auto() -> Result<()> { + let config = load_config(); + let (extra_paths, exclude_paths) = + paths_config_to_vecs(config.as_ref().and_then(|c| c.paths.clone())); let opts = BackupOptions { dry_run: false, excludes: vec![], output: None, + extra_paths, + exclude_paths, }; let archive = backup::run_backup(&opts)?; diff --git a/src/paths.rs b/src/paths.rs index 3dfad2f..7b17c48 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -24,17 +24,41 @@ impl BackupPaths { Self { home } } - pub fn discover(&self, extra_excludes: &[String]) -> Vec { + pub fn discover( + &self, + extra_excludes: &[String], + extra_paths: &[std::path::PathBuf], + exclude_paths: &[std::path::PathBuf], + ) -> Vec { let mut specs = self.build_specs(); + + // Append user-configured extra paths as full scans + for ep in extra_paths { + specs.push(DirSpec { + path: ep.clone(), + excludes: vec![], + mode: ScanMode::Full, + }); + } + let mut all_files = Vec::new(); for spec in &mut specs { + // Skip entire spec if its root is under an excluded path + if exclude_paths.iter().any(|ex| path_under(ex, &spec.path)) { + continue; + } if !spec.path.exists() { continue; } spec.excludes.extend(extra_excludes.iter().cloned()); let files = scan_dir(&spec.path, &spec.excludes, &spec.mode); - all_files.extend(files); + // Filter out individual files that fall under an excluded path + for f in files { + if !exclude_paths.iter().any(|ex| path_under(ex, &f)) { + all_files.push(f); + } + } } all_files.sort(); @@ -46,26 +70,6 @@ impl BackupPaths { let h = &self.home; let mut specs = Vec::new(); - // ~/work/hermes-agent - specs.push(DirSpec { - path: h.join("work/hermes-agent"), - excludes: vec![ - "venv".into(), ".venv".into(), "node_modules".into(), - "__pycache__".into(), ".git/objects".into(), - ], - mode: ScanMode::Full, - }); - - // ~/work/openclaw - specs.push(DirSpec { - path: h.join("work/openclaw"), - excludes: vec![ - "node_modules".into(), "__pycache__".into(), - ".git/objects".into(), "dist".into(), - ], - mode: ScanMode::Full, - }); - // ~/.hermes (everything) specs.push(DirSpec { path: h.join(".hermes"), @@ -231,3 +235,8 @@ fn path_matches_exclude(rel: &str, exclude: &str) -> bool { // Match if any path component sequence matches the exclude pattern rel.contains(exclude) } + +/// Returns true if `target` equals `base` or is a descendant of `base`. +fn path_under(base: &Path, target: &Path) -> bool { + target.starts_with(base) +}