feat: add bread-sync module for snapshot and restore functionality
- Introduced `bread-sync` module with core functionalities for syncing system state via Git. - Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles. - Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`. - Created comprehensive tests for sync operations, package parsing, and machine profile management. - Enhanced `udev` adapter to include vendor and product IDs for scanned devices. - Updated state engine to handle module clearing commands. - Introduced Lua integration for accessing machine information and file system operations. - Improved packaging documentation for Arch Linux and systemd service setup.
This commit is contained in:
parent
96e42bc370
commit
e39b168398
25 changed files with 3930 additions and 92 deletions
18
bread-sync/Cargo.toml
Normal file
18
bread-sync/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "bread-sync"
|
||||
version = "0.1.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
|
||||
88
bread-sync/README.md
Normal file
88
bread-sync/README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 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
|
||||
```
|
||||
135
bread-sync/src/config.rs
Normal file
135
bread-sync/src/config.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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(),
|
||||
"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)
|
||||
}
|
||||
109
bread-sync/src/delegates.rs
Normal file
109
bread-sync/src/delegates.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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()
|
||||
}
|
||||
366
bread-sync/src/git.rs
Normal file
366
bread-sync/src/git.rs
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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
|
||||
}
|
||||
9
bread-sync/src/lib.rs
Normal file
9
bread-sync/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// Bread sync: snapshot and restore system state via a Git remote.
|
||||
pub mod config;
|
||||
pub mod delegates;
|
||||
pub mod git;
|
||||
pub mod machine;
|
||||
pub mod packages;
|
||||
|
||||
pub use config::SyncConfig;
|
||||
pub use git::SyncRepo;
|
||||
79
bread-sync/src/machine.rs
Normal file
79
bread-sync/src/machine.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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())
|
||||
}
|
||||
144
bread-sync/src/packages.rs
Normal file
144
bread-sync/src/packages.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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()?,
|
||||
"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_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()),
|
||||
}
|
||||
}
|
||||
257
bread-sync/tests/sync.rs
Normal file
257
bread-sync/tests/sync.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue