diff --git a/Cargo.lock b/Cargo.lock index 5421a75..29f5aa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "6.2.0" +version = "0.6.6" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "6.2.0" +version = "0.6.6" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "6.2.0" +version = "0.6.6" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 61edef8..8837389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,6 @@ members = [ "breadd", "bread-cli", ] -# bread-sync is being extracted into its own project (see bread-sync/EXTRACTION.md). -# Excluded so it no longer builds, tests, or gates CI as part of bread. -exclude = [ - "bread-sync", -] resolver = "2" [workspace.dependencies] diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index c52cb9a..05e256c 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "6.2.0" +version = "0.6.6" edition = "2021" [[bin]] diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 034b2c1..f0b92a9 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Context, Result}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; /// Contents of `bread.module.toml`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -45,6 +45,67 @@ pub fn parse_source(source: &str) -> Result { } } +/// Validate that a module name is safe to join onto `modules_dir`. +/// +/// Module names ultimately come from untrusted input: a manifest file +/// (`bread.module.toml`, which could be crafted by anyone who hands the user +/// a "module" to install) or a raw CLI argument (`bread modules remove +/// `). Without this check, a name like `../../../../etc` or an +/// absolute path would let install/remove escape `modules_dir` entirely — +/// classic path traversal. Reject any name containing a path separator, +/// a `..` component, or that is otherwise not a single plain path segment. +fn validate_module_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("bread: module name must not be empty"); + } + let path = Path::new(name); + // A valid module name must be exactly one normal path component, e.g. + // it must not contain `/`, must not be `.`/`..`, and must not be an + // absolute path or reference a prefix/root. + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Normal(seg)), None) if seg == name => {} + _ => { + bail!( + "bread: invalid module name '{}' (must be a single path segment, \ + no '/', '..', or absolute paths)", + name + ); + } + } + Ok(()) +} + +/// Join `name` onto `modules_dir`, validating the name and verifying the +/// resulting path is still contained within `modules_dir`. +/// +/// This is defense in depth on top of [`validate_module_name`]: even if the +/// name passes the component check, we canonicalize the parent directory +/// and confirm the joined path's parent resolves to it before allowing any +/// filesystem operation on the result. +fn resolve_module_dir(name: &str, modules_dir: &Path) -> Result { + validate_module_name(name)?; + let dest = modules_dir.join(name); + + // Canonicalize modules_dir itself (it should exist by the time we're + // installing/removing/reading from it in practice; callers that need it + // pre-creation call fs::create_dir_all first). + if let Ok(canonical_root) = modules_dir.canonicalize() { + if let Some(parent) = dest.parent() { + if let Ok(canonical_parent) = parent.canonicalize() { + if canonical_parent != canonical_root { + bail!( + "bread: resolved module path '{}' escapes modules directory", + dest.display() + ); + } + } + } + } + + Ok(dest) +} + /// Install a module from a local directory into `modules_dir`. /// `source_str` is the original source string recorded in the manifest. pub fn install_from_local( @@ -65,7 +126,9 @@ pub fn install_from_local( manifest.source = source_str.to_string(); manifest.installed_at = Utc::now().to_rfc3339(); - let dest = modules_dir.join(&manifest.name); + fs::create_dir_all(modules_dir) + .with_context(|| format!("failed to create {}", modules_dir.display()))?; + let dest = resolve_module_dir(&manifest.name, modules_dir)?; if dest.exists() { fs::remove_dir_all(&dest) .with_context(|| format!("failed to remove existing module at {}", dest.display()))?; @@ -83,7 +146,7 @@ pub fn install_from_local( /// Remove a module directory from `modules_dir`. pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> { - let module_dir = modules_dir.join(name); + let module_dir = resolve_module_dir(name, modules_dir)?; if !module_dir.exists() { bail!("bread: module '{}' is not installed", name); } @@ -115,7 +178,8 @@ pub fn list_modules(modules_dir: &Path) -> Result> { /// Read a module manifest by name. pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result { - let manifest_path = modules_dir.join(name).join("bread.module.toml"); + let module_dir = resolve_module_dir(name, modules_dir)?; + let manifest_path = module_dir.join("bread.module.toml"); if !manifest_path.exists() { bail!("bread: module '{}' is not installed", name); } diff --git a/bread-cli/tests/modules.rs b/bread-cli/tests/modules.rs index 74022fe..225f2dc 100644 --- a/bread-cli/tests/modules.rs +++ b/bread-cli/tests/modules.rs @@ -103,6 +103,63 @@ fn list_reads_manifests_from_disk() { assert_eq!(modules[1].name, "beta"); } +#[test] +fn remove_rejects_path_traversal_name() { + let modules_tmp = TempDir::new().unwrap(); + // A sibling directory outside modules_dir that an attacker would want to delete. + let victim_parent = modules_tmp.path().parent().unwrap(); + let victim = victim_parent.join("victim-dir"); + fs::create_dir_all(&victim).unwrap(); + fs::write(victim.join("keepme.txt"), "do not delete").unwrap(); + + let result = modules_mgmt::remove_module("../victim-dir", modules_tmp.path()); + assert!(result.is_err(), "path traversal name must be rejected"); + assert!(victim.join("keepme.txt").exists(), "victim dir must survive"); + + let _ = fs::remove_dir_all(&victim); +} + +#[test] +fn remove_rejects_absolute_path_name() { + let modules_tmp = TempDir::new().unwrap(); + let result = modules_mgmt::remove_module("/etc/passwd", modules_tmp.path()); + assert!(result.is_err(), "absolute path name must be rejected"); +} + +#[test] +fn remove_rejects_name_with_slash() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "alpha", "1.0.0"); + let result = modules_mgmt::remove_module("alpha/../alpha", modules_tmp.path()); + assert!(result.is_err(), "name containing a slash must be rejected"); + // Original module must be untouched. + assert!(modules_tmp.path().join("alpha").exists()); +} + +#[test] +fn install_rejects_manifest_with_path_traversal_name() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + // Craft a manifest whose `name` field is a traversal attempt. + let manifest = r#"name = "../evil" +version = "1.0.0" +description = "malicious" +author = "attacker" +source = "/tmp/test" +installed_at = "" +"#; + fs::write(src_tmp.path().join("bread.module.toml"), manifest).unwrap(); + fs::write(src_tmp.path().join("init.lua"), "-- evil\n").unwrap(); + + let result = + modules_mgmt::install_from_local(src_tmp.path(), "test:evil", modules_tmp.path()); + assert!( + result.is_err(), + "install must reject a manifest name containing path traversal" + ); +} + #[test] fn manifest_written_correctly_on_install() { let src_tmp = TempDir::new().unwrap(); diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index b8012ec..43e17e8 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "6.2.0" +version = "0.6.6" edition = "2021" [dependencies] diff --git a/bread-shared/src/glob.rs b/bread-shared/src/glob.rs new file mode 100644 index 0000000..7440d9b --- /dev/null +++ b/bread-shared/src/glob.rs @@ -0,0 +1,169 @@ +//! Canonical dotted-segment glob matcher. +//! +//! This is the single implementation of bread's event-name glob semantics, +//! shared by real event dispatch (`breadd::core::subscriptions`) and the +//! IPC `--filter` path used by `bread events` (`breadd::ipc`). Previously +//! these lived as two independently hand-written copies that could drift +//! out of sync despite the docs claiming identical behavior; this module is +//! now the one place that logic is implemented and tested. +//! +//! Wildcard semantics (see `Documentation.md` / the API reference): +//! - `*` matches within a single dot-delimited segment (never crosses `.`). +//! - `**` matches zero or more segments, at any depth. +//! - `?` matches exactly one character, but never a `.`. + +/// Returns true if `event_name` matches the dotted glob `pattern`. +pub fn matches_pattern(pattern: &str, event_name: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix(".**") { + if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) { + return true; + } + } + + matches_glob(pattern.as_bytes(), event_name.as_bytes()) +} + +fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { + let mut idx = 2; + while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' { + idx += 2; + } + let rest = &pattern[idx..]; + if rest.is_empty() { + return true; + } + for offset in 0..=text.len() { + if matches_glob(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if matches_glob(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() || text[offset] == b'.' { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() || text[0] == b'.' { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_match() { + assert!(matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.connected" + )); + assert!(!matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.disconnected" + )); + } + + #[test] + fn single_segment_wildcard() { + assert!(matches_pattern("bread.device.*", "bread.device.foo")); + assert!(!matches_pattern( + "bread.device.*", + "bread.device.dock.connected" + )); + assert!(!matches_pattern("bread.device.*", "bread.device")); + } + + #[test] + fn recursive_wildcard() { + assert!(matches_pattern( + "bread.device.**", + "bread.device.dock.connected" + )); + assert!(matches_pattern("bread.**", "bread.device.dock.connected")); + assert!(matches_pattern("bread.**", "bread")); + } + + #[test] + fn single_char_wildcard() { + assert!(matches_pattern("bread.monitor.?", "bread.monitor.1")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.")); + } + + #[test] + fn star_does_not_cross_dot_segments() { + assert!(matches_pattern( + "bread.*.connected", + "bread.device.connected" + )); + assert!(!matches_pattern( + "bread.*.connected", + "bread.device.dock.connected" + )); + } + + #[test] + fn double_star_matches_zero_or_more_segments() { + assert!(matches_pattern("bread.**", "bread.a")); + assert!(matches_pattern("bread.**", "bread.a.b.c.d")); + } + + #[test] + fn empty_pattern_matches_only_empty_text() { + assert!(matches_pattern("", "")); + assert!(!matches_pattern("", "bread")); + } + + #[test] + fn empty_text_only_matches_wildcards() { + assert!(matches_pattern("**", "")); + assert!(!matches_pattern("bread.*", "")); + } + + #[test] + fn dot_double_star_matches_exact_prefix_with_zero_segments() { + assert!(matches_pattern("bread.device.**", "bread.device")); + } + + #[test] + fn dot_double_star_does_not_match_sibling_prefix() { + assert!(!matches_pattern("bread.device.**", "bread.devicex")); + assert!(!matches_pattern("bread.device.**", "bread.network.connected")); + } + + #[test] + fn mid_pattern_star_does_not_cross_dots() { + assert!(matches_pattern("bread.*.connected", "bread.alpha.connected")); + assert!(!matches_pattern( + "bread.*.connected", + "bread.alpha.beta.connected" + )); + } +} diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index bfbd481..06c2e83 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; +pub mod glob; + /// Identifies which adapter produced an event. /// /// The state engine uses this to choose a normalization strategy and the diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml deleted file mode 100644 index 15bb845..0000000 --- a/bread-sync/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "bread-sync" -version = "1.0.0" -edition = "2021" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true -git2.workspace = true -dirs.workspace = true -chrono.workspace = true -glob.workspace = true -toml = "0.8" -libc = "0.2" - -[dev-dependencies] -tempfile.workspace = true diff --git a/bread-sync/EXTRACTION.md b/bread-sync/EXTRACTION.md deleted file mode 100644 index 6dce450..0000000 --- a/bread-sync/EXTRACTION.md +++ /dev/null @@ -1,36 +0,0 @@ -# bread-sync — slated for extraction - -This crate is **no longer part of the `bread` workspace**. It is parked here -pending extraction into its own standalone project. - -## Why - -`bread`'s architecture deliberately scopes itself to a reactive automation -fabric — see the Non-Goals in `Overview.md`. State/dotfile synchronization -across machines is explicitly *out* of that scope. `bread-sync` grew into a -git-backed snapshot/restore + package + delegate-path manager, which is a -genuinely useful tool but a different product with a different lifecycle. It -was the one component pulling `bread`'s scope discipline out of shape, so it -is being spun out rather than removed (the code is good; it just doesn't -belong in this repo). - -## Status - -- Removed from the root `Cargo.toml` workspace (`members` → `exclude`). -- The `bread sync …` CLI subcommands have been removed from `bread-cli`. -- The `sync.status` IPC method and its integration tests have been removed - from `breadd`. -- No code in `bread`/`breadd`/`bread-cli` depends on this crate anymore. - -## For whoever extracts it (name polls are open) - -1. Move this directory into the new repository. -2. It inherited workspace dependencies (`serde`, `git2`, `dirs`, `chrono`, - `tempfile`, `glob`, …). Pin concrete versions in its own `Cargo.toml`; - `*.workspace = true` will not resolve outside this workspace. -3. The only helper that had to leave this crate is `config::expand_path`, - which moved to `bread-shared::expand_path` because non-sync code (the - module installer) needed it. Reintroduce a local copy in the new project - so it no longer depends on `bread-shared`. -4. Re-add the `bread sync` UX as a standalone binary, or as a `breadd` IPC - client, in the new project — not here. diff --git a/bread-sync/README.md b/bread-sync/README.md deleted file mode 100644 index 7d37899..0000000 --- a/bread-sync/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# bread-sync - -Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote. - -## Purpose - -`bread-sync` provides the library backing `bread sync` commands. It handles: - -- **Git operations** — clone, commit, push, pull, fetch, diff via `git2` -- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages) -- **Delegate file sync** — rsync-style directory copy with glob excludes -- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo -- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp - -## Public API - -### `config` - -```rust -SyncConfig::load(config_dir: &Path) -> Result -SyncConfig::save(&self, config_dir: &Path) -> Result<()> -SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/ -bread_config_dir() -> PathBuf // ~/.config/bread/ -expand_path(path: &str) -> PathBuf // expands ~/ -``` - -### `git` - -```rust -SyncRepo::init(path: &Path) -> Result -SyncRepo::open(path: &Path) -> Result -SyncRepo::clone_from(url: &str, path: &Path) -> Result -SyncRepo::open_or_clone(url: &str, path: &Path) -> Result -SyncRepo::commit(&self, message: &str) -> Result> // None = nothing to commit -SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()> -SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only -SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()> -SyncRepo::is_clean(&self) -> Result -SyncRepo::local_changes(&self) -> Result> -SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result> -SyncRepo::working_diff(&self) -> Result -SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result -SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()> -SyncRepo::last_commit_time(&self) -> Option> -``` - -### `delegates` - -```rust -sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> -resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> -``` - -### `machine` - -```rust -MachineProfile::new(name: String, tags: Vec) -> MachineProfile -MachineProfile::write(&self, machines_dir: &Path) -> Result<()> -MachineProfile::read(machines_dir: &Path, name: &str) -> Result -MachineProfile::list(machines_dir: &Path) -> Result> -hostname() -> String -``` - -### `packages` - -```rust -snapshot(manager: &str, dest: &Path) -> Result // false = manager not found (non-fatal) -parse_pacman(content: &str) -> Vec -parse_pip(content: &str) -> Vec -parse_npm(content: &str) -> Vec -parse_cargo(content: &str) -> Vec -``` - -## Sync repo layout - -``` -~/.local/share/bread/sync-repo/ -├── bread/ ← snapshot of ~/.config/bread/ -├── configs/ -│ └── / ← delegate paths -├── machines/ -│ └── .toml ← per-machine profiles -└── packages/ - ├── pacman.txt - ├── pip.txt - ├── npm.txt - └── cargo.txt -``` diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs deleted file mode 100644 index 9760449..0000000 --- a/bread-sync/src/config.rs +++ /dev/null @@ -1,259 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; - -/// Configuration stored in `~/.config/bread/sync.toml`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncConfig { - pub remote: RemoteConfig, - pub machine: MachineConfig, - #[serde(default)] - pub packages: PackagesConfig, - #[serde(default)] - pub delegates: DelegatesConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteConfig { - pub url: String, - #[serde(default = "default_branch")] - pub branch: String, -} - -fn default_branch() -> String { - "main".to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MachineConfig { - pub name: String, - #[serde(default)] - pub tags: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PackagesConfig { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default)] - pub managers: Vec, -} - -fn default_true() -> bool { - true -} - -impl Default for PackagesConfig { - fn default() -> Self { - Self { - enabled: true, - managers: vec![ - "pacman".to_string(), - "aur".to_string(), - "pip".to_string(), - "npm".to_string(), - "cargo".to_string(), - ], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DelegatesConfig { - #[serde(default)] - pub include: Vec, - #[serde(default)] - pub exclude: Vec, -} - -impl SyncConfig { - /// Load sync config from the given bread config directory. - pub fn load(config_dir: &Path) -> Result { - let path = config_dir.join("sync.toml"); - let raw = fs::read_to_string(&path) - .with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?; - toml::from_str(&raw).context("failed to parse sync.toml") - } - - /// Save sync config to the given bread config directory. - pub fn save(&self, config_dir: &Path) -> Result<()> { - let path = config_dir.join("sync.toml"); - fs::create_dir_all(config_dir)?; - let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?; - fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) - } - - /// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`). - pub fn local_repo_path() -> PathBuf { - if let Some(data_dir) = dirs::data_dir() { - return data_dir.join("bread").join("sync-repo"); - } - // Fallback using $HOME - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home) - .join(".local") - .join("share") - .join("bread") - .join("sync-repo"); - } - PathBuf::from(".local/share/bread/sync-repo") - } -} - -/// Returns the bread config directory (`~/.config/bread/`). -pub fn bread_config_dir() -> PathBuf { - if let Some(cfg) = dirs::config_dir() { - return cfg.join("bread"); - } - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - return PathBuf::from(xdg).join("bread"); - } - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(".config").join("bread"); - } - PathBuf::from(".config/bread") -} - -/// Expand `~` to the home directory in a path string. -pub fn expand_path(path: &str) -> PathBuf { - if path == "~" { - if let Some(home) = dirs::home_dir() { - return home; - } - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home); - } - } else if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(rest); - } - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(rest); - } - } - PathBuf::from(path) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn sample_config() -> SyncConfig { - SyncConfig { - remote: RemoteConfig { - url: "git@github.com:user/repo.git".to_string(), - branch: "main".to_string(), - }, - machine: MachineConfig { - name: "host".to_string(), - tags: vec!["mobile".to_string()], - }, - packages: PackagesConfig::default(), - delegates: DelegatesConfig::default(), - } - } - - #[test] - fn save_and_load_round_trip() { - let tmp = TempDir::new().unwrap(); - let cfg = sample_config(); - cfg.save(tmp.path()).unwrap(); - - assert!(tmp.path().join("sync.toml").exists()); - - let loaded = SyncConfig::load(tmp.path()).unwrap(); - assert_eq!(loaded.remote.url, cfg.remote.url); - assert_eq!(loaded.remote.branch, cfg.remote.branch); - assert_eq!(loaded.machine.name, cfg.machine.name); - assert_eq!(loaded.machine.tags, cfg.machine.tags); - } - - #[test] - fn load_missing_config_returns_helpful_error() { - let tmp = TempDir::new().unwrap(); - let err = SyncConfig::load(tmp.path()).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("sync not initialized") || msg.contains("bread sync init"), - "expected init hint, got: {msg}", - ); - } - - #[test] - fn load_invalid_toml_returns_parse_error() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap(); - let err = SyncConfig::load(tmp.path()).unwrap_err(); - let msg = format!("{err:#}"); - assert!(msg.to_lowercase().contains("parse"), "got: {msg}"); - } - - #[test] - fn packages_config_default_includes_all_managers() { - let cfg = PackagesConfig::default(); - assert!(cfg.enabled); - assert!(cfg.managers.contains(&"pacman".to_string())); - assert!(cfg.managers.contains(&"aur".to_string())); - assert!(cfg.managers.contains(&"pip".to_string())); - assert!(cfg.managers.contains(&"npm".to_string())); - assert!(cfg.managers.contains(&"cargo".to_string())); - } - - #[test] - fn remote_branch_defaults_to_main_when_omitted() { - let raw = r#" -[remote] -url = "git@example.com:r.git" - -[machine] -name = "host" -"#; - let cfg: SyncConfig = toml::from_str(raw).unwrap(); - assert_eq!(cfg.remote.branch, "main"); - } - - #[test] - fn delegates_default_is_empty() { - let cfg = DelegatesConfig::default(); - assert!(cfg.include.is_empty()); - assert!(cfg.exclude.is_empty()); - } - - #[test] - fn local_repo_path_resolves_to_data_dir() { - let path = SyncConfig::local_repo_path(); - // Must include the bread sync-repo segment at the end. - let suffix = path.iter().rev().take(2).collect::>(); - assert_eq!( - suffix, - vec![ - std::ffi::OsStr::new("sync-repo"), - std::ffi::OsStr::new("bread") - ] - ); - } - - #[test] - fn expand_path_passes_through_absolute_paths() { - assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread")); - assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path")); - } - - #[test] - fn expand_path_expands_tilde_alone_to_home() { - let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); - if let Some(home) = home { - assert_eq!(expand_path("~"), home); - } - } - - #[test] - fn expand_path_expands_tilde_prefix() { - let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); - if let Some(home) = home { - assert_eq!(expand_path("~/.config"), home.join(".config")); - } - } -} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs deleted file mode 100644 index 815e87b..0000000 --- a/bread-sync/src/delegates.rs +++ /dev/null @@ -1,247 +0,0 @@ -use anyhow::Result; -use glob::Pattern; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::config::expand_path; - -/// Copy all files from `src` into `dst`, mirroring the directory tree. -/// Files present in `dst` but not in `src` are deleted (rsync-style). -/// Files matching any `exclude` glob are skipped. -pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> { - let patterns: Vec = exclude - .iter() - .filter_map(|g| Pattern::new(g).ok()) - .collect(); - - fs::create_dir_all(dst)?; - sync_dir_inner(src, dst, src, &patterns) -} - -fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> { - // Remove files in dst that don't exist in src. - if dst.exists() { - for entry in fs::read_dir(dst)? { - let entry = entry?; - let rel = entry - .path() - .strip_prefix(dst) - .unwrap_or(&entry.path()) - .to_path_buf(); - let src_counterpart = src.join(&rel); - if !src_counterpart.exists() { - let p = entry.path(); - if p.is_dir() { - let _ = fs::remove_dir_all(&p); - } else { - let _ = fs::remove_file(&p); - } - } - } - } - - if !src.exists() { - return Ok(()); - } - - for entry in fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let rel = src_path.strip_prefix(root).unwrap_or(&src_path); - - if is_excluded(rel, root, patterns) { - continue; - } - - let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path)); - - if src_path.is_dir() { - fs::create_dir_all(&dst_path)?; - sync_dir_inner(&src_path, &dst_path, root, patterns)?; - } else { - if let Some(parent) = dst_path.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) -} - -fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool { - let rel_str = rel.to_string_lossy(); - let file_name = rel - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - - for pat in patterns { - // Match against full relative path or just filename - if pat.matches(&rel_str) || pat.matches(&file_name) { - return true; - } - // For directory-name patterns (e.g. "**/.git"), also check component names - if let Some(pat_str) = pat.as_str().strip_prefix("**/") { - for component in rel.components() { - if let std::path::Component::Normal(name) = component { - if Pattern::new(pat_str) - .map(|p| p.matches(&name.to_string_lossy())) - .unwrap_or(false) - { - return true; - } - } - } - } - } - false -} - -/// Resolve delegate paths from the config (expanding `~`). -pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { - includes - .iter() - .map(|s| { - let expanded = expand_path(s); - let basename = expanded - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| s.clone()); - (basename, expanded) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn sync_dir_copies_nested_tree() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - - fs::create_dir_all(src.path().join("a/b/c")).unwrap(); - fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap(); - fs::write(src.path().join("root.txt"), "root").unwrap(); - - sync_dir(src.path(), dst.path(), &[]).unwrap(); - - assert_eq!( - fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(), - "hello" - ); - assert_eq!( - fs::read_to_string(dst.path().join("root.txt")).unwrap(), - "root" - ); - } - - #[test] - fn sync_dir_overwrites_existing_files() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::write(src.path().join("f"), "new").unwrap(); - fs::write(dst.path().join("f"), "old").unwrap(); - - sync_dir(src.path(), dst.path(), &[]).unwrap(); - assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new"); - } - - #[test] - fn sync_dir_removes_files_no_longer_in_src() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::write(dst.path().join("orphan.txt"), "to remove").unwrap(); - fs::write(src.path().join("keeper.txt"), "stay").unwrap(); - - sync_dir(src.path(), dst.path(), &[]).unwrap(); - - assert!(!dst.path().join("orphan.txt").exists()); - assert!(dst.path().join("keeper.txt").exists()); - } - - #[test] - fn sync_dir_removes_directories_no_longer_in_src() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::create_dir_all(dst.path().join("ghost-dir")).unwrap(); - fs::write(dst.path().join("ghost-dir/x"), "").unwrap(); - - sync_dir(src.path(), dst.path(), &[]).unwrap(); - assert!(!dst.path().join("ghost-dir").exists()); - } - - #[test] - fn sync_dir_exclude_filters_by_basename_pattern() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::write(src.path().join("keep.lua"), "lua").unwrap(); - fs::write(src.path().join("trash.cache"), "").unwrap(); - - sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap(); - assert!(dst.path().join("keep.lua").exists()); - assert!(!dst.path().join("trash.cache").exists()); - } - - #[test] - fn sync_dir_exclude_filters_nested_directory_by_name() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::create_dir_all(src.path().join(".git/objects")).unwrap(); - fs::write(src.path().join(".git/objects/abc"), "").unwrap(); - fs::write(src.path().join("init.lua"), "lua").unwrap(); - - sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap(); - assert!(dst.path().join("init.lua").exists()); - assert!(!dst.path().join(".git").exists()); - } - - #[test] - fn sync_dir_creates_destination_if_missing() { - let src = TempDir::new().unwrap(); - let dst_parent = TempDir::new().unwrap(); - let dst = dst_parent.path().join("brand-new"); - fs::write(src.path().join("hi"), "hi").unwrap(); - - sync_dir(src.path(), &dst, &[]).unwrap(); - assert!(dst.join("hi").exists()); - } - - #[test] - fn sync_dir_empty_src_clears_dst() { - let src = TempDir::new().unwrap(); - let dst = TempDir::new().unwrap(); - fs::write(dst.path().join("a"), "").unwrap(); - fs::write(dst.path().join("b"), "").unwrap(); - - sync_dir(src.path(), dst.path(), &[]).unwrap(); - let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect(); - assert!(remaining.is_empty()); - } - - // ─── resolve_include_paths ──────────────────────────────────────────── - - #[test] - fn resolve_include_paths_uses_basename_as_key() { - let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()]; - let resolved = resolve_include_paths(&includes); - assert_eq!(resolved.len(), 2); - assert_eq!(resolved[0].0, "bar"); - assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar")); - assert_eq!(resolved[1].0, "quux"); - } - - #[test] - fn resolve_include_paths_expands_tilde_in_source() { - let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); - if let Some(home) = home { - let resolved = resolve_include_paths(&["~/Documents".to_string()]); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].1, home.join("Documents")); - assert_eq!(resolved[0].0, "Documents"); - } - } -} diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs deleted file mode 100644 index ae75bb4..0000000 --- a/bread-sync/src/export.rs +++ /dev/null @@ -1,879 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::Utc; -use git2::Repository; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::config::{expand_path, SyncConfig}; -use crate::delegates::sync_dir; -use crate::machine::{hostname, MachineProfile}; -use crate::packages; - -/// Maps a staged path back to the original absolute path on the source machine. -/// Drives the import — no hardcoded paths needed. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathRecord { - /// Relative path within the export (e.g. "configs/hypr"). - pub staging: String, - /// Original path with `~` (e.g. "~/.config/hypr"). - pub original: String, - /// Whether this is a single file (false = directory). - #[serde(default)] - pub is_file: bool, -} - -/// A git repository found on the machine, keyed by its remote URL. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GitRepoRecord { - /// Path relative to $HOME (e.g. "Projects/bread"). - pub path: String, - /// Remote URL (e.g. "https://github.com/Breadway/bread.git"). - pub remote: String, - /// Branch that was checked out at export time. - pub branch: String, -} - -/// Manifest stored in the export root as `manifest.toml`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExportManifest { - pub version: u32, - pub machine: String, - pub hostname: String, - pub exported_at: String, - /// Explicit staging→original path map for all captured items. - #[serde(default)] - pub path_map: Vec, - /// High-level list of config dir names (for display). - pub configs: Vec, - /// Git repos found on the source machine. - #[serde(default)] - pub repos: Vec, - pub system: bool, - pub packages: Vec, - // Legacy fields kept for forward compat (ignored on import) - #[serde(default)] - pub bread: bool, - #[serde(default)] - pub dotfiles: Vec, - #[serde(default)] - pub local_bin: Vec, - #[serde(default)] - pub systemd_units: Vec, -} - -/// Config directories always included in the export (if they exist on disk). -static BUILTIN_CONFIGS: &[(&str, &str)] = &[ - ("hypr", "~/.config/hypr"), - ("fish", "~/.config/fish"), - ("kitty", "~/.config/kitty"), - ("nvim", "~/.config/nvim"), - ("ags", "~/.config/ags"), - ("wofi", "~/.config/wofi"), - ("waybar", "~/.config/waybar"), - ("dunst", "~/.config/dunst"), - ("mako", "~/.config/mako"), - ("hyprlock", "~/.config/hyprlock"), - ("hyprpaper", "~/.config/hyprpaper"), - ("swaylock", "~/.config/swaylock"), - ("wlogout", "~/.config/wlogout"), - ("swappy", "~/.config/swappy"), - ("btop", "~/.config/btop"), - ("waypaper", "~/.config/waypaper"), - ("wal", "~/.config/wal"), - ("gtk-3.0", "~/.config/gtk-3.0"), - ("gtk-4.0", "~/.config/gtk-4.0"), - ("keyd", "~/.config/keyd"), - ("autostart", "~/.config/autostart"), -]; - -/// Standalone dotfiles captured as individual files: (staging-name, source-path). -static BUILTIN_DOTFILES: &[(&str, &str)] = &[ - (".gitconfig", "~/.gitconfig"), - ("user-dirs.dirs", "~/.config/user-dirs.dirs"), - ("mimeapps.list", "~/.config/mimeapps.list"), - ("ssh_config", "~/.ssh/config"), - (".zshrc", "~/.zshrc"), - (".zprofile", "~/.zprofile"), - (".zshenv", "~/.zshenv"), -]; - -/// System-level directories. World-readable ones are copied directly; -/// root-only ones (networkmanager, bluetooth) require running with sudo. -static SYSTEM_PATHS: &[(&str, &str)] = &[ - ("udev", "/etc/udev/rules.d"), - ("modprobe", "/etc/modprobe.d"), - ("sysctl", "/etc/sysctl.d"), - ("networkmanager", "/etc/NetworkManager/system-connections"), - ("bluetooth", "/var/lib/bluetooth"), -]; - -/// Directories excluded from every recursive copy. -static DEFAULT_EXCLUDES: &[&str] = &[ - "**/.git", - "**/*.cache", - "**/node_modules", - "**/@girs", - "**/__pycache__", - "fish_variables?*", -]; - -/// Directories skipped when searching for git repos. -static GIT_SKIP_DIRS: &[&str] = &[ - ".local", - "Nextcloud", - "target", - "node_modules", - "__pycache__", - ".cache", - "snap", - "flatpak", - "@girs", - "Steam", -]; - -// ── stage_export ──────────────────────────────────────────────────────────── - -/// Build a self-contained snapshot directory at `staging`. -pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Result { - fs::create_dir_all(staging)?; - - let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); - let mut path_map: Vec = Vec::new(); - let mut included_configs: Vec = Vec::new(); - - // Helper: tilde-ify an absolute path for storage in the manifest. - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); - let tilde = |p: &Path| -> String { - p.strip_prefix(&home) - .map(|rel| format!("~/{}", rel.display())) - .unwrap_or_else(|_| p.display().to_string()) - }; - - // 1. Bread config → bread/ - let bread_dest = staging.join("bread"); - sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?; - path_map.push(PathRecord { - staging: "bread".to_string(), - original: tilde(cfg_dir), - is_file: false, - }); - - // 2. Built-in + delegate configs → configs// - let configs_dir = staging.join("configs"); - - for (name, raw_path) in BUILTIN_CONFIGS { - let src = expand_path(raw_path); - if src.exists() { - let dst = configs_dir.join(name); - sync_dir(&src, &dst, &excludes) - .with_context(|| format!("failed to snapshot {raw_path}"))?; - path_map.push(PathRecord { - staging: format!("configs/{name}"), - original: raw_path.to_string(), - is_file: false, - }); - included_configs.push(name.to_string()); - } - } - - let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include); - for (basename, src_path) in &delegate_paths { - if src_path.exists() && !included_configs.contains(basename) { - let dst = configs_dir.join(basename); - sync_dir(src_path, &dst, &config.delegates.exclude) - .with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?; - path_map.push(PathRecord { - staging: format!("configs/{basename}"), - original: tilde(src_path), - is_file: false, - }); - included_configs.push(basename.clone()); - } - } - - // 3. Dotfiles → dotfiles/ - let dotfiles_dir = staging.join("dotfiles"); - fs::create_dir_all(&dotfiles_dir)?; - - for (dest_name, raw_path) in BUILTIN_DOTFILES { - let src = expand_path(raw_path); - if src.exists() { - fs::copy(&src, dotfiles_dir.join(dest_name)) - .with_context(|| format!("failed to copy {raw_path}"))?; - path_map.push(PathRecord { - staging: format!("dotfiles/{dest_name}"), - original: raw_path.to_string(), - is_file: true, - }); - } - } - - // 4. ~/.local/bin custom scripts → local-bin/ - // Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts). - let local_bin_src = expand_path("~/.local/bin"); - let local_bin_dst = staging.join("local-bin"); - if local_bin_src.exists() { - fs::create_dir_all(&local_bin_dst)?; - let mut any = false; - for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? { - let entry = entry?; - let meta = entry.metadata()?; - if meta.file_type().is_symlink() || meta.len() > 512 * 1024 { - continue; - } - let path = entry.path(); - if path.is_file() { - let name = path.file_name().unwrap().to_string_lossy().to_string(); - fs::copy(&path, local_bin_dst.join(&name))?; - any = true; - } - } - if any { - path_map.push(PathRecord { - staging: "local-bin".to_string(), - original: "~/.local/bin".to_string(), - is_file: false, - }); - } - } - - // 5. ~/.local/share/fonts → local-fonts/ - let fonts_src = expand_path("~/.local/share/fonts"); - let fonts_dst = staging.join("local-fonts"); - if fonts_src.exists() { - sync_dir(&fonts_src, &fonts_dst, &excludes).context("failed to snapshot fonts")?; - path_map.push(PathRecord { - staging: "local-fonts".to_string(), - original: "~/.local/share/fonts".to_string(), - is_file: false, - }); - } - - // 7. ~/.config/systemd/user → systemd/ - let systemd_src = expand_path("~/.config/systemd/user"); - let systemd_dst = staging.join("systemd"); - if systemd_src.exists() { - sync_dir(&systemd_src, &systemd_dst, &excludes) - .context("failed to snapshot systemd user units")?; - path_map.push(PathRecord { - staging: "systemd".to_string(), - original: "~/.config/systemd/user".to_string(), - is_file: false, - }); - } - - // 8. System configs → system/ (read-only; restore needs sudo) - let system_dst = staging.join("system"); - let mut has_system = false; - for (name, raw_path) in SYSTEM_PATHS { - let src = PathBuf::from(raw_path); - if !src.exists() { - continue; - } - match sync_dir(&src, &system_dst.join(name), &excludes) { - Ok(_) => has_system = true, - Err(e) => { - let msg = e.to_string(); - if msg.contains("Permission denied") || msg.contains("permission denied") { - eprintln!( - "bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)" - ); - } else { - eprintln!("bread: warning: failed to snapshot {raw_path}: {e}"); - } - } - } - } - - // 9. Package snapshots → packages/ - let packages_dir = staging.join("packages"); - let mut included_managers: Vec = Vec::new(); - if config.packages.enabled { - for manager in &config.packages.managers { - let dest_file = packages_dir.join(format!("{manager}.txt")); - match packages::snapshot(manager, &dest_file) { - Ok(true) => included_managers.push(manager.clone()), - Ok(false) => {} - Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"), - } - } - } - - // 10. Machine profile → machines/ - let machines_dir = staging.join("machines"); - MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) - .write(&machines_dir)?; - - // 11. Git repositories — find all repos with a remote, commit+push each - let nc_dirs = nextcloud_sync_dirs(&home); - if !nc_dirs.is_empty() { - let labels: Vec<_> = nc_dirs - .iter() - .map(|p| { - p.strip_prefix(&home) - .map(|r| format!("~/{}", r.display())) - .unwrap_or_else(|_| p.display().to_string()) - }) - .collect(); - eprintln!( - "bread: skipping Nextcloud-tracked folders: {}", - labels.join(", ") - ); - } - let repos = find_git_repos(&home); - commit_and_push_repos(&repos, &home); - - // 12. Manifest - let manifest = ExportManifest { - version: 2, - machine: config.machine.name.clone(), - hostname: hostname(), - exported_at: Utc::now().to_rfc3339(), - path_map, - configs: included_configs, - repos, - system: has_system, - packages: included_managers, - bread: true, - dotfiles: vec![], - local_bin: vec![], - systemd_units: vec![], - }; - fs::write( - staging.join("manifest.toml"), - toml::to_string_pretty(&manifest).context("failed to serialize manifest")?, - )?; - - // 11. restore.sh - let restore_path = staging.join("restore.sh"); - fs::write(&restore_path, generate_restore_sh(&manifest))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&restore_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&restore_path, perms)?; - } - - Ok(manifest) -} - -// ── apply_import ──────────────────────────────────────────────────────────── - -/// Apply a staged snapshot directory to this machine. -/// Returns a list of human-readable descriptions of what was applied. -pub fn apply_import( - staging: &Path, - cfg_dir: &Path, - install_packages: bool, - clone_repos: bool, -) -> Result> { - let mut applied: Vec = Vec::new(); - - // Read manifest to get the path map - let manifest_path = staging.join("manifest.toml"); - let path_map: Vec = if manifest_path.exists() { - let raw = fs::read_to_string(&manifest_path)?; - toml::from_str::(&raw) - .map(|m| m.path_map) - .unwrap_or_default() - } else { - vec![] - }; - - if !path_map.is_empty() { - // Manifest-driven restore: use path_map for exact original locations - for record in &path_map { - let src = staging.join(&record.staging); - if !src.exists() { - continue; - } - let dst = expand_path(&record.original); - - if record.is_file { - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent)?; - } - // Secure directory permissions for SSH - if record.staging.contains("ssh_config") { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Some(p) = dst.parent() { - if let Ok(m) = fs::metadata(p) { - let mut perms = m.permissions(); - perms.set_mode(0o700); - let _ = fs::set_permissions(p, perms); - } - } - } - } - fs::copy(&src, &dst) - .with_context(|| format!("failed to restore {}", record.original))?; - applied.push(record.original.clone()); - } else { - sync_dir(&src, &dst, &[]) - .with_context(|| format!("failed to restore {}", record.original))?; - applied.push(record.original.clone()); - - // Reload systemd if this was the systemd dir - if record.staging == "systemd" { - let _ = std::process::Command::new("systemctl") - .args(["--user", "daemon-reload"]) - .status(); - } - - // Rebuild font cache after restoring fonts - if record.staging == "local-fonts" { - let _ = std::process::Command::new("fc-cache").arg("-f").status(); - } - - // Make local-bin scripts executable - if record.staging == "local-bin" { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(entries) = fs::read_dir(&dst) { - for entry in entries.filter_map(|e| e.ok()) { - if entry.path().is_file() { - if let Ok(m) = fs::metadata(entry.path()) { - let mut perms = m.permissions(); - perms.set_mode(perms.mode() | 0o111); - let _ = fs::set_permissions(entry.path(), perms); - } - } - } - } - } - } - } - } - } else { - // Legacy fallback for v1 exports without path_map - let bread_src = staging.join("bread"); - if bread_src.exists() { - sync_dir(&bread_src, cfg_dir, &[])?; - applied.push("~/.config/bread".to_string()); - } - let configs_dir = staging.join("configs"); - if configs_dir.exists() { - let config_home = expand_path("~/.config"); - for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) { - let src = entry.path(); - if src.is_dir() { - let name = src.file_name().unwrap().to_string_lossy().to_string(); - sync_dir(&src, &config_home.join(&name), &[])?; - applied.push(format!("~/.config/{name}")); - } - } - } - } - - // Package installs - if install_packages { - let packages_dir = staging.join("packages"); - if packages_dir.exists() { - install_packages_from(&packages_dir)?; - applied.push("packages installed".to_string()); - } - } - - // Clone git repos - if clone_repos { - let manifest_path = staging.join("manifest.toml"); - if manifest_path.exists() { - let raw = fs::read_to_string(&manifest_path)?; - if let Ok(manifest) = toml::from_str::(&raw) { - let home = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default())); - for repo in &manifest.repos { - let dest = home.join(&repo.path); - if dest.exists() { - applied.push(format!("skip (exists): ~/{}", repo.path)); - continue; - } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent)?; - } - eprint!(" cloning ~/{} ... ", repo.path); - let status = std::process::Command::new("git") - .args(["clone", "--branch", &repo.branch, &repo.remote]) - .arg(&dest) - .status(); - match status { - Ok(s) if s.success() => { - eprintln!("done"); - applied.push(format!("cloned ~/{}", repo.path)); - } - _ => { - eprintln!("failed"); - applied.push(format!("clone failed: ~/{}", repo.path)); - } - } - } - } - } - } - - Ok(applied) -} - -// ── commit_and_push_repos ─────────────────────────────────────────────────── - -fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { - if repos.is_empty() { - return; - } - eprintln!("bread: committing and pushing {} repo(s)...", repos.len()); - for repo in repos { - let dir = home.join(&repo.path); - let dir_str = dir.to_string_lossy(); - - // Stage all changes - let add = std::process::Command::new("git") - .args(["-C", &dir_str, "add", "-A"]) - .output(); - if add.map(|o| !o.status.success()).unwrap_or(true) { - eprintln!(" ~/{}: git add failed, skipping", repo.path); - continue; - } - - // Check if there's anything staged - let has_changes = std::process::Command::new("git") - .args(["-C", &dir_str, "diff", "--cached", "--quiet"]) - .status() - .map(|s| !s.success()) - .unwrap_or(false); - - if has_changes { - let commit = std::process::Command::new("git") - .args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"]) - .output(); - match commit { - Ok(o) if o.status.success() => {} - Ok(o) => { - eprintln!( - " ~/{}: commit failed: {}", - repo.path, - String::from_utf8_lossy(&o.stderr).trim() - ); - continue; - } - Err(e) => { - eprintln!(" ~/{}: commit failed: {}", repo.path, e); - continue; - } - } - } - - // Push - eprint!(" ~/{}: pushing... ", repo.path); - let push = std::process::Command::new("git") - .args(["-C", &dir_str, "push"]) - .output(); - match push { - Ok(o) if o.status.success() => eprintln!("ok"), - Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), - Err(e) => eprintln!("failed: {}", e), - } - } -} - -// ── find_git_repos ────────────────────────────────────────────────────────── - -/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots. -/// Always includes ~/Nextcloud if it exists, even without a config file. -fn nextcloud_sync_dirs(home: &Path) -> Vec { - let mut dirs: Vec = Vec::new(); - - let cfg = home.join(".config/Nextcloud/nextcloud.cfg"); - if let Ok(content) = fs::read_to_string(&cfg) { - for line in content.lines() { - if let Some(raw) = line.trim().strip_prefix("localPath=") { - let p = PathBuf::from(raw); - let p = if p.is_absolute() { p } else { home.join(p) }; - if !dirs.contains(&p) { - dirs.push(p); - } - } - } - } - - // Always treat ~/Nextcloud as off-limits if it exists - let default_nc = home.join("Nextcloud"); - if default_nc.exists() && !dirs.contains(&default_nc) { - dirs.push(default_nc); - } - - dirs -} - -fn find_git_repos(home: &Path) -> Vec { - let nc_dirs = nextcloud_sync_dirs(home); - let mut repos: Vec = Vec::new(); - - // Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell) - walk_repos(home, home, 0, 1, &mut repos, &nc_dirs); - - // Deeper search in common project directories - for subdir in &[ - "Projects", - "Documents", - "src", - "dev", - "code", - "repos", - "builds", - ] { - let p = home.join(subdir); - if p.exists() { - walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs); - } - } - - // .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf) - let config_dir = home.join(".config"); - if config_dir.exists() { - walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs); - } - - // Deduplicate by path, sort for determinism - repos.sort_by(|a, b| a.path.cmp(&b.path)); - repos.dedup_by(|a, b| a.path == b.path); - repos -} - -fn walk_repos( - dir: &Path, - home: &Path, - depth: u32, - max_depth: u32, - repos: &mut Vec, - nc_dirs: &[PathBuf], -) { - // Skip anything inside a Nextcloud sync root - if nc_dirs.iter().any(|nc| dir.starts_with(nc)) { - return; - } - - if dir.join(".git").exists() { - if let Ok(repo) = Repository::open(dir) { - let remote_url = repo - .find_remote("origin") - .ok() - .and_then(|r| r.url().map(str::to_string)); - - if let Some(remote) = remote_url { - let branch = repo - .head() - .ok() - .and_then(|h| h.shorthand().map(str::to_string)) - .unwrap_or_else(|| "main".to_string()); - - let rel = dir - .strip_prefix(home) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| dir.to_string_lossy().to_string()); - - repos.push(GitRepoRecord { - path: rel, - remote, - branch, - }); - } - } - return; // don't recurse into git repos (skip submodules) - } - - if depth >= max_depth { - return; - } - - if let Ok(entries) = fs::read_dir(dir) { - let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); - entries.sort_by_key(|e| e.file_name()); - - for entry in entries { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let name = path.file_name().unwrap_or_default().to_string_lossy(); - if GIT_SKIP_DIRS.contains(&name.as_ref()) { - continue; - } - walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs); - } - } -} - -// ── package install ───────────────────────────────────────────────────────── - -fn install_packages_from(packages_dir: &Path) -> Result<()> { - let pacman_file = packages_dir.join("pacman.txt"); - if pacman_file.exists() { - let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?); - if !pkgs.is_empty() { - eprintln!("bread: installing {} pacman packages...", pkgs.len()); - let _ = std::process::Command::new("sudo") - .args(["pacman", "-S", "--needed"]) - .args(&pkgs) - .status(); - } - } - let cargo_file = packages_dir.join("cargo.txt"); - if cargo_file.exists() { - for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) { - let _ = std::process::Command::new("cargo") - .args(["install", &pkg]) - .status(); - } - } - let pip_file = packages_dir.join("pip.txt"); - if pip_file.exists() { - let _ = std::process::Command::new("pip") - .args(["install", "--user", "-r"]) - .arg(&pip_file) - .status(); - } - let npm_file = packages_dir.join("npm.txt"); - if npm_file.exists() { - for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) { - let _ = std::process::Command::new("npm") - .args(["install", "-g", &pkg]) - .status(); - } - } - Ok(()) -} - -// ── restore.sh ─────────────────────────────────────────────────────────────── - -fn generate_restore_sh(manifest: &ExportManifest) -> String { - let ts = &manifest.exported_at[..16]; - let mut s = String::new(); - - s.push_str("#!/bin/bash\n"); - s.push_str("set -e\n"); - s.push_str("cd \"$(dirname \"$0\")\"\n"); - s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n"); - s.push_str(&format!( - "echo \"Restoring bread snapshot for {} ({})\"\n\n", - manifest.machine, ts - )); - - // Config dirs and dotfiles from path_map - let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect(); - let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect(); - - if !dirs.is_empty() { - s.push_str("# configs and directories\n"); - for r in &dirs { - let dst = &r.original; - let src = &r.staging; - s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n")); - s.push_str(&format!(" mkdir -p \"{dst}\"\n")); - s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n")); - if r.staging == "systemd" { - s.push_str(" systemctl --user daemon-reload\n"); - } - if r.staging == "local-bin" { - s.push_str(" chmod +x \"${dst}\"/*\n"); - } - s.push_str(&format!(" echo \"[OK] {dst}\"\n")); - s.push_str("fi\n"); - } - s.push('\n'); - } - - if !files.is_empty() { - s.push_str("# dotfiles\n"); - for r in &files { - let dst = &r.original; - let src = &r.staging; - s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n")); - if r.staging.contains("ssh_config") { - s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n"); - } - // Expand ~ in destination for shell - let dst_shell = dst.replace('~', "$HOME"); - s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n")); - s.push_str(&format!(" echo \"[OK] {dst}\"\n")); - s.push_str("fi\n"); - } - s.push('\n'); - } - - // Packages - if !manifest.packages.is_empty() { - s.push_str("echo \"\"\n"); - s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n"); - if manifest.packages.contains(&"pacman".to_string()) { - s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n"); - } - if manifest.packages.contains(&"cargo".to_string()) { - s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n"); - } - if manifest.packages.contains(&"pip".to_string()) { - s.push_str( - "echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n", - ); - } - if manifest.packages.contains(&"npm".to_string()) { - s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n"); - } - s.push('\n'); - } - - // System files - if manifest.system { - s.push_str("echo \"\"\n"); - s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n"); - s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n"); - s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n"); - s.push_str("fi\n"); - s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n"); - s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n"); - s.push_str("fi\n"); - s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n"); - s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n"); - s.push_str("fi\n"); - s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n"); - s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n"); - s.push_str("fi\n"); - s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n"); - s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n"); - s.push_str("fi\n\n"); - } - - // Git repos - if !manifest.repos.is_empty() { - s.push_str("echo \"\"\n"); - s.push_str("echo \"--- Git repositories ---\"\n"); - for repo in &manifest.repos { - let dest = format!("$HOME/{}", repo.path); - let branch = &repo.branch; - let remote = &repo.remote; - // Create parent dir and clone; skip if already present - let parent = std::path::Path::new(&repo.path) - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - if !parent.is_empty() { - s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n")); - } - s.push_str(&format!("if [ ! -d \"{dest}/.git\" ]; then\n")); - s.push_str(&format!( - " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", - repo.path - )); - s.push_str(&format!( - "else\n echo \"[skip] ~/{} (already exists)\"\nfi\n", - repo.path - )); - } - } - - s -} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs deleted file mode 100644 index d8f04af..0000000 --- a/bread-sync/src/git.rs +++ /dev/null @@ -1,364 +0,0 @@ -use anyhow::{Context, Result}; -use git2::{ - build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, - Repository, Signature, StatusOptions, -}; -use std::path::{Path, PathBuf}; - -/// Wraps a git2 repository with sync-specific operations. -pub struct SyncRepo { - repo: Repository, - pub path: PathBuf, -} - -impl SyncRepo { - /// Open an existing repository at `path`. - pub fn open(path: &Path) -> Result { - let repo = Repository::open(path) - .with_context(|| format!("failed to open git repo at {}", path.display()))?; - Ok(Self { - repo, - path: path.to_path_buf(), - }) - } - - /// Clone `url` into `path`. - pub fn clone_from(url: &str, path: &Path) -> Result { - let fetch_opts = make_fetch_options(); - let mut builder = git2::build::RepoBuilder::new(); - builder.fetch_options(fetch_opts); - let repo = builder - .clone(url, path) - .with_context(|| format!("failed to clone {} into {}", url, path.display()))?; - Ok(Self { - repo, - path: path.to_path_buf(), - }) - } - - /// Open the repo at `path` if it exists; otherwise clone from `url`. - pub fn open_or_clone(url: &str, path: &Path) -> Result { - if path.exists() { - Self::open(path) - } else { - std::fs::create_dir_all(path)?; - Self::clone_from(url, path) - } - } - - /// Initialize a new empty repository at `path` with `main` as the initial branch. - pub fn init(path: &Path) -> Result { - std::fs::create_dir_all(path)?; - let mut opts = git2::RepositoryInitOptions::new(); - opts.initial_head("main"); - let repo = Repository::init_opts(path, &opts) - .with_context(|| format!("failed to init git repo at {}", path.display()))?; - Ok(Self { - repo, - path: path.to_path_buf(), - }) - } - - /// Stage all changes (equivalent to `git add -A`). - pub fn stage_all(&self) -> Result<()> { - let mut index = self.repo.index().context("failed to get git index")?; - index - .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) - .context("failed to stage changes")?; - index.write().context("failed to write git index")?; - Ok(()) - } - - /// Create a commit. Returns `None` if there are no staged changes. - pub fn commit(&self, message: &str) -> Result> { - self.stage_all()?; - - let mut index = self.repo.index()?; - let tree_id = index.write_tree()?; - - // Check if tree matches current HEAD (nothing to commit) - if let Ok(head) = self.repo.head() { - if let Ok(head_commit) = head.peel_to_commit() { - if head_commit.tree_id() == tree_id { - return Ok(None); - } - } - } - - let tree = self.repo.find_tree(tree_id)?; - let sig = Signature::now("Bread Sync", "bread@localhost")?; - - let oid = match self.repo.head() { - Ok(head) => { - let parent = head.peel_to_commit()?; - self.repo - .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? - } - Err(_) => { - // First commit — no parents - self.repo - .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? - } - }; - - Ok(Some(oid)) - } - - /// Push `branch` to `remote_name`. - pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = self - .repo - .find_remote(remote_name) - .with_context(|| format!("remote '{}' not found", remote_name))?; - - let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); - let mut push_opts = PushOptions::new(); - let callbacks = make_callbacks(); - push_opts.remote_callbacks(callbacks); - remote - .push(&[refspec.as_str()], Some(&mut push_opts)) - .context("git push failed")?; - Ok(()) - } - - /// Fetch `branch` from `remote_name` without merging. - pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = self - .repo - .find_remote(remote_name) - .with_context(|| format!("remote '{}' not found", remote_name))?; - let mut fetch_opts = make_fetch_options(); - remote - .fetch(&[branch], Some(&mut fetch_opts), None) - .context("git fetch failed")?; - Ok(()) - } - - /// Fetch and fast-forward merge. Errors on non-fast-forward. - pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> { - self.fetch(remote_name, branch)?; - - let fetch_head = self - .repo - .find_reference("FETCH_HEAD") - .context("FETCH_HEAD not found after fetch")?; - let fetch_commit = self - .repo - .reference_to_annotated_commit(&fetch_head) - .context("failed to get annotated commit from FETCH_HEAD")?; - - let (analysis, _) = self - .repo - .merge_analysis(&[&fetch_commit]) - .context("merge analysis failed")?; - - if analysis.is_up_to_date() { - return Ok(()); - } - - if analysis.is_fast_forward() { - let target_id = fetch_commit.id(); - let ref_name = format!("refs/heads/{branch}"); - match self.repo.find_reference(&ref_name) { - Ok(mut r) => { - r.set_target(target_id, "fast-forward pull")?; - } - Err(_) => { - self.repo - .reference(&ref_name, target_id, true, "fast-forward pull")?; - } - } - self.repo.set_head(&ref_name)?; - self.repo - .checkout_head(Some(CheckoutBuilder::default().force())) - .context("checkout failed during pull")?; - Ok(()) - } else { - anyhow::bail!( - "bread: sync conflict — resolve manually in {}", - self.path.display() - ) - } - } - - /// Returns true if working tree has no uncommitted changes. - pub fn is_clean(&self) -> Result { - Ok(self.local_changes()?.is_empty()) - } - - /// Returns list of (status_char, path) for working-tree changes vs HEAD. - pub fn local_changes(&self) -> Result> { - let mut status_opts = StatusOptions::new(); - status_opts - .include_untracked(true) - .recurse_untracked_dirs(true); - - let statuses = self - .repo - .statuses(Some(&mut status_opts)) - .context("failed to get git status")?; - - let mut out = Vec::new(); - for entry in statuses.iter() { - let s = entry.status(); - let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) { - 'A' - } else if s.contains(git2::Status::INDEX_DELETED) - || s.contains(git2::Status::WT_DELETED) - { - 'D' - } else { - 'M' - }; - if let Some(path) = entry.path() { - out.push((ch, path.to_string())); - } - } - Ok(out) - } - - /// Returns list of (status_char, path) for changes on remote not yet pulled. - pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result> { - // We compare HEAD to remote/branch - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_oid = match self.repo.find_reference(&remote_ref) { - Ok(r) => r.peel_to_commit()?.id(), - Err(_) => return Ok(vec![]), - }; - - let head_commit = match self.repo.head() { - Ok(h) => h.peel_to_commit()?.id(), - Err(_) => return Ok(vec![]), - }; - - if head_commit == remote_oid { - return Ok(vec![]); - } - - let head_tree = self.repo.find_commit(head_commit)?.tree()?; - let remote_tree = self.repo.find_commit(remote_oid)?.tree()?; - - let diff = self - .repo - .diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None) - .context("failed to compute remote diff")?; - - let mut out = Vec::new(); - for delta in diff.deltas() { - let ch = match delta.status() { - git2::Delta::Added => 'A', - git2::Delta::Deleted => 'D', - _ => 'M', - }; - if let Some(path) = delta.new_file().path() { - out.push((ch, path.to_string_lossy().to_string())); - } - } - Ok(out) - } - - /// Return a unified diff string of working tree vs HEAD. - pub fn working_diff(&self) -> Result { - let head_tree = match self.repo.head() { - Ok(h) => Some(h.peel_to_tree()?), - Err(_) => None, - }; - - let diff = self - .repo - .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) - .context("failed to compute working diff")?; - - let mut out = String::new(); - diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { - let prefix = match line.origin() { - '+' | '-' | ' ' => line.origin().to_string(), - _ => String::new(), - }; - out.push_str(&prefix); - if let Ok(s) = std::str::from_utf8(line.content()) { - out.push_str(s); - } - true - }) - .context("failed to format diff")?; - - Ok(out) - } - - /// Return a unified diff string between HEAD and remote branch HEAD. - pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_oid = self - .repo - .find_reference(&remote_ref) - .and_then(|r| r.peel_to_commit()) - .map(|c| c.id()) - .ok(); - - let head_tree = match self.repo.head() { - Ok(h) => Some(h.peel_to_tree()?), - Err(_) => None, - }; - let remote_tree = remote_oid - .and_then(|id| self.repo.find_commit(id).ok()) - .and_then(|c| c.tree().ok()); - - let diff = self - .repo - .diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None) - .context("failed to compute remote diff")?; - - let mut out = String::new(); - diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { - let prefix = match line.origin() { - '+' | '-' | ' ' => line.origin().to_string(), - _ => String::new(), - }; - out.push_str(&prefix); - if let Ok(s) = std::str::from_utf8(line.content()) { - out.push_str(s); - } - true - }) - .context("failed to format remote diff")?; - - Ok(out) - } - - /// Set a named remote. - pub fn set_remote(&self, name: &str, url: &str) -> Result<()> { - let _ = self.repo.remote_delete(name); - self.repo - .remote(name, url) - .with_context(|| format!("failed to set remote {name}"))?; - Ok(()) - } - - /// Return the timestamp of the last commit, or None if no commits. - pub fn last_commit_time(&self) -> Option> { - let head = self.repo.head().ok()?; - let commit = head.peel_to_commit().ok()?; - let t = commit.time(); - // git2::Time uses seconds-from-epoch and offset-in-minutes - let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?; - Some(naive.with_timezone(&chrono::Local)) - } -} - -fn make_callbacks<'a>() -> RemoteCallbacks<'a> { - let mut cb = RemoteCallbacks::new(); - cb.credentials(|_url, username_from_url, allowed_types| { - if allowed_types.contains(git2::CredentialType::SSH_KEY) { - return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); - } - Cred::default() - }); - cb -} - -fn make_fetch_options<'a>() -> FetchOptions<'a> { - let mut opts = FetchOptions::new(); - opts.remote_callbacks(make_callbacks()); - opts -} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs deleted file mode 100644 index e508750..0000000 --- a/bread-sync/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -/// Bread sync: snapshot and restore system state via a Git remote. -pub mod config; -pub mod delegates; -pub mod export; -pub mod git; -pub mod machine; -pub mod packages; - -pub use config::SyncConfig; -pub use export::{apply_import, stage_export, ExportManifest}; -pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs deleted file mode 100644 index 6044d09..0000000 --- a/bread-sync/src/machine.rs +++ /dev/null @@ -1,167 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::Path; - -/// Machine profile stored in `machines/.toml` in the sync repo. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MachineProfile { - pub name: String, - pub hostname: String, - pub tags: Vec, - pub last_sync: String, // RFC 3339 -} - -impl MachineProfile { - /// Create a new profile for this machine. - pub fn new(name: String, tags: Vec) -> Self { - Self { - hostname: hostname(), - name, - tags, - last_sync: Utc::now().to_rfc3339(), - } - } - - /// Write this profile to `/.toml`. - pub fn write(&self, machines_dir: &Path) -> Result<()> { - fs::create_dir_all(machines_dir)?; - let path = machines_dir.join(format!("{}.toml", self.name)); - let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?; - fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) - } - - /// Read a machine profile from `/.toml`. - pub fn read(machines_dir: &Path, name: &str) -> Result { - let path = machines_dir.join(format!("{name}.toml")); - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - toml::from_str(&raw).context("failed to parse machine profile") - } - - /// List all machine profiles in `machines_dir`. - pub fn list(machines_dir: &Path) -> Result> { - if !machines_dir.exists() { - return Ok(vec![]); - } - let mut out = Vec::new(); - for entry in fs::read_dir(machines_dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("toml") { - if let Ok(raw) = fs::read_to_string(&path) { - if let Ok(profile) = toml::from_str::(&raw) { - out.push(profile); - } - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(out) - } -} - -/// Return the system hostname. -pub fn hostname() -> String { - // Try gethostname via libc, fall back to environment variable. - let mut buf = [0u8; 256]; - unsafe { - if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { - if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { - return s.to_string(); - } - } - } - std::env::var("HOSTNAME") - .or_else(|_| std::env::var("HOST")) - .unwrap_or_else(|_| "unknown".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn write_creates_machines_dir_if_missing() { - let tmp = TempDir::new().unwrap(); - let machines = tmp.path().join("does/not/exist/yet"); - let profile = MachineProfile::new("host".to_string(), vec![]); - profile.write(&machines).unwrap(); - assert!(machines.join("host.toml").exists()); - } - - #[test] - fn write_overwrites_existing_profile() { - let tmp = TempDir::new().unwrap(); - let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]); - p1.write(tmp.path()).unwrap(); - - let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]); - p2.write(tmp.path()).unwrap(); - - let loaded = MachineProfile::read(tmp.path(), "host").unwrap(); - assert_eq!(loaded.tags, vec!["b", "c"]); - } - - #[test] - fn list_returns_empty_when_dir_missing() { - let tmp = TempDir::new().unwrap(); - let missing = tmp.path().join("nope"); - assert!(MachineProfile::list(&missing).unwrap().is_empty()); - } - - #[test] - fn list_returns_sorted_profiles_only_for_toml_files() { - let tmp = TempDir::new().unwrap(); - MachineProfile::new("zebra".to_string(), vec![]) - .write(tmp.path()) - .unwrap(); - MachineProfile::new("alpha".to_string(), vec![]) - .write(tmp.path()) - .unwrap(); - MachineProfile::new("middle".to_string(), vec![]) - .write(tmp.path()) - .unwrap(); - // Non-toml file should be ignored. - std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap(); - - let list = MachineProfile::list(tmp.path()).unwrap(); - let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "middle", "zebra"]); - } - - #[test] - fn list_skips_invalid_toml_files_without_failing() { - let tmp = TempDir::new().unwrap(); - MachineProfile::new("valid".to_string(), vec![]) - .write(tmp.path()) - .unwrap(); - std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap(); - - let list = MachineProfile::list(tmp.path()).unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0].name, "valid"); - } - - #[test] - fn read_returns_helpful_error_when_missing() { - let tmp = TempDir::new().unwrap(); - let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err(); - assert!(err.to_string().contains("failed to read")); - } - - #[test] - fn new_assigns_current_hostname_and_timestamp() { - let p = MachineProfile::new("h".to_string(), vec![]); - assert!(!p.hostname.is_empty()); - assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok()); - } - - #[test] - fn hostname_returns_non_empty_string() { - // Whether libc or env fallback fires, the result must be non-empty. - assert!(!hostname().is_empty()); - } -} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs deleted file mode 100644 index b1548ae..0000000 --- a/bread-sync/src/packages.rs +++ /dev/null @@ -1,257 +0,0 @@ -use anyhow::Result; -use std::fs; -use std::path::Path; -use std::process::Command; - -/// Snapshot a package manager's installed packages and write to `dest`. -/// Returns true if the snapshot was written, false if the package manager -/// is not installed (warns instead of failing). -pub fn snapshot(manager: &str, dest: &Path) -> Result { - let content = match manager { - "pacman" => run_pacman()?, - "aur" => run_aur()?, - "pip" => run_pip()?, - "npm" => run_npm()?, - "cargo" => run_cargo()?, - other => { - eprintln!("bread: unknown package manager '{}', skipping", other); - return Ok(false); - } - }; - - let Some(content) = content else { - eprintln!("bread: package manager '{}' not found, skipping", manager); - return Ok(false); - }; - - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent)?; - } - fs::write(dest, content)?; - Ok(true) -} - -/// Parse a pacman snapshot (one "name version" per line, space-separated) and -/// return a list of package names. -pub fn parse_pacman(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) - .collect() -} - -/// Parse a pip freeze snapshot and return package names. -pub fn parse_pip(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) - .map(|l| { - l.split("==") - .next() - .unwrap_or(l) - .split(">=") - .next() - .unwrap_or(l) - .trim() - .to_string() - }) - .collect() -} - -/// Parse npm global packages list (parseable format, one path per line). -pub fn parse_npm(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty()) - .filter_map(|l| { - // `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg - let name = Path::new(l) - .file_name() - .map(|n| n.to_string_lossy().to_string())?; - // Skip npm itself and the root node_modules - if name == "node_modules" { - return None; - } - Some(name) - }) - .collect() -} - -/// Parse cargo install list. -/// Format: "crate v1.2.3 (some-path):\n binary\n..." -pub fn parse_cargo(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) - .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) - .collect() -} - -fn run_aur() -> Result> { - match Command::new("pacman").arg("-Qm").output() { - Ok(out) if out.status.success() => { - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) - } - Ok(_) => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } -} - -fn run_pacman() -> Result> { - match Command::new("pacman").arg("-Qe").output() { - Ok(out) if out.status.success() => { - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) - } - Ok(_) => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } -} - -fn run_pip() -> Result> { - // Try pip3 first, then pip - for cmd in ["pip3", "pip"] { - match Command::new(cmd) - .args(["list", "--user", "--format=freeze"]) - .output() - { - Ok(out) if out.status.success() => { - return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) - } - Ok(_) => continue, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(e.into()), - } - } - Ok(None) -} - -fn run_npm() -> Result> { - match Command::new("npm") - .args(["list", "-g", "--depth=0", "--parseable"]) - .output() - { - Ok(out) if out.status.success() => { - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) - } - Ok(_) => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } -} - -fn run_cargo() -> Result> { - match Command::new("cargo").args(["install", "--list"]).output() { - Ok(out) if out.status.success() => { - Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) - } - Ok(_) => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ─── parse_pacman ───────────────────────────────────────────────────── - - #[test] - fn pacman_parses_each_line_to_first_field() { - let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; - assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]); - } - - #[test] - fn pacman_skips_blank_lines() { - let input = "firefox 1\n\n \ncurl 2\n"; - assert_eq!(parse_pacman(input), vec!["firefox", "curl"]); - } - - #[test] - fn pacman_handles_empty_input() { - assert!(parse_pacman("").is_empty()); - assert!(parse_pacman("\n\n\n").is_empty()); - } - - #[test] - fn pacman_handles_single_token_lines() { - // A line with no version still yields the package name. - assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]); - } - - // ─── parse_pip ──────────────────────────────────────────────────────── - - #[test] - fn pip_strips_eq_and_ge_specifiers() { - let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; - assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]); - } - - #[test] - fn pip_skips_comments_and_blank_lines() { - let input = "# editable install\n\nflake8==1.0\n# trailing\n"; - assert_eq!(parse_pip(input), vec!["flake8"]); - } - - #[test] - fn pip_handles_package_without_specifier() { - assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]); - } - - // ─── parse_npm ──────────────────────────────────────────────────────── - - #[test] - fn npm_extracts_basename_from_paths() { - let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n"; - let pkgs = parse_npm(input); - assert!(pkgs.contains(&"npm".to_string())); - assert!(pkgs.contains(&"typescript".to_string())); - assert!(pkgs.contains(&"yarn".to_string())); - } - - #[test] - fn npm_skips_root_node_modules_entry() { - let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n"; - assert_eq!(parse_npm(input), vec!["typescript"]); - } - - #[test] - fn npm_handles_empty_input() { - assert!(parse_npm("").is_empty()); - } - - // ─── parse_cargo ────────────────────────────────────────────────────── - - #[test] - fn cargo_extracts_crate_names_from_install_list_output() { - let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n"; - assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]); - } - - #[test] - fn cargo_skips_binary_lines() { - // Indented lines are binaries inside a crate. - let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n"; - assert_eq!(parse_cargo(input), vec!["alpha", "beta"]); - } - - #[test] - fn cargo_handles_empty_input() { - assert!(parse_cargo("").is_empty()); - } - - // ─── snapshot dispatch ──────────────────────────────────────────────── - - #[test] - fn snapshot_unknown_manager_returns_false_without_writing() { - let tmp = tempfile::TempDir::new().unwrap(); - let dest = tmp.path().join("out.txt"); - let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap(); - assert!(!wrote); - assert!(!dest.exists()); - } -} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs deleted file mode 100644 index 0cc2dc9..0000000 --- a/bread-sync/tests/sync.rs +++ /dev/null @@ -1,482 +0,0 @@ -use bread_sync::{ - config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, - delegates, machine, packages, SyncRepo, -}; -use std::fs; -use tempfile::TempDir; - -fn make_bare_repo(path: &std::path::Path) -> git2::Repository { - let mut opts = git2::RepositoryInitOptions::new(); - opts.bare(true); - opts.initial_head("main"); - git2::Repository::init_opts(path, &opts).unwrap() -} - -// Helper to create a git commit in a non-bare repo so we have initial state -fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { - let repo = SyncRepo::init(path).unwrap(); - fs::write(path.join(".gitkeep"), "").unwrap(); - repo.stage_all().unwrap(); - repo.commit("initial commit").unwrap(); - repo -} - -#[test] -fn sync_init_creates_toml_with_required_fields() { - let tmp = TempDir::new().unwrap(); - let config = SyncConfig { - remote: RemoteConfig { - url: "git@github.com:test/sync.git".to_string(), - branch: "main".to_string(), - }, - machine: MachineConfig { - name: "testbox".to_string(), - tags: vec!["mobile".to_string()], - }, - packages: PackagesConfig::default(), - delegates: DelegatesConfig::default(), - }; - config.save(tmp.path()).unwrap(); - - let loaded = SyncConfig::load(tmp.path()).unwrap(); - assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); - assert_eq!(loaded.remote.branch, "main"); - assert_eq!(loaded.machine.name, "testbox"); - assert_eq!(loaded.machine.tags, vec!["mobile"]); -} - -#[test] -fn sync_init_errors_if_already_initialized() { - let tmp = TempDir::new().unwrap(); - let config = SyncConfig { - remote: RemoteConfig { - url: "git@github.com:test/sync.git".to_string(), - branch: "main".to_string(), - }, - machine: MachineConfig { - name: "box".to_string(), - tags: vec![], - }, - packages: PackagesConfig::default(), - delegates: DelegatesConfig::default(), - }; - config.save(tmp.path()).unwrap(); - - // Second load should succeed (init itself must check for existence externally) - // We test that load works - let result = SyncConfig::load(tmp.path()); - assert!(result.is_ok()); - // sync.toml now exists — the CLI checks this before calling save - assert!(tmp.path().join("sync.toml").exists()); -} - -#[test] -fn sync_push_creates_correct_directory_structure() { - let repo_tmp = TempDir::new().unwrap(); - let bare_tmp = TempDir::new().unwrap(); - let bread_cfg_tmp = TempDir::new().unwrap(); - - // Create initial bare remote - let _bare = make_bare_repo(bare_tmp.path()); - - // Create local bread config - fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); - - // Init local sync repo - let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) - .unwrap(); - - // Snapshot bread dir - let bread_dest = repo_tmp.path().join("bread"); - delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); - - // Write machine profile - let machines_dir = repo_tmp.path().join("machines"); - let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); - profile.write(&machines_dir).unwrap(); - - // Commit and push - repo.commit("sync: testbox").unwrap(); - repo.push("origin", "main").unwrap(); - - // Verify structure in local repo - assert!(repo_tmp.path().join("bread").exists()); - assert!(repo_tmp.path().join("bread").join("init.lua").exists()); - assert!(repo_tmp - .path() - .join("machines") - .join("testbox.toml") - .exists()); -} - -#[test] -fn sync_push_snapshots_bread_config() { - let repo_tmp = TempDir::new().unwrap(); - let bare_tmp = TempDir::new().unwrap(); - let bread_cfg_tmp = TempDir::new().unwrap(); - - make_bare_repo(bare_tmp.path()); - - // Create a more complex bread config - fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); - fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); - fs::write( - bread_cfg_tmp.path().join("modules/mymod/init.lua"), - "-- mymod", - ) - .unwrap(); - - let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) - .unwrap(); - - let bread_dest = repo_tmp.path().join("bread"); - delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); - - repo.commit("sync: testbox").unwrap(); - repo.push("origin", "main").unwrap(); - - // Verify files were copied - assert!(bread_dest.join("init.lua").exists()); - assert!(bread_dest.join("modules/mymod/init.lua").exists()); - - let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); - assert_eq!(content, "-- init"); -} - -#[test] -fn sync_pull_copies_files_from_repo() { - let bare_tmp = TempDir::new().unwrap(); - let local_tmp = TempDir::new().unwrap(); - let apply_tmp = TempDir::new().unwrap(); - - make_bare_repo(bare_tmp.path()); - - // Create a local repo, add some files, push to bare - let repo = SyncRepo::init(local_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) - .unwrap(); - - let bread_dest = local_tmp.path().join("bread"); - fs::create_dir_all(&bread_dest).unwrap(); - fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); - - repo.commit("sync: first push").unwrap(); - repo.push("origin", "main").unwrap(); - - // Now clone the bare repo and pull - let clone_tmp = TempDir::new().unwrap(); - let _cloned = - SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); - - // Apply bread/ to apply_tmp - let src = clone_tmp.path().join("bread"); - if src.exists() { - delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); - } - - assert!(apply_tmp.path().join("init.lua").exists()); - let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); - assert_eq!(content, "-- from sync"); -} - -#[test] -fn package_manifest_pacman_parses_output_correctly() { - let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; - let pkgs = packages::parse_pacman(input); - assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); -} - -#[test] -fn package_manifest_pip_parses_output_correctly() { - let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; - let pkgs = packages::parse_pip(input); - assert_eq!(pkgs, vec!["requests", "numpy", "black"]); -} - -#[test] -fn delegates_exclude_globs_filter_correctly() { - let src_tmp = TempDir::new().unwrap(); - let dst_tmp = TempDir::new().unwrap(); - - // Create files that should and shouldn't be copied - fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); - fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); - fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); - fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); - fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); - - let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; - delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); - - assert!(dst_tmp.path().join("lua/init.lua").exists()); - assert!(!dst_tmp.path().join(".git").exists()); - assert!(!dst_tmp.path().join("log.cache").exists()); -} - -#[test] -fn machine_profile_written_with_correct_fields() { - let machines_tmp = TempDir::new().unwrap(); - let profile = machine::MachineProfile::new( - "myhost".to_string(), - vec!["mobile".to_string(), "battery".to_string()], - ); - profile.write(machines_tmp.path()).unwrap(); - - let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); - assert_eq!(loaded.name, "myhost"); - assert_eq!(loaded.tags, vec!["mobile", "battery"]); - assert!(!loaded.hostname.is_empty()); - // last_sync must be valid RFC 3339 - let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); - assert!( - parsed.is_ok(), - "last_sync '{}' is not valid RFC 3339", - loaded.last_sync - ); -} - -#[test] -fn status_shows_no_changes_when_clean() { - let repo_tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(repo_tmp.path()); - let changes = repo.local_changes().unwrap(); - assert!( - changes.is_empty(), - "expected no local changes, got: {:?}", - changes - ); - assert!(repo.is_clean().unwrap()); -} - -#[test] -fn push_with_no_changes_returns_none() { - let repo_tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(repo_tmp.path()); - - // No new changes — commit should return None - let result = repo.commit("second commit").unwrap(); - assert!( - result.is_none(), - "expected None (nothing to commit), got: {:?}", - result - ); -} - -// ─── git.rs additional coverage ──────────────────────────────────────────── - -#[test] -fn init_creates_repo_with_main_branch() { - let tmp = TempDir::new().unwrap(); - let repo = SyncRepo::init(tmp.path()).unwrap(); - fs::write(tmp.path().join("x"), "").unwrap(); - repo.stage_all().unwrap(); - let oid = repo.commit("initial").unwrap(); - assert!(oid.is_some(), "first commit should succeed"); - - // Verify HEAD is on refs/heads/main. - let head_ref = std::process::Command::new("git") - .args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"]) - .output() - .unwrap(); - let head_name = String::from_utf8_lossy(&head_ref.stdout); - assert!( - head_name.trim() == "refs/heads/main", - "expected refs/heads/main, got {head_name}" - ); -} - -#[test] -fn open_or_clone_opens_existing_repo() { - let tmp = TempDir::new().unwrap(); - SyncRepo::init(tmp.path()).unwrap(); - - // Calling open_or_clone on an existing path must not attempt to clone. - let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path()); - assert!(again.is_ok()); -} - -#[test] -fn open_or_clone_clones_into_missing_path() { - let bare = TempDir::new().unwrap(); - let bare_repo = make_bare_repo(bare.path()); - // Seed the bare repo with at least one commit so a clone is meaningful. - let local = TempDir::new().unwrap(); - let repo = SyncRepo::init(local.path()).unwrap(); - fs::write(local.path().join("seed"), "x").unwrap(); - repo.commit("seed").unwrap(); - repo.set_remote("origin", bare.path().to_str().unwrap()) - .unwrap(); - repo.push("origin", "main").unwrap(); - drop(bare_repo); - - let dest_parent = TempDir::new().unwrap(); - let dest = dest_parent.path().join("clone-target"); - let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap(); - assert_eq!(cloned.path, dest); - assert!(dest.join("seed").exists()); -} - -#[test] -fn local_changes_reports_new_modified_and_deleted() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - - fs::write(tmp.path().join("added.txt"), "new").unwrap(); - fs::write(tmp.path().join(".gitkeep"), "modified").unwrap(); - - let changes = repo.local_changes().unwrap(); - assert!(!changes.is_empty()); - let kinds: Vec = changes.iter().map(|(c, _)| *c).collect(); - assert!(kinds.contains(&'A')); - assert!(kinds.contains(&'M')); -} - -#[test] -fn is_clean_after_commit() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - assert!(repo.is_clean().unwrap()); -} - -#[test] -fn working_diff_includes_modified_tracked_content() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - // Modify an already-tracked file so it appears in `git diff HEAD`. - fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap(); - - let diff = repo.working_diff().unwrap(); - assert!( - diff.contains("tracked change"), - "diff did not include tracked change, diff was: {diff:?}" - ); -} - -#[test] -fn working_diff_empty_when_only_untracked_files() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap(); - - // working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED, - // so untracked files don't appear — local_changes is the right tool for that. - let diff = repo.working_diff().unwrap(); - assert!( - diff.is_empty() || !diff.contains("new-untracked"), - "expected untracked file to be excluded, diff was: {diff:?}" - ); -} - -#[test] -fn set_remote_overwrites_existing_remote() { - let tmp = TempDir::new().unwrap(); - let repo = SyncRepo::init(tmp.path()).unwrap(); - repo.set_remote("origin", "https://example.com/a.git") - .unwrap(); - // A second call must not error out — it should replace the previous URL. - repo.set_remote("origin", "https://example.com/b.git") - .unwrap(); -} - -#[test] -fn last_commit_time_returns_none_for_empty_repo() { - let tmp = TempDir::new().unwrap(); - let repo = SyncRepo::init(tmp.path()).unwrap(); - assert!(repo.last_commit_time().is_none()); -} - -#[test] -fn last_commit_time_present_after_commit() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - assert!(repo.last_commit_time().is_some()); -} - -#[test] -fn push_pull_round_trip_through_bare_remote() { - let bare = TempDir::new().unwrap(); - make_bare_repo(bare.path()); - - // Push from author repo. - let author = TempDir::new().unwrap(); - let r1 = SyncRepo::init(author.path()).unwrap(); - r1.set_remote("origin", bare.path().to_str().unwrap()) - .unwrap(); - fs::write(author.path().join("note.txt"), "v1").unwrap(); - r1.commit("v1").unwrap(); - r1.push("origin", "main").unwrap(); - - // Clone into reader repo and confirm contents. - let reader_tmp = TempDir::new().unwrap(); - let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap(); - assert_eq!( - fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), - "v1" - ); - - // Author writes a second version and pushes. - fs::write(author.path().join("note.txt"), "v2").unwrap(); - r1.commit("v2").unwrap(); - r1.push("origin", "main").unwrap(); - - // Reader pulls and sees the new content. - r2.pull("origin", "main").unwrap(); - assert_eq!( - fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), - "v2" - ); -} - -#[test] -fn pull_with_no_remote_changes_is_noop() { - let bare = TempDir::new().unwrap(); - make_bare_repo(bare.path()); - - let local = TempDir::new().unwrap(); - let repo = SyncRepo::init(local.path()).unwrap(); - repo.set_remote("origin", bare.path().to_str().unwrap()) - .unwrap(); - fs::write(local.path().join("a"), "1").unwrap(); - repo.commit("c1").unwrap(); - repo.push("origin", "main").unwrap(); - - // Calling pull immediately after push must be up-to-date and succeed. - repo.pull("origin", "main").unwrap(); - assert!(repo.is_clean().unwrap()); -} - -#[test] -fn remote_changes_returns_empty_when_remote_unknown() { - let tmp = TempDir::new().unwrap(); - let repo = init_repo_with_commit(tmp.path()); - let changes = repo.remote_changes("origin", "main").unwrap(); - assert!(changes.is_empty()); -} - -// ─── machine list ────────────────────────────────────────────────────────── - -#[test] -fn machine_list_returns_all_profiles_sorted() { - let machines_tmp = TempDir::new().unwrap(); - for name in ["delta", "alpha", "charlie", "bravo"] { - machine::MachineProfile::new(name.to_string(), vec![]) - .write(machines_tmp.path()) - .unwrap(); - } - let list = machine::MachineProfile::list(machines_tmp.path()).unwrap(); - let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]); -} - -// ─── packages snapshot ───────────────────────────────────────────────────── - -#[test] -fn snapshot_writes_destination_when_manager_unknown_is_skipped() { - let dest_tmp = TempDir::new().unwrap(); - let dest = dest_tmp.path().join("nested/dir/file.txt"); - let wrote = packages::snapshot("does-not-exist", &dest).unwrap(); - assert!(!wrote); - assert!(!dest.exists()); -} diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 2f7e285..f4ef2fa 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "6.2.0" +version = "0.6.6" edition = "2021" [dependencies] diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index 77cdfb3..9a95b0a 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -60,147 +60,22 @@ impl SubscriptionTable { pub fn match_event(&self, event_name: &str) -> Vec { self.entries .iter() - .filter(|sub| matches_pattern(&sub.pattern, event_name)) + .filter(|sub| bread_shared::glob::matches_pattern(&sub.pattern, event_name)) .cloned() .collect() } } -fn matches_pattern(pattern: &str, event_name: &str) -> bool { - if let Some(prefix) = pattern.strip_suffix(".**") { - if event_name == prefix { - return true; - } - } - - matches_glob(pattern.as_bytes(), event_name.as_bytes()) -} - -fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { - if pattern.is_empty() { - return text.is_empty(); - } - - if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { - let mut idx = 2; - while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' { - idx += 2; - } - let rest = &pattern[idx..]; - if rest.is_empty() { - return true; - } - for offset in 0..=text.len() { - if matches_glob(rest, &text[offset..]) { - return true; - } - } - return false; - } - - match pattern[0] { - b'*' => { - let mut offset = 0; - loop { - if matches_glob(&pattern[1..], &text[offset..]) { - return true; - } - if offset == text.len() || text[offset] == b'.' { - break; - } - offset += 1; - } - false - } - b'?' => { - if text.is_empty() || text[0] == b'.' { - return false; - } - matches_glob(&pattern[1..], &text[1..]) - } - ch => { - if text.first().copied() != Some(ch) { - return false; - } - matches_glob(&pattern[1..], &text[1..]) - } - } -} +// Glob-matching semantics (`*`, `**`, `?`) are implemented and tested once, +// in `bread_shared::glob`. Both this module (real event dispatch) and +// `breadd::ipc` (the CLI `--filter` path) delegate to that single +// implementation so they cannot drift apart. See `bread-shared/src/glob.rs` +// for the pattern-matching test suite. #[cfg(test)] mod tests { use super::*; - #[test] - fn exact_match() { - assert!(matches_pattern( - "bread.device.dock.connected", - "bread.device.dock.connected" - )); - assert!(!matches_pattern( - "bread.device.dock.connected", - "bread.device.dock.disconnected" - )); - } - - #[test] - fn single_segment_wildcard() { - assert!(matches_pattern("bread.device.*", "bread.device.foo")); - assert!(!matches_pattern( - "bread.device.*", - "bread.device.dock.connected" - )); - assert!(!matches_pattern("bread.device.*", "bread.device")); - } - - #[test] - fn recursive_wildcard() { - assert!(matches_pattern( - "bread.device.**", - "bread.device.dock.connected" - )); - assert!(matches_pattern("bread.**", "bread.device.dock.connected")); - assert!(matches_pattern("bread.**", "bread")); - } - - #[test] - fn single_char_wildcard() { - assert!(matches_pattern("bread.monitor.?", "bread.monitor.1")); - assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10")); - assert!(!matches_pattern("bread.monitor.?", "bread.monitor.")); - } - - #[test] - fn star_does_not_cross_dot_segments() { - // `*` matches within a segment only. - assert!(matches_pattern( - "bread.*.connected", - "bread.device.connected" - )); - assert!(!matches_pattern( - "bread.*.connected", - "bread.device.dock.connected" - )); - } - - #[test] - fn double_star_matches_zero_or_more_segments() { - assert!(matches_pattern("bread.**", "bread.a")); - assert!(matches_pattern("bread.**", "bread.a.b.c.d")); - } - - #[test] - fn empty_pattern_matches_only_empty_text() { - assert!(matches_pattern("", "")); - assert!(!matches_pattern("", "bread")); - } - - #[test] - fn empty_text_only_matches_wildcards() { - assert!(matches_pattern("**", "")); - assert!(!matches_pattern("bread.*", "")); - } - // ─── SubscriptionTable ──────────────────────────────────────────────── #[test] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index c3fe8d6..d0482d8 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -323,7 +323,7 @@ impl Server { loop { let evt = rx.recv().await?; if let Some(filter) = filter.as_deref() { - if !matches_filter(&evt.event, filter) { + if !bread_shared::glob::matches_pattern(filter, &evt.event) { continue; } } @@ -337,127 +337,9 @@ impl Server { } } -fn matches_filter(event_name: &str, pattern: &str) -> bool { - // Delegates to the same glob logic as the subscription table: - // `*` matches one segment (no dot-crossing), `**` matches any depth. - if let Some(prefix) = pattern.strip_suffix(".**") { - if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) { - return true; - } - return false; - } - - matches_glob_filter(pattern.as_bytes(), event_name.as_bytes()) -} - -fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool { - if pattern.is_empty() { - return text.is_empty(); - } - - if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { - let rest = &pattern[2..]; - if rest.is_empty() { - return true; - } - for offset in 0..=text.len() { - if matches_glob_filter(rest, &text[offset..]) { - return true; - } - } - return false; - } - - match pattern[0] { - b'*' => { - let mut offset = 0; - loop { - if matches_glob_filter(&pattern[1..], &text[offset..]) { - return true; - } - if offset == text.len() || text[offset] == b'.' { - break; - } - offset += 1; - } - false - } - b'?' => { - if text.is_empty() || text[0] == b'.' { - return false; - } - matches_glob_filter(&pattern[1..], &text[1..]) - } - ch => { - if text.first().copied() != Some(ch) { - return false; - } - matches_glob_filter(&pattern[1..], &text[1..]) - } - } -} - -#[cfg(test)] -mod tests { - use super::matches_filter; - - #[test] - fn filter_exact_match() { - assert!(matches_filter("bread.window.opened", "bread.window.opened")); - assert!(!matches_filter( - "bread.window.opened", - "bread.window.closed" - )); - } - - #[test] - fn filter_dot_star_matches_one_segment_only() { - assert!(matches_filter("bread.device.connected", "bread.device.*")); - assert!(!matches_filter( - "bread.device.dock.connected", - "bread.device.*" - )); - assert!(!matches_filter("bread.device", "bread.device.*")); - } - - #[test] - fn filter_dot_double_star_matches_zero_or_more_segments() { - // Matches the exact prefix (zero segments after). - assert!(matches_filter("bread.device", "bread.device.**")); - // And matches deeper paths. - assert!(matches_filter( - "bread.device.dock.connected", - "bread.device.**" - )); - // But not a sibling at the same depth. - assert!(!matches_filter( - "bread.network.connected", - "bread.device.**" - )); - } - - #[test] - fn filter_question_mark_matches_single_char_not_dot() { - assert!(matches_filter("bread.x", "bread.?")); - assert!(!matches_filter("bread.xy", "bread.?")); - assert!(!matches_filter("bread.", "bread.?")); - } - - #[test] - fn filter_mid_pattern_star_does_not_cross_dots() { - // A `*` in the middle of the pattern (not the `.*` suffix shortcut) - // matches within a single segment only. - assert!(matches_filter("bread.alpha.connected", "bread.*.connected")); - assert!(!matches_filter( - "bread.alpha.beta.connected", - "bread.*.connected" - )); - } - - #[test] - fn filter_dot_star_matches_exactly_one_segment() { - assert!(matches_filter("bread.alpha", "bread.*")); - assert!(!matches_filter("bread.alpha.beta", "bread.*")); - assert!(!matches_filter("bread", "bread.*")); - } -} +// The CLI `--filter` glob semantics used to be a second, hand-rolled copy of +// the subscription-table matcher (`matches_filter`/`matches_glob_filter` +// used to live here). That duplication is exactly what let the two paths +// drift out of sync despite the docs claiming parity. Both now delegate to +// the single implementation in `bread_shared::glob::matches_pattern`; see +// that module for the pattern-matching test suite. diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 68dde40..6d3a4a5 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -540,7 +540,7 @@ impl LuaEngine { let exec_fn = self.lua.create_function(move |_lua, cmd: String| { task::spawn_blocking(move || { match std::process::Command::new("sh") - .arg("-lc") + .arg("-c") .arg(&cmd) .status() { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 520280e..64b0933 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=bread -pkgver=0.6.0 +pkgver=0.6.6 pkgrel=1 pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') @@ -11,11 +11,10 @@ license=('MIT') # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, # leaving all lua_* symbols undefined. Disable LTO for a clean static link. options=(!lto !debug) -depends=('glibc' 'libgit2') +depends=('glibc') optdepends=( 'libnotify: desktop notifications via bread.notify()' 'upower: D-Bus battery events (sysfs polling used otherwise)' - 'git: bread sync push/pull operations' ) makedepends=('rust' 'cargo') source=("${pkgname}-${pkgver}.tar.gz") diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 020e26c..f7de16e 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -16,7 +16,7 @@ makepkg -si 3. Update `source` to the release tarball URL. 4. Run `updpkgsums` (or manually set `sha256sums`). 5. Update `url` if the repository has moved. -6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically. +6. Set `depends` accurately — at minimum: `glibc`. Add `udev` if not linking statically. ## Runtime dependencies @@ -26,4 +26,3 @@ makepkg -si | `udev` | yes | device events | | `dbus` | optional | UPower battery events | | `libnotify` | optional | `bread.notify()` (uses `notify-send`) | -| `git` | optional | `bread sync` push/pull |