Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub struct BackupOptions {
pub dry_run: bool,
pub excludes: Vec<String>,
pub output: Option<PathBuf>,
pub extra_paths: Vec<PathBuf>,
pub exclude_paths: Vec<PathBuf>,
}

pub fn run_backup(opts: &BackupOptions) -> Result<PathBuf> {
Expand All @@ -22,7 +24,7 @@ pub fn run_backup(opts: &BackupOptions) -> Result<PathBuf> {

// 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();
Expand Down
39 changes: 38 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ enum Commands {
#[derive(Deserialize)]
struct Config {
upload: Option<UploadConfig>,
paths: Option<PathsConfig>,
}

#[derive(Deserialize, Default, Clone)]
struct PathsConfig {
/// Additional paths to include in every backup.
#[serde(default)]
extra: Vec<String>,
/// Paths to exclude from every backup (entire directory trees).
#[serde(default)]
exclude: Vec<String>,
}

#[derive(Deserialize)]
Expand All @@ -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<PathsConfig>) -> (Vec<PathBuf>, Vec<PathBuf>) {
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 } => {
Expand Down Expand Up @@ -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)?;

Expand Down
53 changes: 31 additions & 22 deletions src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,41 @@ impl BackupPaths {
Self { home }
}

pub fn discover(&self, extra_excludes: &[String]) -> Vec<PathBuf> {
pub fn discover(
&self,
extra_excludes: &[String],
extra_paths: &[std::path::PathBuf],
exclude_paths: &[std::path::PathBuf],
) -> Vec<PathBuf> {
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();
Expand All @@ -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"),
Expand Down Expand Up @@ -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)
}