diff --git a/Cargo.toml b/Cargo.toml index e62636f..b299e72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/flake.nix b/flake.nix index 1d1f397..678f999 100644 --- a/flake.nix +++ b/flake.nix @@ -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 + ]; }; }); } diff --git a/src/config.rs b/src/config.rs index a3379cc..2ad6380 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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) -> Result { Err(Error::MissingConfig) } -pub fn reload(config: &PathConfig) -> Result { +pub fn reload(pc: &PathConfig) -> Result { info!( "{} 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) -> Result { 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) -> Result { // Listing // ======================================== -pub fn list_packages(config: PathConfig) { +pub fn list_packages(pc: PathConfig) { println!( "Listing packages in {}...\n", - colorize_string(format!("{}", config.0.unresolved().display())), + colorize_string(format!( + "{}", + 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); } } diff --git a/src/daemon.rs b/src/daemon.rs index cd8695b..9c74459 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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> { +pub fn launch(mut pc: PathConfig, freq_secs: u64) -> Result<(), Box> { 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 Result<(), Box { - 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()); } diff --git a/src/git.rs b/src/git.rs index 297a072..b11828f 100644 --- a/src/git.rs +++ b/src/git.rs @@ -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 { +/// this method does not perform any validation on remote at all. +pub fn init(pc: &PathConfig) -> Result { // 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 { None => { info!( "Creating new homesync repository at {}.", - 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 { } } } + +// ======================================== +// Application +// ======================================== + +fn find_repo_files(path: &Path) -> Result> { + 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 { + 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 = 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(()) +} diff --git a/src/lib.rs b/src/lib.rs index 98d8b9e..f32d712 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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>; + +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> { +pub fn run_daemon(config: PathConfig, freq_secs: u64) -> Result { daemon::launch(config, freq_secs)?; Ok(()) } -pub fn run_init(candidates: Vec) -> Result<(), Box> { +pub fn run_init(candidates: Vec) -> 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) -> Result<(), Box> { // 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) -> Result<(), Box> { } 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> { - Ok(()) -} - -pub fn run_push(_config: PathConfig) -> Result<(), Box> { - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index b616d79..73292b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 ") @@ -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> { 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> { Ok(()) } Some(("list", _)) => Ok(homesync::run_list(config)?), - Some(("pull", _)) => Ok(homesync::run_pull(config)?), - Some(("push", _)) => Ok(homesync::run_push(config)?), _ => unreachable!(), } } diff --git a/src/path.rs b/src/path.rs index 721237b..80ff151 100644 --- a/src/path.rs +++ b/src/path.rs @@ -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 { + 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 { - let re = Regex::new(r"\$(?P[[: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 { - let mut resolved = env::current_dir()?; +pub fn expand(path: &Path) -> Result { + 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 { "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 { } } 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 { + let resolved = expand(&path)?; let resolved = resolved.canonicalize()?; Ok(ResPathBuf { inner: resolved,