Fix audit findings: path traversal, exec shell, glob dup, dead bread-sync, version drift
- modules_mgmt.rs: reject module names containing path separators, `..`,
or absolute paths before joining onto modules_dir (install_from_local,
remove_module, read_module_manifest); adds canonicalized containment
check as defense in depth. Manifest-supplied names and CLI args were
previously joined unsanitized, allowing path traversal on install/remove.
- breadd/src/lua/mod.rs: bread.exec now runs via `sh -c` instead of
`sh -lc`; no documented reason was found for login-shell semantics.
- Unify the two independently hand-written glob matchers (subscription
dispatch in breadd/src/core/subscriptions.rs vs. the CLI --filter path
in breadd/src/ipc/mod.rs) into one implementation in
bread-shared/src/glob.rs, used by both call sites.
- Remove the dead bread-sync/ tree (already excluded from the workspace
and fully unreferenced) and its stale PKGBUILD deps (libgit2, git
optdepend) and packaging docs mention.
- Correct the version-number transposition bug ("6.2.0" instead of
"0.6.2"/"0.6.6") across bread-shared, breadd, and bread-cli Cargo.toml,
and fix PKGBUILD's stale pkgver, so Cargo.toml/doctor/PKGBUILD all agree
with the latest git tag (v0.6.6).
This commit is contained in:
parent
1fda781b4c
commit
89c5849539
25 changed files with 319 additions and 3085 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -293,7 +293,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-cli"
|
name = "bread-cli"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
|
|
@ -311,7 +311,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -319,7 +319,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadd"
|
name = "breadd"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@ members = [
|
||||||
"breadd",
|
"breadd",
|
||||||
"bread-cli",
|
"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"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-cli"
|
name = "bread-cli"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use anyhow::{bail, Context, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
/// Contents of `bread.module.toml`.
|
/// Contents of `bread.module.toml`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -45,6 +45,67 @@ pub fn parse_source(source: &str) -> Result<PathBuf> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// <name>`). 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<PathBuf> {
|
||||||
|
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`.
|
/// Install a module from a local directory into `modules_dir`.
|
||||||
/// `source_str` is the original source string recorded in the manifest.
|
/// `source_str` is the original source string recorded in the manifest.
|
||||||
pub fn install_from_local(
|
pub fn install_from_local(
|
||||||
|
|
@ -65,7 +126,9 @@ pub fn install_from_local(
|
||||||
manifest.source = source_str.to_string();
|
manifest.source = source_str.to_string();
|
||||||
manifest.installed_at = Utc::now().to_rfc3339();
|
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() {
|
if dest.exists() {
|
||||||
fs::remove_dir_all(&dest)
|
fs::remove_dir_all(&dest)
|
||||||
.with_context(|| format!("failed to remove existing module at {}", dest.display()))?;
|
.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`.
|
/// Remove a module directory from `modules_dir`.
|
||||||
pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> {
|
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() {
|
if !module_dir.exists() {
|
||||||
bail!("bread: module '{}' is not installed", name);
|
bail!("bread: module '{}' is not installed", name);
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +178,8 @@ pub fn list_modules(modules_dir: &Path) -> Result<Vec<ModuleManifest>> {
|
||||||
|
|
||||||
/// Read a module manifest by name.
|
/// Read a module manifest by name.
|
||||||
pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result<ModuleManifest> {
|
pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result<ModuleManifest> {
|
||||||
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() {
|
if !manifest_path.exists() {
|
||||||
bail!("bread: module '{}' is not installed", name);
|
bail!("bread: module '{}' is not installed", name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,63 @@ fn list_reads_manifests_from_disk() {
|
||||||
assert_eq!(modules[1].name, "beta");
|
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]
|
#[test]
|
||||||
fn manifest_written_correctly_on_install() {
|
fn manifest_written_correctly_on_install() {
|
||||||
let src_tmp = TempDir::new().unwrap();
|
let src_tmp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
169
bread-shared/src/glob.rs
Normal file
169
bread-shared/src/glob.rs
Normal file
|
|
@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod glob;
|
||||||
|
|
||||||
/// Identifies which adapter produced an event.
|
/// Identifies which adapter produced an event.
|
||||||
///
|
///
|
||||||
/// The state engine uses this to choose a normalization strategy and the
|
/// The state engine uses this to choose a normalization strategy and the
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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>
|
|
||||||
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>
|
|
||||||
SyncRepo::open(path: &Path) -> Result<SyncRepo>
|
|
||||||
SyncRepo::clone_from(url: &str, path: &Path) -> Result<SyncRepo>
|
|
||||||
SyncRepo::open_or_clone(url: &str, path: &Path) -> Result<SyncRepo>
|
|
||||||
SyncRepo::commit(&self, message: &str) -> Result<Option<git2::Oid>> // 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<bool>
|
|
||||||
SyncRepo::local_changes(&self) -> Result<Vec<(char, String)>>
|
|
||||||
SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result<Vec<(char, String)>>
|
|
||||||
SyncRepo::working_diff(&self) -> Result<String>
|
|
||||||
SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result<String>
|
|
||||||
SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()>
|
|
||||||
SyncRepo::last_commit_time(&self) -> Option<DateTime<Local>>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `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<String>) -> MachineProfile
|
|
||||||
MachineProfile::write(&self, machines_dir: &Path) -> Result<()>
|
|
||||||
MachineProfile::read(machines_dir: &Path, name: &str) -> Result<MachineProfile>
|
|
||||||
MachineProfile::list(machines_dir: &Path) -> Result<Vec<MachineProfile>>
|
|
||||||
hostname() -> String
|
|
||||||
```
|
|
||||||
|
|
||||||
### `packages`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
snapshot(manager: &str, dest: &Path) -> Result<bool> // false = manager not found (non-fatal)
|
|
||||||
parse_pacman(content: &str) -> Vec<String>
|
|
||||||
parse_pip(content: &str) -> Vec<String>
|
|
||||||
parse_npm(content: &str) -> Vec<String>
|
|
||||||
parse_cargo(content: &str) -> Vec<String>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sync repo layout
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.local/share/bread/sync-repo/
|
|
||||||
├── bread/ ← snapshot of ~/.config/bread/
|
|
||||||
├── configs/
|
|
||||||
│ └── <basename>/ ← delegate paths
|
|
||||||
├── machines/
|
|
||||||
│ └── <name>.toml ← per-machine profiles
|
|
||||||
└── packages/
|
|
||||||
├── pacman.txt
|
|
||||||
├── pip.txt
|
|
||||||
├── npm.txt
|
|
||||||
└── cargo.txt
|
|
||||||
```
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PackagesConfig {
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub managers: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub exclude: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyncConfig {
|
|
||||||
/// Load sync config from the given bread config directory.
|
|
||||||
pub fn load(config_dir: &Path) -> Result<Self> {
|
|
||||||
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::<Vec<_>>();
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Pattern> = 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<PathRecord>,
|
|
||||||
/// High-level list of config dir names (for display).
|
|
||||||
pub configs: Vec<String>,
|
|
||||||
/// Git repos found on the source machine.
|
|
||||||
#[serde(default)]
|
|
||||||
pub repos: Vec<GitRepoRecord>,
|
|
||||||
pub system: bool,
|
|
||||||
pub packages: Vec<String>,
|
|
||||||
// Legacy fields kept for forward compat (ignored on import)
|
|
||||||
#[serde(default)]
|
|
||||||
pub bread: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dotfiles: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub local_bin: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub systemd_units: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<ExportManifest> {
|
|
||||||
fs::create_dir_all(staging)?;
|
|
||||||
|
|
||||||
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
|
|
||||||
let mut path_map: Vec<PathRecord> = Vec::new();
|
|
||||||
let mut included_configs: Vec<String> = 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/<name>/
|
|
||||||
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<String> = 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<Vec<String>> {
|
|
||||||
let mut applied: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
// Read manifest to get the path map
|
|
||||||
let manifest_path = staging.join("manifest.toml");
|
|
||||||
let path_map: Vec<PathRecord> = if manifest_path.exists() {
|
|
||||||
let raw = fs::read_to_string(&manifest_path)?;
|
|
||||||
toml::from_str::<ExportManifest>(&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::<ExportManifest>(&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<PathBuf> {
|
|
||||||
let mut dirs: Vec<PathBuf> = 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<GitRepoRecord> {
|
|
||||||
let nc_dirs = nextcloud_sync_dirs(home);
|
|
||||||
let mut repos: Vec<GitRepoRecord> = 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<GitRepoRecord>,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<Option<git2::Oid>> {
|
|
||||||
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<bool> {
|
|
||||||
Ok(self.local_changes()?.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns list of (status_char, path) for working-tree changes vs HEAD.
|
|
||||||
pub fn local_changes(&self) -> Result<Vec<(char, String)>> {
|
|
||||||
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<Vec<(char, String)>> {
|
|
||||||
// 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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<chrono::DateTime<chrono::Local>> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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/<name>.toml` in the sync repo.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MachineProfile {
|
|
||||||
pub name: String,
|
|
||||||
pub hostname: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub last_sync: String, // RFC 3339
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MachineProfile {
|
|
||||||
/// Create a new profile for this machine.
|
|
||||||
pub fn new(name: String, tags: Vec<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
hostname: hostname(),
|
|
||||||
name,
|
|
||||||
tags,
|
|
||||||
last_sync: Utc::now().to_rfc3339(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write this profile to `<machines_dir>/<name>.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 `<machines_dir>/<name>.toml`.
|
|
||||||
pub fn read(machines_dir: &Path, name: &str) -> Result<Self> {
|
|
||||||
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<Vec<Self>> {
|
|
||||||
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::<Self>(&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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<bool> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<Option<String>> {
|
|
||||||
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<Option<String>> {
|
|
||||||
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<Option<String>> {
|
|
||||||
// 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<Option<String>> {
|
|
||||||
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<Option<String>> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<char> = 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());
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadd"
|
name = "breadd"
|
||||||
version = "6.2.0"
|
version = "0.6.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -60,147 +60,22 @@ impl SubscriptionTable {
|
||||||
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
|
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|sub| matches_pattern(&sub.pattern, event_name))
|
.filter(|sub| bread_shared::glob::matches_pattern(&sub.pattern, event_name))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_pattern(pattern: &str, event_name: &str) -> bool {
|
// Glob-matching semantics (`*`, `**`, `?`) are implemented and tested once,
|
||||||
if let Some(prefix) = pattern.strip_suffix(".**") {
|
// in `bread_shared::glob`. Both this module (real event dispatch) and
|
||||||
if event_name == prefix {
|
// `breadd::ipc` (the CLI `--filter` path) delegate to that single
|
||||||
return true;
|
// implementation so they cannot drift apart. See `bread-shared/src/glob.rs`
|
||||||
}
|
// for the pattern-matching test suite.
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 ────────────────────────────────────────────────
|
// ─── SubscriptionTable ────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ impl Server {
|
||||||
loop {
|
loop {
|
||||||
let evt = rx.recv().await?;
|
let evt = rx.recv().await?;
|
||||||
if let Some(filter) = filter.as_deref() {
|
if let Some(filter) = filter.as_deref() {
|
||||||
if !matches_filter(&evt.event, filter) {
|
if !bread_shared::glob::matches_pattern(filter, &evt.event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -337,127 +337,9 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_filter(event_name: &str, pattern: &str) -> bool {
|
// The CLI `--filter` glob semantics used to be a second, hand-rolled copy of
|
||||||
// Delegates to the same glob logic as the subscription table:
|
// the subscription-table matcher (`matches_filter`/`matches_glob_filter`
|
||||||
// `*` matches one segment (no dot-crossing), `**` matches any depth.
|
// used to live here). That duplication is exactly what let the two paths
|
||||||
if let Some(prefix) = pattern.strip_suffix(".**") {
|
// drift out of sync despite the docs claiming parity. Both now delegate to
|
||||||
if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) {
|
// the single implementation in `bread_shared::glob::matches_pattern`; see
|
||||||
return true;
|
// that module for the pattern-matching test suite.
|
||||||
}
|
|
||||||
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.*"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ impl LuaEngine {
|
||||||
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
|
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
|
||||||
task::spawn_blocking(move || {
|
task::spawn_blocking(move || {
|
||||||
match std::process::Command::new("sh")
|
match std::process::Command::new("sh")
|
||||||
.arg("-lc")
|
.arg("-c")
|
||||||
.arg(&cmd)
|
.arg(&cmd)
|
||||||
.status()
|
.status()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
pkgname=bread
|
pkgname=bread
|
||||||
pkgver=0.6.0
|
pkgver=0.6.6
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A reactive automation fabric for Linux desktops"
|
pkgdesc="A reactive automation fabric for Linux desktops"
|
||||||
arch=('x86_64')
|
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,
|
# 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.
|
# leaving all lua_* symbols undefined. Disable LTO for a clean static link.
|
||||||
options=(!lto !debug)
|
options=(!lto !debug)
|
||||||
depends=('glibc' 'libgit2')
|
depends=('glibc')
|
||||||
optdepends=(
|
optdepends=(
|
||||||
'libnotify: desktop notifications via bread.notify()'
|
'libnotify: desktop notifications via bread.notify()'
|
||||||
'upower: D-Bus battery events (sysfs polling used otherwise)'
|
'upower: D-Bus battery events (sysfs polling used otherwise)'
|
||||||
'git: bread sync push/pull operations'
|
|
||||||
)
|
)
|
||||||
makedepends=('rust' 'cargo')
|
makedepends=('rust' 'cargo')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
source=("${pkgname}-${pkgver}.tar.gz")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ makepkg -si
|
||||||
3. Update `source` to the release tarball URL.
|
3. Update `source` to the release tarball URL.
|
||||||
4. Run `updpkgsums` (or manually set `sha256sums`).
|
4. Run `updpkgsums` (or manually set `sha256sums`).
|
||||||
5. Update `url` if the repository has moved.
|
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
|
## Runtime dependencies
|
||||||
|
|
||||||
|
|
@ -26,4 +26,3 @@ makepkg -si
|
||||||
| `udev` | yes | device events |
|
| `udev` | yes | device events |
|
||||||
| `dbus` | optional | UPower battery events |
|
| `dbus` | optional | UPower battery events |
|
||||||
| `libnotify` | optional | `bread.notify()` (uses `notify-send`) |
|
| `libnotify` | optional | `bread.notify()` (uses `notify-send`) |
|
||||||
| `git` | optional | `bread sync` push/pull |
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue