bulk: syncr

This commit is contained in:
Strix 2025-02-26 03:05:18 +01:00
parent f0e039461c
commit 344e1693bd
55 changed files with 1838 additions and 796 deletions

1149
sync-runner/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
sync-runner/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "sync-runner"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.29", features = ["derive"] }
colored = "3.0.0"
execute = "0.2.13"
fern = "0.7.1"
git2 = "0.20.0"
glob = "0.3.2"
hex = "0.4.3"
lazy_static = "1.5.0"
log = "0.4.26"
regex = "1.11.1"
serde = { version = "1.0.218", features = ["serde_derive"] }
sha2 = "0.10.8"
shellexpand = "3.1.0"
toml = "0.8.20"

View file

@ -0,0 +1,11 @@
install(pacman): pacman -Sy %args
uninstall(pacman): pacman -Rn %args
install(apt): apt install %args
uninstall(apt): apt remove %args
install(dnf): dnf install %args
uninstall(dnf): dnf remove %args
install(yum): yum install %args
uninstall(yum): yum remove %args

10
sync-runner/src/action.rs Normal file
View file

@ -0,0 +1,10 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub enum Action {
/// Sync your device with dotfiles repository
Sync {
#[arg(short, long)]
config_path: Option<String>,
},
}

View file

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct Daemon {
/// interval in minutes
pub interval: u64,
}

View file

@ -0,0 +1,15 @@
#![allow(unused)]
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
mod daemon;
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
pub title: String,
pub daemon: daemon::Daemon,
pub source: HashMap<String, crate::source::Source>
}

View file

@ -0,0 +1,34 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
mod pm;
#[derive(Debug, Serialize, Deserialize)]
pub struct Package {
name: String,
}
impl Package {
pub fn install(&self) {
todo!()
}
pub fn uninstall(&self) {
todo!()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CrateAction {
pub name: String,
pub command: String,
pub args: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Crate {
pub pkgs: HashMap<String, Package>,
pub actions: HashMap<String, CrateAction>,
pub super_actions: HashMap<String, CrateAction>,
}

View file

@ -0,0 +1,90 @@
use lazy_static::lazy_static;
use regex::Regex;
use std::{process::Command, str::FromStr};
const pm_cfg: &str = include_str!("../../package_manager.list");
/// regex: `(?<pm>[a-z]+)>(?<action>(?:un)?install+): (?<command>.*)`
/// example: pacman>install: pacman -Sy %args
lazy_static! {
static ref PM_REGEX: Regex =
Regex::new(r"(?P<pm>[a-z]+)>(?P<action>(?:un)?install+): (?P<command>.*)").unwrap();
}
#[derive(Debug)]
struct PackageManager {
name: String,
command: String,
args: Vec<String>,
}
impl PackageManager {
fn new(name: &str, command: &str, args: Vec<String>) -> Self {
Self {
name: name.to_string(),
command: command.to_string(),
args,
}
}
pub fn install(&self, packages: Vec<String>) -> Result<(), Vec<String>> {
Command::new(&self.command)
.args(&self.args)
.args(packages)
.spawn()
.expect("err");
Ok(())
}
pub fn uninstall(&self, packages: Vec<String>) -> Result<(), Vec<String>> {
todo!();
}
}
impl FromStr for PackageManager {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let caps = PM_REGEX.captures(s).ok_or("invalid package manager")?;
let name = caps.name("pm").ok_or("invalid package manager")?.as_str();
let command = caps
.name("command")
.ok_or("invalid package manager")?
.as_str();
let args = caps
.name("args")
.ok_or("invalid package manager")?
.as_str()
.split_whitespace()
.map(|s| s.to_string())
.collect();
Ok(Self::new(name, command, args))
}
}
#[derive(Debug)]
struct Package {
name: String,
}
impl FromStr for Package {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let caps = PM_REGEX.captures(s).ok_or("invalid package")?;
let name = caps.name("name").ok_or("invalid package")?.as_str();
Ok(Self::new(name))
}
}
impl Package {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
}
}
}
pub fn package_managers() -> Vec<PackageManager> {
pm_cfg
.lines()
.map(|s| s.to_string())
.map(|s| s.parse::<PackageManager>())
.collect::<Result<Vec<PackageManager>, String>>()
.expect("invalid package manager")
}

View file

@ -0,0 +1,64 @@
use std::env;
use colored::Colorize;
use fern::Dispatch;
use log::{Record, SetLoggerError};
fn format_regular<S: Into<String>>(log: S, record: &Record) -> String {
let log = log.into();
let line_prefix = |line: String, extend: bool| {
let prefix = if extend {
match record.level() {
log::Level::Trace => " ]".bright_blue(),
log::Level::Debug => " ?".green(),
log::Level::Info => " >".blue(),
log::Level::Warn => " #".yellow(),
log::Level::Error => " !".red(),
}.to_string()
} else {
match record.level() {
log::Level::Trace => "[TRACE]".bright_blue().italic(),
log::Level::Debug => "??".green(),
log::Level::Info => "=>".blue(),
log::Level::Warn => "##".yellow(),
log::Level::Error => "!!".red().bold()
}.to_string()
};
return format!("{} {}", prefix, line);
};
let mut lines = log.lines().peekable();
let mut output = match lines.peek() {
Some(_line) => line_prefix(lines.next().unwrap().to_string(), false),
None => return "".to_string(),
};
for line in lines {
output.push_str(&*format!("\n{}", line_prefix(line.to_string(), true)));
}
output
}
pub fn setup_logger() -> Result<(), SetLoggerError> {
Dispatch::new()
.format(|out, message, record| {
match record.metadata().target() {
// command output logging
"command:stdout" => out.finish(format_args!("{} {}", ">>".cyan(), message.to_string())),
"command:stderr" => out.finish(format_args!("{} {}", ">>".red(), message.to_string())),
// this target means, it's an item and not a log.
"item" => out.finish(format_args!("{} {}", "*".blue(), message.to_string())),
// default logging
_ => out.finish(format_args!("{}", format_regular(message.to_string(), record))),
}
})
.level(
env::var("SYNCR_LOG_LEVEL")
.unwrap_or_else(|_| "info".to_string())
.parse()
.unwrap_or(log::LevelFilter::Info),
)
.chain(std::io::stdout())
.apply()
}

77
sync-runner/src/main.rs Normal file
View file

@ -0,0 +1,77 @@
use std::{
env::set_current_dir,
fs::{exists, read_to_string, File},
path::{absolute, Path},
process::{exit, Stdio},
};
use action::Action;
use cfg::Config;
use clap::Parser;
use colored::Colorize;
use execute::{command_args, Execute};
use log::{debug, error, info, trace, warn};
use prelude::abspath;
mod action;
mod cfg;
mod crates;
mod logging;
mod prelude;
mod source;
fn main() -> Result<(), Box<dyn std::error::Error>> {
logging::setup_logger()?;
let git_sha1 = String::from_utf8(
command_args!("git", "rev-parse", "HEAD")
.stdout(Stdio::piped())
.execute_output()?
.stdout,
)?;
let action = Action::parse();
match action {
Action::Sync { config_path } => {
trace!("fetching config dir... {config_path:?}");
if let Some(config_path) = abspath(&config_path.unwrap_or("~/.syncr".into())) {
trace!("setting config dir as cwd... {config_path}");
set_current_dir(config_path)?;
}
let config =
toml::from_str::<Config>(&read_to_string(abspath("./syncr.toml").unwrap())?)?;
info!("syncing \"{}\"...", config.title.bold());
info!("updating sources...");
let mut available_sources = vec![];
for (name, source) in &config.source {
debug!("checking {name}...");
if !source.available() {
warn!("source \"{name}\" unavailable.");
} else {
info!("source \"{name}\" available!");
available_sources.push(source);
}
}
if available_sources.len() == 0 {
error!("{}", "sync impossible; no sources.".bold());
exit(1);
}
let oldpwd = absolute(".")?;
for source in available_sources {
// cd to source dir
source.go_to_dir()?;
for c in source.get_crates()? {
info!("{} pkgs", c.pkgs.len())
}
}
}
_ => {
println!("{action:#?}");
}
}
Ok(())
}

View file

@ -0,0 +1,5 @@
pub fn abspath(p: &str) -> Option<String> {
let exp_path = shellexpand::full(p).ok()?;
let can_path = std::fs::canonicalize(exp_path.as_ref()).ok()?;
can_path.into_os_string().into_string().ok()
}

View file

@ -0,0 +1,241 @@
use std::{
env::current_dir, fs::{self, create_dir_all, exists}, io::Write, path::{Path, PathBuf}
};
use git2::Repository;
use log::{debug, info, trace, warn};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha512};
use crate::prelude::abspath;
#[derive(Serialize, Deserialize, Debug)]
pub struct Git {
pub url: String,
#[serde(default = "default_branch")]
pub branch: String,
}
fn default_branch() -> String {
"main".to_string()
}
impl Git {
fn url_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.url);
let hash = hasher.finalize();
hex::encode(hash)
}
fn branch_hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.url);
let hash = hasher.finalize();
hex::encode(hash)
}
pub fn repository_path_str(&self) -> String {
format!(".data/git/{}{}", self.url_hash(), self.branch_hash())
}
pub fn repository_path(&self) -> Result<PathBuf, std::io::Error> {
fs::canonicalize(self.repository_path_str())
}
pub fn exists_on_fs(&self) -> bool {
self.repository_path().is_ok()
}
pub fn clone_repository(&self) -> Result<Repository, git2::Error> {
Repository::clone_recurse(&self.url, Path::new(&self.repository_path_str()))
}
pub fn repository(&self) -> Result<Repository, git2::Error> {
if !self.exists_on_fs() {
create_dir_all(self.repository_path_str()).unwrap();
}
match Repository::open(self.repository_path().unwrap()) {
Ok(r) => Ok(r),
Err(_) => self.clone_repository(),
}
}
fn up_to_date(&self) -> Result<bool, Box<dyn std::error::Error>>{
debug!("checking repo up to date...");
let repo = self.repository()?;
let mut remote = repo.find_remote("origin")?;
// Fetch latest references from remote
remote.fetch(&[self.branch.clone()], None, None)?;
let fetch_head = repo.refname_to_id(&format!("refs/remotes/origin/{}", self.branch))?;
let local_head = repo.refname_to_id(&format!("refs/heads/{}", self.branch))?;
Ok(fetch_head == local_head)
}
pub fn update(&self) -> Result<bool, Box<dyn std::error::Error>> {
if self.up_to_date()? {
return Ok(true);
}
debug!("updating repository...");
let repository = self.repository()?;
let mut remote = repository.find_remote("origin")?;
let mut cb = git2::RemoteCallbacks::new();
cb.transfer_progress(|stats| {
if stats.received_objects() == stats.total_objects() {
print!(
"resolving deltas {}/{}\r",
stats.indexed_deltas(),
stats.total_deltas()
);
} else if stats.total_objects() > 0 {
print!(
"received {}/{} objects ({}) in {} bytes\r",
stats.received_objects(),
stats.total_objects(),
stats.indexed_objects(),
stats.received_bytes()
);
}
std::io::stdout().flush().unwrap();
true
});
let mut fo = git2::FetchOptions::new();
fo.remote_callbacks(cb);
// Always fetch all tags.
// Perform a download and also update tips
fo.download_tags(git2::AutotagOption::All);
info!("fetching {}...", remote.name().unwrap());
remote.fetch(&[self.branch.clone()], Some(&mut fo), None)?;
let fetch_head = repository.find_reference("FETCH_HEAD")?;
do_merge(
&repository,
&self.branch,
repository.reference_to_annotated_commit(&fetch_head)?,
)?;
Ok(true)
}
pub fn ensure(&self) -> Result<&Self, Box<dyn std::error::Error>> {
if self.exists_on_fs() {
self.update();
} else {
self.clone_repository()?;
}
Ok(self)
}
}
fn do_merge<'a>(
repo: &'a Repository,
remote_branch: &str,
fetch_commit: git2::AnnotatedCommit<'a>,
) -> Result<(), git2::Error> {
// 1. do a merge analysis
let analysis = repo.merge_analysis(&[&fetch_commit])?;
// 2. Do the appopriate merge
if analysis.0.is_fast_forward() {
info!("doing a fast forward...");
// do a fast forward
let refname = format!("refs/heads/{}", remote_branch);
match repo.find_reference(&refname) {
Ok(mut r) => {
fast_forward(repo, &mut r, &fetch_commit)?;
}
Err(_) => {
// The branch doesn't exist so just set the reference to the
// commit directly. Usually this is because you are pulling
// into an empty repository.
repo.reference(
&refname,
fetch_commit.id(),
true,
&format!("Setting {} to {}", remote_branch, fetch_commit.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
// do a normal merge
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
normal_merge(&repo, &head_commit, &fetch_commit)?;
} else {
info!("nothing to do...");
}
Ok(())
}
fn fast_forward(
repo: &Repository,
lb: &mut git2::Reference,
rc: &git2::AnnotatedCommit,
) -> Result<(), git2::Error> {
let name = match lb.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
};
let msg = format!("fast-forward: setting {} to id: {}", name, rc.id());
info!("{}", msg);
lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
// For some reason the force is required to make the working directory actually get updated
// I suspect we should be adding some logic to handle dirty working directory states
// but this is just an example so maybe not.
.force(),
))?;
Ok(())
}
fn normal_merge(
repo: &Repository,
local: &git2::AnnotatedCommit,
remote: &git2::AnnotatedCommit,
) -> Result<(), git2::Error> {
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo
.find_commit(repo.merge_base(local.id(), remote.id())?)?
.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
warn!("merge conficts detected...");
repo.checkout_index(Some(&mut idx), None)?;
return Ok(());
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}

View file

@ -0,0 +1,68 @@
use std::{env::{current_dir, set_current_dir}, fs::{create_dir_all, read_to_string}, path::PathBuf};
use log::{debug, info, trace};
use serde::{Deserialize, Serialize};
use crate::{crates::Crate, prelude::abspath};
pub mod git;
#[derive(Serialize, Deserialize, Debug)]
#[serde(default)]
pub struct Source {
interval: u64,
git: Option<git::Git>,
}
impl Default for Source {
fn default() -> Self {
Source {
interval: 60,
git: None,
}
}
}
impl Source {
pub fn available(&self) -> bool {
if let Some(git) = &self.git {
trace!("checking git...");
return git.ensure().is_ok();
}
false
}
pub fn go_to_dir(&self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(git) = &self.git {
if PathBuf::from(git.repository_path_str()) == current_dir()? {
return Ok(())
}
let dir = git.ensure()?.repository_path()?;
trace!("setting git dir as cwd... ({}@{}, {})", git.url, git.branch, dir.display());
set_current_dir(dir)?;
}
Ok(())
}
pub fn get_crates(&self) -> Result<Vec<Crate>, Box<dyn std::error::Error>> {
let mut crates = vec![];
if let Some(git) = &self.git {
trace!("getting crates from git...");
debug!("{}", current_dir()?.display());
// get crates (read dir, crates/*/crate.toml)
for crate_file in glob::glob("crates/*/crate.toml").expect("err") {
debug!("{crate_file:#?}");
match crate_file {
Ok(cd) =>{
debug!("{}", cd.display());
crates.push(toml::from_str(&read_to_string(cd)?)?)
},
_ => continue
}
}
}
debug!("{:#?}", crates);
Ok(crates)
}
}