From f67036e217a4a124d6eedc3e1edf424f4f2a1420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:03:09 +0000 Subject: [PATCH 1/3] feat: remove hardcoded ~/work/hermes-agent; add configurable extra/exclude paths via config Agent-Logs-Url: https://github.com/thedavidweng/hbackup/sessions/f1204332-755c-4dcc-959f-a99541243758 Co-authored-by: thedavidweng <95214375+thedavidweng@users.noreply.github.com> --- README.md | 21 +++++++++++++++++---- src/backup.rs | 4 +++- src/main.rs | 39 ++++++++++++++++++++++++++++++++++++++- src/paths.rs | 43 +++++++++++++++++++++++++++++++------------ 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 17fa36f..99f7e12 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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 @@ -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..02edbc8 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,16 +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"), @@ -231,3 +245,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) +} From fb0eaa304374c0f0bd60e3650c94836acf702009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:17:19 +0000 Subject: [PATCH 2/3] feat: remove hardcoded ~/.openclaw/workspace* auto-discovery Agent-Logs-Url: https://github.com/thedavidweng/hbackup/sessions/290ede70-ad55-4ed4-83a1-eb7632829591 Co-authored-by: thedavidweng <95214375+thedavidweng@users.noreply.github.com> --- README.md | 2 +- src/paths.rs | 21 --------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/README.md b/README.md index 99f7e12..2961922 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Backup and restore tool for Hermes Agent + OpenClaw installations. - **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 diff --git a/src/paths.rs b/src/paths.rs index 02edbc8..b553a48 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -101,27 +101,6 @@ impl BackupPaths { mode: ScanMode::Selective(openclaw_subdirs), }); - // ~/.openclaw workspace dirs — auto-discover instead of hardcoding - let ws_subdirs: Vec = vec![ - ".openclaw", "memory", "skills", ".omx", ".pi", - "config", "hooks", "scripts", "lib", - ].into_iter().map(String::from).collect(); - let openclaw_base = h.join(".openclaw"); - if openclaw_base.exists() { - if let Ok(entries) = std::fs::read_dir(&openclaw_base) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("workspace") && entry.path().is_dir() { - specs.push(DirSpec { - path: entry.path(), - excludes: vec![], - mode: ScanMode::Selective(ws_subdirs.clone()), - }); - } - } - } - } - // ~/.openclaw-dev specs.push(DirSpec { path: h.join(".openclaw-dev"), From a24a8b4cc7ff67d12b47b9e56b9c4af32b4d51aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:25:24 +0000 Subject: [PATCH 3/3] fix: remove ~/work/openclaw; restore ~/.openclaw/workspace* auto-discovery Agent-Logs-Url: https://github.com/thedavidweng/hbackup/sessions/fc9122ea-08de-43d6-b478-99e48307cb44 Co-authored-by: thedavidweng <95214375+thedavidweng@users.noreply.github.com> --- src/paths.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/paths.rs b/src/paths.rs index b553a48..7b17c48 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -70,16 +70,6 @@ impl BackupPaths { let h = &self.home; let mut specs = Vec::new(); - // ~/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"), @@ -101,6 +91,27 @@ impl BackupPaths { mode: ScanMode::Selective(openclaw_subdirs), }); + // ~/.openclaw workspace dirs — auto-discover instead of hardcoding + let ws_subdirs: Vec = vec![ + ".openclaw", "memory", "skills", ".omx", ".pi", + "config", "hooks", "scripts", "lib", + ].into_iter().map(String::from).collect(); + let openclaw_base = h.join(".openclaw"); + if openclaw_base.exists() { + if let Ok(entries) = std::fs::read_dir(&openclaw_base) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("workspace") && entry.path().is_dir() { + specs.push(DirSpec { + path: entry.path(), + excludes: vec![], + mode: ScanMode::Selective(ws_subdirs.clone()), + }); + } + } + } + } + // ~/.openclaw-dev specs.push(DirSpec { path: h.join(".openclaw-dev"),