Setup apply to sync between desktop and homesync repo.

Also remove unused commands.
pull/3/head
Joshua Potter 2022-01-02 13:48:18 -05:00
parent 61b9a338a5
commit bf65142e61
8 changed files with 211 additions and 115 deletions

View File

@ -14,7 +14,6 @@ clap = { version = "3.0.0-rc.9", features = ["derive"] }
git2 = "0.13.25"
log = "0.4.14"
notify = "4.0.16"
regex = "1.5.4"
serde = "1.0"
serde_derive = "1.0.132"
serde_yaml = "0.8"

View File

@ -19,7 +19,11 @@
rustc
rustfmt
] ++ lib.optionals stdenv.isDarwin (
with darwin.apple_sdk.frameworks; [ CoreServices ]);
with darwin.apple_sdk.frameworks; [ CoreServices ]
) ++ lib.optionals stdenv.isLinux [
pkgs.openssl
pkgs.zlib
];
};
});
}

View File

@ -96,17 +96,23 @@ impl Config {
}
#[derive(Debug)]
pub struct PathConfig(pub ResPathBuf, pub Config);
pub struct PathConfig {
pub homesync_yml: ResPathBuf,
pub config: Config,
}
impl PathConfig {
pub fn new(path: &ResPathBuf, config: Config) -> Self {
PathConfig(path.clone(), config)
PathConfig {
homesync_yml: path.clone(),
config,
}
}
// TODO(jrpotter): Create backup file before overwriting.
pub fn write(&self) -> Result<()> {
let mut file = fs::File::create(&self.0)?;
let serialized = serde_yaml::to_string(&self.1)?;
let mut file = fs::File::create(&self.homesync_yml)?;
let serialized = serde_yaml::to_string(&self.config)?;
file.write_all(serialized.as_bytes())?;
Ok(())
}
@ -143,12 +149,12 @@ pub fn load(candidates: &Vec<ResPathBuf>) -> Result<PathConfig> {
Err(Error::MissingConfig)
}
pub fn reload(config: &PathConfig) -> Result<PathConfig> {
pub fn reload(pc: &PathConfig) -> Result<PathConfig> {
info!(
"<green>{}</> configuration reloaded.",
config.1.local.display()
pc.config.local.display()
);
load(&vec![config.0.clone()])
load(&vec![pc.homesync_yml.clone()])
}
// ========================================
@ -205,14 +211,14 @@ pub fn write(path: &ResPathBuf, loaded: Option<Config>) -> Result<PathConfig> {
Some(c) => Some(&c.remote),
None => None,
})?;
let generated = PathConfig(
path.clone(),
Config {
let generated = PathConfig {
homesync_yml: path.clone(),
config: Config {
local,
remote,
packages: loaded.map_or(BTreeMap::new(), |c| c.packages),
},
);
};
generated.write()?;
Ok(generated)
}
@ -221,13 +227,16 @@ pub fn write(path: &ResPathBuf, loaded: Option<Config>) -> Result<PathConfig> {
// Listing
// ========================================
pub fn list_packages(config: PathConfig) {
pub fn list_packages(pc: PathConfig) {
println!(
"Listing packages in {}...\n",
colorize_string(format!("<green>{}</>", config.0.unresolved().display())),
colorize_string(format!(
"<green>{}</>",
pc.homesync_yml.unresolved().display()
)),
);
// Alphabetical ordered ensured by B-tree implementation.
for (k, _) in config.1.packages {
for (k, _) in pc.config.packages {
println!("{}", k);
}
}

View File

@ -107,7 +107,7 @@ impl<'a> WatchState<'a> {
/// Reads in the new path config, updating all watched and pending files
/// according to the packages in the specified config.
pub fn update(&mut self, config: &PathConfig) {
pub fn update(&mut self, pc: &PathConfig) {
self.send_poll(PollEvent::Clear);
for path in &self.watching {
match self.watcher.unwatch(&path) {
@ -122,7 +122,7 @@ impl<'a> WatchState<'a> {
}
}
self.watching.clear();
for (_, package) in &config.1.packages {
for (_, package) in &pc.config.packages {
for path in &package.configs {
match path::soft_resolve(&path) {
Ok(None) => self.send_poll(PollEvent::Pending(path.clone())),
@ -138,7 +138,7 @@ impl<'a> WatchState<'a> {
// Daemon
// ========================================
pub fn launch(mut config: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Error>> {
pub fn launch(mut pc: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Error>> {
let (poll_tx, poll_rx) = channel();
let (watch_tx, watch_rx) = channel();
let watch_tx1 = watch_tx.clone();
@ -152,9 +152,9 @@ pub fn launch(mut config: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Erro
// changes to it for hot reloading purposes, and not worry that our wrapper
// will ever clear it from its watch state.
let mut watcher: RecommendedWatcher = Watcher::new(watch_tx1, Duration::from_secs(freq_secs))?;
watcher.watch(&config.0, RecursiveMode::NonRecursive)?;
watcher.watch(&pc.homesync_yml, RecursiveMode::NonRecursive)?;
let mut state = WatchState::new(poll_tx, &mut watcher)?;
state.update(&config);
state.update(&pc);
loop {
// Received paths should always be the fully resolved ones so safe to
// compare against our current config path.
@ -166,16 +166,16 @@ pub fn launch(mut config: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Erro
trace!("NoticeRemove {}", p.display());
}
Ok(DebouncedEvent::Create(p)) => {
if config.0 == p {
config = config::reload(&config)?;
state.update(&config);
if pc.homesync_yml == p {
pc = config::reload(&pc)?;
state.update(&pc);
}
trace!("Create {}", p.display());
}
Ok(DebouncedEvent::Write(p)) => {
if config.0 == p {
config = config::reload(&config)?;
state.update(&config);
if pc.homesync_yml == p {
pc = config::reload(&pc)?;
state.update(&pc);
}
trace!("Write {}", p.display());
}

View File

@ -1,7 +1,43 @@
use super::{config::PathConfig, path};
use git2::Repository;
use path::ResPathBuf;
use simplelog::{info, paris};
use std::{env::VarError, error, fmt, fs, io, path::PathBuf, result};
use std::{
collections::HashSet,
env::VarError,
error, fmt, fs, io,
path::{Path, PathBuf},
result,
};
// All git error codes.
// TODO(jrpotter): Remove these once done needing to reference them.
// git2::ErrorCode::GenericError => panic!("generic"),
// git2::ErrorCode::NotFound => panic!("not_found"),
// git2::ErrorCode::Exists => panic!("exists"),
// git2::ErrorCode::Ambiguous => panic!("ambiguous"),
// git2::ErrorCode::BufSize => panic!("buf_size"),
// git2::ErrorCode::User => panic!("user"),
// git2::ErrorCode::BareRepo => panic!("bare_repo"),
// git2::ErrorCode::UnbornBranch => panic!("unborn_branch"),
// git2::ErrorCode::Unmerged => panic!("unmerged"),
// git2::ErrorCode::NotFastForward => panic!("not_fast_forward"),
// git2::ErrorCode::InvalidSpec => panic!("invalid_spec"),
// git2::ErrorCode::Conflict => panic!("conflict"),
// git2::ErrorCode::Locked => panic!("locked"),
// git2::ErrorCode::Modified => panic!("modified"),
// git2::ErrorCode::Auth => panic!("auth"),
// git2::ErrorCode::Certificate => panic!("certificate"),
// git2::ErrorCode::Applied => panic!("applied"),
// git2::ErrorCode::Peel => panic!("peel"),
// git2::ErrorCode::Eof => panic!("eof"),
// git2::ErrorCode::Invalid => panic!("invalid"),
// git2::ErrorCode::Uncommitted => panic!("uncommitted"),
// git2::ErrorCode::Directory => panic!("directory"),
// git2::ErrorCode::MergeConflict => panic!("merge_conflict"),
// git2::ErrorCode::HashsumMismatch => panic!("hashsum_mismatch"),
// git2::ErrorCode::IndexDirty => panic!("index_dirty"),
// git2::ErrorCode::ApplyFail => panic!("apply_fail"),
// ========================================
// Error
@ -14,6 +50,7 @@ pub enum Error {
GitError(git2::Error),
IOError(io::Error),
NotHomesyncRepo,
NotWorkingRepo,
VarError(VarError),
}
@ -53,6 +90,10 @@ impl fmt::Display for Error {
f,
"Local repository is not managed by `homesync`. Missing `.homesync` sentinel file."
),
Error::NotWorkingRepo => write!(
f,
"Local repository should be a working directory. Did you manually initialize with `--bare`?"
),
Error::VarError(e) => write!(f, "{}", e),
}
}
@ -64,53 +105,17 @@ impl error::Error for Error {}
// Initialization
// ========================================
// All git error codes.
// TODO(jrpotter): Remove these once done needing to reference them.
// git2::ErrorCode::GenericError => panic!("generic"),
// git2::ErrorCode::NotFound => panic!("not_found"),
// git2::ErrorCode::Exists => panic!("exists"),
// git2::ErrorCode::Ambiguous => panic!("ambiguous"),
// git2::ErrorCode::BufSize => panic!("buf_size"),
// git2::ErrorCode::User => panic!("user"),
// git2::ErrorCode::BareRepo => panic!("bare_repo"),
// git2::ErrorCode::UnbornBranch => panic!("unborn_branch"),
// git2::ErrorCode::Unmerged => panic!("unmerged"),
// git2::ErrorCode::NotFastForward => panic!("not_fast_forward"),
// git2::ErrorCode::InvalidSpec => panic!("invalid_spec"),
// git2::ErrorCode::Conflict => panic!("conflict"),
// git2::ErrorCode::Locked => panic!("locked"),
// git2::ErrorCode::Modified => panic!("modified"),
// git2::ErrorCode::Auth => panic!("auth"),
// git2::ErrorCode::Certificate => panic!("certificate"),
// git2::ErrorCode::Applied => panic!("applied"),
// git2::ErrorCode::Peel => panic!("peel"),
// git2::ErrorCode::Eof => panic!("eof"),
// git2::ErrorCode::Invalid => panic!("invalid"),
// git2::ErrorCode::Uncommitted => panic!("uncommitted"),
// git2::ErrorCode::Directory => panic!("directory"),
// git2::ErrorCode::MergeConflict => panic!("merge_conflict"),
// git2::ErrorCode::HashsumMismatch => panic!("hashsum_mismatch"),
// git2::ErrorCode::IndexDirty => panic!("index_dirty"),
// git2::ErrorCode::ApplyFail => panic!("apply_fail"),
/// Sets up a local github repository all configuration files will be synced to.
/// If there does not exist a local repository at the requested location, we
/// attempt to make it.
///
/// NOTE! This does not perform any syncing between local and remote. In fact,
/// this method does not perform any validation on the remote.
pub fn init(config: &PathConfig) -> Result<git2::Repository> {
/// this method does not perform any validation on remote at all.
pub fn init(pc: &PathConfig) -> Result<Repository> {
// Permit the use of environment variables within the local configuration
// path (e.g. `$HOME`). Unlike with resolution, we want to fail if the
// environment variable is not defined.
let expanded = match config.1.local.to_str() {
Some(s) => s,
None => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Could not local path to a UTF-8 encoded string.",
))?,
};
let expanded = path::expand_env(expanded)?;
let expanded = path::expand(&pc.config.local)?;
// Attempt to open the local path as a git repository if possible. The
// `NotFound` error is thrown if:
//
@ -146,7 +151,7 @@ pub fn init(config: &PathConfig) -> Result<git2::Repository> {
None => {
info!(
"Creating new homesync repository at <green>{}</>.",
config.1.local.display()
pc.config.local.display()
);
let repo = Repository::init(&expanded)?;
fs::File::create(sentinel)?;
@ -154,3 +159,76 @@ pub fn init(config: &PathConfig) -> Result<git2::Repository> {
}
}
}
// ========================================
// Application
// ========================================
fn find_repo_files(path: &Path) -> Result<Vec<ResPathBuf>> {
let mut seen = Vec::new();
if path.is_dir() {
for entry in fs::read_dir(path)? {
let nested = entry?.path();
if nested.is_dir() {
if nested.ends_with(".git") {
continue;
}
let nested = find_repo_files(&nested)?;
seen.extend_from_slice(&nested);
} else if !nested.ends_with(".homesync") {
seen.push(ResPathBuf::new(&nested)?);
}
}
}
Ok(seen)
}
fn find_package_files(pc: &PathConfig) -> Vec<ResPathBuf> {
let mut seen = Vec::new();
for (_, package) in &pc.config.packages {
for path in &package.configs {
if let Ok(resolved) = path::resolve(path) {
seen.push(resolved);
}
}
}
seen
}
pub fn apply(pc: &PathConfig, repo: &Repository) -> Result<()> {
let workdir = repo.workdir().ok_or(Error::NotWorkingRepo)?;
let repo_files = find_repo_files(&workdir)?;
let package_files = find_package_files(&pc);
// Find all files in our repository that are no longer being referenced in
// our primary config file. They should be removed from the repository.
let lookup_files: HashSet<PathBuf> = package_files
.iter()
.map(|m| m.unresolved().to_path_buf())
.collect();
for repo_file in &repo_files {
let relative = repo_file
.resolved()
.strip_prefix(workdir)
.expect("Relative git file could not be stripped properly.")
.to_path_buf();
if !lookup_files.contains(&relative) {
fs::remove_file(repo_file)?;
}
if let Some(p) = repo_file.resolved().parent() {
if p.read_dir()?.next().is_none() {
fs::remove_dir(p)?;
}
}
}
// Find all resolvable files in our primary config and copy them into the
// repository.
for package_file in &package_files {
let mut copy = workdir.to_path_buf();
copy.push(package_file.unresolved());
if let Some(p) = copy.parent() {
fs::create_dir_all(p)?;
}
fs::copy(package_file.resolved(), copy)?;
}
Ok(())
}

View File

@ -7,17 +7,20 @@ use config::PathConfig;
use path::ResPathBuf;
use std::{error::Error, io};
pub fn run_add(_config: PathConfig) -> Result<(), config::Error> {
// TODO(jrpotter): Show $EDITOR that allows writing specific package.
type Result = std::result::Result<(), Box<dyn Error>>;
pub fn run_apply(config: PathConfig) -> Result {
let repo = git::init(&config)?;
git::apply(&config, &repo)?;
Ok(())
}
pub fn run_daemon(config: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Error>> {
pub fn run_daemon(config: PathConfig, freq_secs: u64) -> Result {
daemon::launch(config, freq_secs)?;
Ok(())
}
pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), Box<dyn Error>> {
pub fn run_init(candidates: Vec<ResPathBuf>) -> Result {
debug_assert!(!candidates.is_empty(), "Empty candidates found in `init`.");
if candidates.is_empty() {
Err(config::Error::IOError(io::Error::new(
@ -29,7 +32,7 @@ pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), Box<dyn Error>> {
// Check if we already have a local config somewhere. If so, reprompt
// the same configuration options and override the values present in the
// current YAML file.
Ok(loaded) => config::write(&loaded.0, Some(loaded.1))?,
Ok(loaded) => config::write(&loaded.homesync_yml, Some(loaded.config))?,
// Otherwise create a new config file at the given location. We always
// assume we want to write to the first file in our priority list. If
// not, the user should specify which config they want to write using
@ -41,23 +44,12 @@ pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), Box<dyn Error>> {
}
Err(e) => Err(e)?,
};
// Verify (or create) our local and remote git repositories. The internal
// git library we chose to use employs async/await so let's wrap around a
// channel.
git::init(&config)?;
println!("\nFinished initialization.");
Ok(())
}
pub fn run_list(config: PathConfig) -> Result<(), config::Error> {
pub fn run_list(config: PathConfig) -> Result {
config::list_packages(config);
Ok(())
}
pub fn run_pull(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(())
}
pub fn run_push(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(())
}

View File

@ -16,7 +16,7 @@ fn main() {
.expect("Could not initialize logger library.");
let matches = App::new("homesync")
.about("Cross desktop configuration sync tool.")
.about("Cross desktop sync tool.")
.version("0.1.0")
.setting(AppSettings::SubcommandRequiredElseHelp)
.author("Joshua Potter <jrpotter.github.io>")
@ -28,7 +28,9 @@ fn main() {
.help("Specify a configuration file to use in place of defaults")
.takes_value(true),
)
.subcommand(App::new("add").about("Add new configuration to local repository"))
.subcommand(
App::new("apply").about("Find all changes and apply them to the local repository"),
)
.subcommand(
App::new("daemon")
.about("Start up a new homesync daemon")
@ -50,8 +52,6 @@ fn main() {
)
.subcommand(App::new("init").about("Initialize the homesync local repository"))
.subcommand(App::new("list").about("See which packages homesync manages"))
.subcommand(App::new("pull").about("Pull remote repository into local repository"))
.subcommand(App::new("push").about("Push local repository to remote repository"))
.get_matches();
if let Err(e) = dispatch(matches) {
@ -71,7 +71,7 @@ fn dispatch(matches: clap::ArgMatches) -> Result<(), Box<dyn Error>> {
subcommand => {
let config = homesync::config::load(&candidates)?;
match subcommand {
Some(("add", _)) => Ok(homesync::run_add(config)?),
Some(("apply", _)) => Ok(homesync::run_apply(config)?),
Some(("daemon", matches)) => {
let freq_secs: u64 = match matches.value_of("frequency") {
Some(f) => f.parse().unwrap_or(0),
@ -85,8 +85,6 @@ fn dispatch(matches: clap::ArgMatches) -> Result<(), Box<dyn Error>> {
Ok(())
}
Some(("list", _)) => Ok(homesync::run_list(config)?),
Some(("pull", _)) => Ok(homesync::run_pull(config)?),
Some(("push", _)) => Ok(homesync::run_push(config)?),
_ => unreachable!(),
}
}

View File

@ -1,4 +1,3 @@
use regex::Regex;
use serde::{
de,
de::{Unexpected, Visitor},
@ -61,13 +60,30 @@ pub struct ResPathBuf {
unresolved: PathBuf,
}
fn unresolved_error(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::Other,
format!("Path '{}' should be fully resolved.", path.display()),
)
}
impl ResPathBuf {
pub fn display(&self) -> std::path::Display {
self.inner.display()
pub fn new(path: &Path) -> Result<Self> {
if !path.is_absolute() {
Err(unresolved_error(path))?;
}
Ok(ResPathBuf {
inner: path.to_path_buf(),
unresolved: path.to_path_buf(),
})
}
pub fn resolved(&self) -> &PathBuf {
&self.inner
}
pub fn unresolved(&self) -> &PathBuf {
return &self.unresolved;
&self.unresolved
}
}
@ -214,20 +230,8 @@ pub fn validate_is_dir(path: &Path) -> Result<()> {
/// Find environment variables within the argument and expand them if possible.
///
/// Returns an error if any found environment variables are not defined.
pub fn expand_env(s: &str) -> Result<String> {
let re = Regex::new(r"\$(?P<env>[[:alnum:]]+)").unwrap();
let mut path = s.to_owned();
for caps in re.captures_iter(s) {
let evar = env::var(&caps["env"])?;
path = path.replace(&format!("${}", &caps["env"]), &evar);
}
Ok(path)
}
/// Attempt to resolve the provided path, returning a fully resolved path
/// instance if successful.
pub fn resolve(path: &Path) -> Result<ResPathBuf> {
let mut resolved = env::current_dir()?;
pub fn expand(path: &Path) -> Result<PathBuf> {
let mut expanded = env::current_dir()?;
for comp in path.components() {
match comp {
Component::Prefix(_) => Err(io::Error::new(
@ -235,12 +239,12 @@ pub fn resolve(path: &Path) -> Result<ResPathBuf> {
"We do not currently support Windows.",
))?,
Component::RootDir => {
resolved.clear();
resolved.push(Component::RootDir)
expanded.clear();
expanded.push(Component::RootDir)
}
Component::CurDir => (),
Component::ParentDir => {
if !resolved.pop() {
if !expanded.pop() {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot take parent of root.",
@ -248,11 +252,23 @@ pub fn resolve(path: &Path) -> Result<ResPathBuf> {
}
}
Component::Normal(c) => {
let c: OsString = expand_env(&c.to_string_lossy())?.into();
resolved.push(Component::Normal(&c));
let lossy = c.to_string_lossy();
if lossy.starts_with("$") {
let evar = env::var(lossy.replacen("$", "", 1))?;
expanded.push(Component::Normal(&OsString::from(evar)));
} else {
expanded.push(c);
}
}
}
}
Ok(expanded)
}
/// Attempt to resolve the provided path, returning a fully resolved path
/// instance if successful.
pub fn resolve(path: &Path) -> Result<ResPathBuf> {
let resolved = expand(&path)?;
let resolved = resolved.canonicalize()?;
Ok(ResPathBuf {
inner: resolved,