revert
This commit is contained in:
parent
c65e50fe1c
commit
96e42bc370
18 changed files with 125 additions and 3432 deletions
|
|
@ -1,19 +0,0 @@
|
|||
[package]
|
||||
name = "bread-sync"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
toml = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
git2 = { version = "0.18", features = ["vendored-libgit2"] }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# bread-sync
|
||||
|
||||
Sync and module management library for the Bread reactive desktop automation daemon.
|
||||
|
||||
Provides:
|
||||
- `SyncConfig` — load/save `~/.config/bread/sync.toml`
|
||||
- Git backend (via git2) for push/pull of bread config to a remote repository
|
||||
- Delegate file handling — copy arbitrary config files into the sync repo
|
||||
- Package manifest generation for pacman/pip/npm/cargo
|
||||
- Machine profile — name and tags read from sync.toml
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level sync configuration stored in `~/.config/bread/sync.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SyncConfig {
|
||||
#[serde(default)]
|
||||
pub remote: RemoteConfig,
|
||||
#[serde(default)]
|
||||
pub machine: MachineConfig,
|
||||
#[serde(default)]
|
||||
pub packages: PackagesConfig,
|
||||
#[serde(default)]
|
||||
pub delegates: DelegatesConfig,
|
||||
}
|
||||
|
||||
/// Git remote configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RemoteConfig {
|
||||
pub url: Option<String>,
|
||||
#[serde(default = "default_branch")]
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
fn default_branch() -> String {
|
||||
"main".to_string()
|
||||
}
|
||||
|
||||
/// Machine identity — name comes from here, falls back to hostname.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MachineConfig {
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which package managers to snapshot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackagesConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_managers")]
|
||||
pub managers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for PackagesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
managers: default_managers(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_managers() -> Vec<String> {
|
||||
vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()]
|
||||
}
|
||||
|
||||
/// Config file delegation — which extra paths to include in the sync repo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DelegatesConfig {
|
||||
/// Absolute or `~`-prefixed paths to copy into `configs/<basename>/`.
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
/// Glob patterns to exclude when copying.
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
impl SyncConfig {
|
||||
/// Load from `~/.config/bread/sync.toml`, returning `Default` if not present.
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
let cfg: Self = toml::from_str(&raw)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed.
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let raw = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if `~/.config/bread/sync.toml` exists on disk.
|
||||
pub fn is_initialized() -> Result<bool> {
|
||||
Ok(config_path()?.exists())
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to `~/.config/bread/sync.toml`.
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
|
||||
Ok(config_dir.join("bread").join("sync.toml"))
|
||||
}
|
||||
|
||||
/// Path to `~/.local/share/bread/sync-repo/`.
|
||||
pub fn sync_repo_path() -> Result<PathBuf> {
|
||||
let data_dir = dirs::data_local_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?;
|
||||
Ok(data_dir.join("bread").join("sync-repo"))
|
||||
}
|
||||
|
||||
/// Path to `~/.config/bread/`.
|
||||
pub fn bread_config_dir() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
|
||||
Ok(config_dir.join("bread"))
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::DelegatesConfig;
|
||||
|
||||
/// Expand `~` in a path string to the user's home directory.
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(rest))
|
||||
.unwrap_or_else(|| PathBuf::from(path))
|
||||
} else if path == "~" {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs.
|
||||
fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool {
|
||||
let rel = path.strip_prefix(base).unwrap_or(path);
|
||||
let rel_str = rel.to_string_lossy();
|
||||
for pattern in excludes {
|
||||
if glob_matches(pattern, &rel_str) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy all files under `src` dir to `dest` dir, honouring `excludes`.
|
||||
/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are
|
||||
/// absent in `src` (rsync `--delete` behaviour).
|
||||
pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
copy_recursive(src, src, dest, excludes)?;
|
||||
delete_extra(src, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> {
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
|
||||
if is_excluded(root, &src_path, excludes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = entry.file_name();
|
||||
let dest_path = dest.join(&file_name);
|
||||
|
||||
if src_path.is_dir() {
|
||||
std::fs::create_dir_all(&dest_path)?;
|
||||
copy_recursive(root, &src_path, &dest_path, excludes)?;
|
||||
} else {
|
||||
std::fs::copy(&src_path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove files/dirs from `dest` that don't exist in `src`.
|
||||
fn delete_extra(src: &Path, dest: &Path) -> Result<()> {
|
||||
if !dest.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dest)? {
|
||||
let entry = entry?;
|
||||
let dest_path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let src_path = src.join(&file_name);
|
||||
if !src_path.exists() {
|
||||
if dest_path.is_dir() {
|
||||
std::fs::remove_dir_all(&dest_path)?;
|
||||
} else {
|
||||
std::fs::remove_file(&dest_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy each `include` path into `<repo_root>/configs/<basename>/`.
|
||||
pub fn copy_delegates_to_repo(
|
||||
cfg: &DelegatesConfig,
|
||||
repo_root: &Path,
|
||||
) -> Result<()> {
|
||||
let configs_dir = repo_root.join("configs");
|
||||
std::fs::create_dir_all(&configs_dir)?;
|
||||
|
||||
for raw_path in &cfg.include {
|
||||
let src = expand_tilde(raw_path);
|
||||
if !src.exists() {
|
||||
tracing_warn(&format!(
|
||||
"delegate path does not exist, skipping: {}",
|
||||
src.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let basename = src
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?;
|
||||
let dest = configs_dir.join(basename);
|
||||
if src.is_dir() {
|
||||
sync_dir(&src, &dest, &cfg.exclude)?;
|
||||
} else {
|
||||
std::fs::copy(&src, &dest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore each delegate path from `<repo_root>/configs/<basename>/` to its original location.
|
||||
pub fn restore_delegates_from_repo(
|
||||
cfg: &DelegatesConfig,
|
||||
repo_root: &Path,
|
||||
) -> Result<()> {
|
||||
let configs_dir = repo_root.join("configs");
|
||||
|
||||
for raw_path in &cfg.include {
|
||||
let dest = expand_tilde(raw_path);
|
||||
let basename = match dest.file_name() {
|
||||
Some(n) => n.to_os_string(),
|
||||
None => continue,
|
||||
};
|
||||
let src = configs_dir.join(&basename);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
if src.is_dir() {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
sync_dir(&src, &dest, &[])?;
|
||||
} else {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simple glob match for `**` and `*` patterns against a path string.
|
||||
fn glob_matches(pattern: &str, path: &str) -> bool {
|
||||
glob_match_bytes(pattern.as_bytes(), path.as_bytes())
|
||||
}
|
||||
|
||||
fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return text.is_empty();
|
||||
}
|
||||
|
||||
// `**` matches any sequence including path separators
|
||||
if pattern.starts_with(b"**") {
|
||||
let rest = &pattern[2..];
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// skip leading separator in rest
|
||||
let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest };
|
||||
for offset in 0..=text.len() {
|
||||
if glob_match_bytes(rest, &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
match pattern[0] {
|
||||
b'*' => {
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if glob_match_bytes(&pattern[1..], &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
if offset == text.len() {
|
||||
break;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
b'?' => {
|
||||
if text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
glob_match_bytes(&pattern[1..], &text[1..])
|
||||
}
|
||||
ch => {
|
||||
if text.first().copied() != Some(ch) {
|
||||
return false;
|
||||
}
|
||||
glob_match_bytes(&pattern[1..], &text[1..])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tracing_warn(msg: &str) {
|
||||
// Use eprintln since tracing may not be configured in library context
|
||||
eprintln!("warn: {msg}");
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
/// Open an existing repo or initialise a new one at `path`.
|
||||
pub fn init_or_open(path: &Path) -> Result<git2::Repository> {
|
||||
if path.join(".git").exists() || is_bare(path) {
|
||||
Ok(git2::Repository::open(path)?)
|
||||
} else {
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(git2::Repository::init(path)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone `url` to `path` if `path` is not already a repo, otherwise open it.
|
||||
pub fn clone_or_open(url: &str, path: &Path) -> Result<git2::Repository> {
|
||||
if path.join(".git").exists() || is_bare(path) {
|
||||
return Ok(git2::Repository::open(path)?);
|
||||
}
|
||||
let mut builder = git2::build::RepoBuilder::new();
|
||||
let mut fetch_opts = git2::FetchOptions::new();
|
||||
fetch_opts.remote_callbacks(make_callbacks());
|
||||
builder.fetch_options(fetch_opts);
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(builder.clone(url, path)?)
|
||||
}
|
||||
|
||||
/// Stage every tracked and untracked change (equivalent to `git add -A`).
|
||||
pub fn stage_all(repo: &git2::Repository) -> Result<()> {
|
||||
let mut index = repo.index()?;
|
||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
|
||||
// Remove entries for deleted files
|
||||
index.update_all(["*"].iter(), None)?;
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if the index has staged changes compared to HEAD (or repo is new).
|
||||
pub fn has_changes(repo: &git2::Repository) -> Result<bool> {
|
||||
let mut index = repo.index()?;
|
||||
index.read(false)?;
|
||||
|
||||
// New repo with no commits yet
|
||||
if repo.head().is_err() {
|
||||
return Ok(index.len() > 0);
|
||||
}
|
||||
|
||||
let head = repo.head()?.peel_to_tree()?;
|
||||
let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?;
|
||||
Ok(diff.deltas().count() > 0)
|
||||
}
|
||||
|
||||
/// Commit all staged changes with `message`. Returns the new commit OID.
|
||||
pub fn commit(repo: &git2::Repository, message: &str) -> Result<git2::Oid> {
|
||||
let mut index = repo.index()?;
|
||||
let tree_id = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_id)?;
|
||||
let sig = repo.signature().unwrap_or_else(|_| {
|
||||
git2::Signature::now("bread", "bread@localhost").expect("signature")
|
||||
});
|
||||
|
||||
let oid = if let Ok(head) = repo.head() {
|
||||
let parent = head.peel_to_commit()?;
|
||||
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
|
||||
} else {
|
||||
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
|
||||
};
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
/// Push `branch` to `remote_name` (defaults to "origin").
|
||||
pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote(remote_name)?;
|
||||
let mut opts = git2::PushOptions::new();
|
||||
opts.remote_callbacks(make_callbacks());
|
||||
remote.push(
|
||||
&[&format!("refs/heads/{branch}:refs/heads/{branch}")],
|
||||
Some(&mut opts),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch from `remote_name` without merging.
|
||||
pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote(remote_name)?;
|
||||
let mut opts = git2::FetchOptions::new();
|
||||
opts.remote_callbacks(make_callbacks());
|
||||
remote.fetch(&[] as &[&str], Some(&mut opts), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict.
|
||||
pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> {
|
||||
fetch(repo, remote_name)?;
|
||||
|
||||
let fetch_head = repo
|
||||
.find_reference(&format!("refs/remotes/{remote_name}/{branch}"))
|
||||
.map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?;
|
||||
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||
|
||||
let analysis = repo.merge_analysis(&[&fetch_commit])?;
|
||||
if analysis.0.is_up_to_date() {
|
||||
return Ok(());
|
||||
}
|
||||
if !analysis.0.is_fast_forward() {
|
||||
return Err(anyhow!(
|
||||
"sync conflict — resolve manually in {}",
|
||||
repo.workdir()
|
||||
.unwrap_or_else(|| Path::new("?"))
|
||||
.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Fast-forward: update HEAD and checkout
|
||||
let head_ref = repo.find_reference("HEAD")?;
|
||||
let resolved = head_ref.resolve()?;
|
||||
let refname = resolved.name().unwrap_or("HEAD").to_string();
|
||||
repo.find_reference(&refname)?
|
||||
.set_target(fetch_commit.id(), "fast-forward")?;
|
||||
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a remote named `name` pointing at `url`, or update it if it already exists.
|
||||
pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> {
|
||||
if repo.find_remote(name).is_ok() {
|
||||
repo.remote_set_url(name, url)?;
|
||||
} else {
|
||||
repo.remote(name, url)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return working-tree diff against HEAD as a unified diff string.
|
||||
pub fn diff_workdir(repo: &git2::Repository) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) {
|
||||
let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?;
|
||||
diff.print(git2::DiffFormat::Patch, |_, _, line| {
|
||||
buf.extend_from_slice(line.content());
|
||||
true
|
||||
})?;
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
/// Return diff between HEAD and `remote/branch` as a unified diff string.
|
||||
pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<String> {
|
||||
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
|
||||
let remote_tree = repo
|
||||
.find_reference(&remote_ref)
|
||||
.map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))?
|
||||
.peel_to_tree()?;
|
||||
let local_tree = repo.head()?.peel_to_tree()?;
|
||||
let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?;
|
||||
let mut buf = Vec::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_, _, line| {
|
||||
buf.extend_from_slice(line.content());
|
||||
true
|
||||
})?;
|
||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
/// Return a list of `(status_char, path)` for the working tree.
|
||||
pub fn status_lines(repo: &git2::Repository) -> Result<Vec<(char, String)>> {
|
||||
let statuses = repo.statuses(None)?;
|
||||
let mut out = Vec::new();
|
||||
for entry in statuses.iter() {
|
||||
let path = entry.path().unwrap_or("?").to_string();
|
||||
let flag = entry.status();
|
||||
let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) {
|
||||
'A'
|
||||
} else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) {
|
||||
'D'
|
||||
} else {
|
||||
'M'
|
||||
};
|
||||
out.push((ch, path));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns true if the local HEAD is behind the remote.
|
||||
pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool {
|
||||
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
|
||||
let Ok(remote_ref) = repo.find_reference(&remote_ref) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(remote_commit) = remote_ref.peel_to_commit() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else {
|
||||
return false;
|
||||
};
|
||||
remote_commit.id() != local_commit.id()
|
||||
}
|
||||
|
||||
/// Timestamp of the HEAD commit (or "never").
|
||||
pub fn last_commit_time(repo: &git2::Repository) -> String {
|
||||
let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else {
|
||||
return "never".to_string();
|
||||
};
|
||||
let ts = commit.time().seconds();
|
||||
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
fn is_bare(path: &Path) -> bool {
|
||||
path.join("HEAD").exists() && path.join("objects").exists()
|
||||
}
|
||||
|
||||
fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> {
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
callbacks.credentials(|_url, username_from_url, allowed_types| {
|
||||
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
|
||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
||||
} else if allowed_types.contains(git2::CredentialType::DEFAULT) {
|
||||
git2::Cred::default()
|
||||
} else {
|
||||
Err(git2::Error::from_str(
|
||||
"no supported credential type (SSH agent or default)",
|
||||
))
|
||||
}
|
||||
});
|
||||
callbacks
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
pub mod config;
|
||||
pub mod delegates;
|
||||
pub mod git;
|
||||
pub mod machine;
|
||||
pub mod packages;
|
||||
|
||||
pub use config::{
|
||||
bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig,
|
||||
RemoteConfig, SyncConfig,
|
||||
};
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::SyncConfig;
|
||||
|
||||
/// Machine profile persisted to `<repo>/machines/<name>.toml`.
|
||||
#[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 {
|
||||
pub fn new(cfg: &SyncConfig) -> Result<Self> {
|
||||
let host = hostname()?;
|
||||
let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone());
|
||||
Ok(Self {
|
||||
name,
|
||||
hostname: host,
|
||||
tags: cfg.machine.tags.clone(),
|
||||
last_sync: Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write profile to `<repo>/machines/<name>.toml`.
|
||||
pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> {
|
||||
let machines_dir = repo_root.join("machines");
|
||||
std::fs::create_dir_all(&machines_dir)?;
|
||||
let path = machines_dir.join(format!("{}.toml", self.name));
|
||||
let raw = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, raw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load from `<repo>/machines/<name>.toml`.
|
||||
pub fn load_from_repo(repo_root: &Path, name: &str) -> Result<Self> {
|
||||
let path = repo_root.join("machines").join(format!("{name}.toml"));
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all machine profiles in `<repo>/machines/`.
|
||||
pub fn list_machines(repo_root: &Path) -> Vec<MachineProfile> {
|
||||
let machines_dir = repo_root.join("machines");
|
||||
let Ok(entries) = std::fs::read_dir(&machines_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml"))
|
||||
.filter_map(|e| {
|
||||
std::fs::read_to_string(e.path())
|
||||
.ok()
|
||||
.and_then(|raw| toml::from_str::<MachineProfile>(&raw).ok())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the machine name from sync.toml, falling back to hostname.
|
||||
pub fn machine_name(cfg: &SyncConfig) -> Result<String> {
|
||||
if let Some(name) = cfg.machine.name.as_deref() {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
hostname()
|
||||
}
|
||||
|
||||
/// Returns the machine tags from sync.toml.
|
||||
pub fn machine_tags(cfg: &SyncConfig) -> Vec<String> {
|
||||
cfg.machine.tags.clone()
|
||||
}
|
||||
|
||||
/// Returns true if `tag` is in the machine's tag list.
|
||||
pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool {
|
||||
cfg.machine.tags.iter().any(|t| t == tag)
|
||||
}
|
||||
|
||||
fn hostname() -> Result<String> {
|
||||
// Try /etc/hostname first (no subprocess)
|
||||
if let Ok(raw) = std::fs::read_to_string("/etc/hostname") {
|
||||
let trimmed = raw.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed);
|
||||
}
|
||||
}
|
||||
// Fall back to hostname(1)
|
||||
let out = std::process::Command::new("hostname")
|
||||
.output()
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?;
|
||||
Ok(s.trim().to_string())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn format_last_sync(dt: &DateTime<Utc>) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Write package manifests to `<repo>/packages/`.
|
||||
/// Skips package managers that are not installed (warns instead of erroring).
|
||||
pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> {
|
||||
let pkg_dir = repo_root.join("packages");
|
||||
std::fs::create_dir_all(&pkg_dir)?;
|
||||
|
||||
for mgr in managers {
|
||||
match mgr.as_str() {
|
||||
"pacman" => {
|
||||
if let Some(content) = run_pacman() {
|
||||
std::fs::write(pkg_dir.join("pacman.txt"), content)?;
|
||||
} else {
|
||||
eprintln!("warn: pacman not found, skipping package snapshot");
|
||||
}
|
||||
}
|
||||
"pip" => {
|
||||
if let Some(content) = run_pip() {
|
||||
std::fs::write(pkg_dir.join("pip.txt"), content)?;
|
||||
} else {
|
||||
eprintln!("warn: pip not found, skipping package snapshot");
|
||||
}
|
||||
}
|
||||
"npm" => {
|
||||
if let Some(content) = run_npm() {
|
||||
std::fs::write(pkg_dir.join("npm.txt"), content)?;
|
||||
} else {
|
||||
eprintln!("warn: npm not found, skipping package snapshot");
|
||||
}
|
||||
}
|
||||
"cargo" => {
|
||||
if let Some(content) = run_cargo() {
|
||||
std::fs::write(pkg_dir.join("cargo.txt"), content)?;
|
||||
} else {
|
||||
eprintln!("warn: cargo not found, skipping package snapshot");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("warn: unknown package manager '{other}', skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a `pacman.txt` snapshot into a list of package names.
|
||||
pub fn parse_pacman(content: &str) -> Vec<String> {
|
||||
content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Parse a `pip.txt` (freeze format) snapshot into package names.
|
||||
pub fn parse_pip(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
|
||||
.filter_map(|l| l.split("==").next().map(|s| s.trim().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse an `npm.txt` (parseable) snapshot into package names.
|
||||
pub fn parse_npm(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.skip(1) // first line is the npm global prefix path
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| {
|
||||
Path::new(l.trim())
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `cargo install --list` output into `name version` lines.
|
||||
pub fn parse_cargo(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
|
||||
.filter_map(|l| {
|
||||
// Format: `name v1.2.3 (...):` or `name v1.2.3:`
|
||||
let parts: Vec<&str> = l.splitn(2, ' ').collect();
|
||||
if parts.len() == 2 {
|
||||
let name = parts[0];
|
||||
let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':');
|
||||
Some(format!("{name} {version}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_pacman() -> Option<String> {
|
||||
let output = Command::new("pacman").args(["-Qe"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
||||
fn run_pip() -> Option<String> {
|
||||
let output = Command::new("pip")
|
||||
.args(["list", "--user", "--format=freeze"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
||||
fn run_npm() -> Option<String> {
|
||||
let output = Command::new("npm")
|
||||
.args(["list", "-g", "--depth=0", "--parseable"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
||||
fn run_cargo() -> Option<String> {
|
||||
let output = Command::new("cargo")
|
||||
.args(["install", "--list"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout).ok()
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// Placeholder - tests will be added in step 15
|
||||
Loading…
Add table
Add a link
Reference in a new issue