From 014a7d72e5e0e5cb8687c76fc182cd69a5850312 Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Fri, 7 Jan 2022 12:30:46 -0500 Subject: [PATCH] Allow specifying SSH keys and clean up config further. --- examples/config.yaml | 52 +++++------ examples/template.yaml | 23 ++--- src/config.rs | 36 ++++---- src/daemon.rs | 4 +- src/git.rs | 194 +++++++++++++++++++++++++---------------- 5 files changed, 178 insertions(+), 131 deletions(-) diff --git a/examples/config.yaml b/examples/config.yaml index 8b3ff51..aa37618 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -2,39 +2,35 @@ user: name: jrpotter email: jrpotter@github.io -local: $HOME/.homesync -remote: - name: origin - branch: master - url: "https://github.com/jrpotter/home-config.git" +ssh: + private: $HOME/.ssh/id_ed25519 +repos: + local: $HOME/.homesync + remote: + name: origin + branch: master + url: "git@github.com:jrpotter/home-config.git" packages: alacritty: - configs: - - $HOME/.alacritty.yml - - $HOME/.config/alacritty/alacritty.yml - - $XDG_CONFIG_HOME/alacritty.yml - - $XDG_CONFIG_HOME/alacritty/alacritty.yml + - $HOME/.alacritty.yml + - $HOME/.config/alacritty/alacritty.yml + - $XDG_CONFIG_HOME/alacritty.yml + - $XDG_CONFIG_HOME/alacritty/alacritty.yml bash: - configs: - - $HOME/.bash_profile - - $HOME/.bashrc + - $HOME/.bash_profile + - $HOME/.bashrc home-manager: - configs: - - $HOME/home.nix + - $HOME/home.nix homesync: - configs: - - $HOME/.homesync.yml - - $HOME/.config/homesync/homesync.yml - - $XDG_CONFIG_HOME/homesync.yml - - $XDG_CONFIG_HOME/homesync/homesync.yml + - $HOME/.homesync.yml + - $HOME/.config/homesync/homesync.yml + - $XDG_CONFIG_HOME/homesync.yml + - $XDG_CONFIG_HOME/homesync/homesync.yml neovim: - configs: - - $HOME/.config/nvim/init.vim - - $XDG_CONFIG_HOME/nvim/init.vim + - $HOME/.config/nvim/init.vim + - $XDG_CONFIG_HOME/nvim/init.vim termite: - configs: - - $HOME/.config/termite/config - - $XDG_CONFIG_HOME/termite/config + - $HOME/.config/termite/config + - $XDG_CONFIG_HOME/termite/config tmux: - configs: - - $HOME/.tmux.conf + - $HOME/.tmux.conf diff --git a/examples/template.yaml b/examples/template.yaml index 2b4b340..f3c9b2f 100644 --- a/examples/template.yaml +++ b/examples/template.yaml @@ -2,15 +2,18 @@ user: name: name email: email@email.com -local: $HOME/.homesync -remote: - name: origin - branch: master - url: "https://github.com/owner/repo.git" +ssh: + public: $HOME/.ssh/id_ed25519.pub + private: $HOME/.ssh/id_ed25519 +repos: + local: $HOME/.homesync + remote: + name: origin + branch: master + url: "https://github.com/owner/repo.git" packages: homesync: - configs: - - $HOME/.homesync.yml - - $HOME/.config/homesync/homesync.yml - - $XDG_CONFIG_HOME/homesync.yml - - $XDG_CONFIG_HOME/homesync/homesync.yml + - $HOME/.homesync.yml + - $HOME/.config/homesync/homesync.yml + - $XDG_CONFIG_HOME/homesync.yml + - $XDG_CONFIG_HOME/homesync/homesync.yml diff --git a/src/config.rs b/src/config.rs index 48db143..0bfbe2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,6 @@ use paris::formatter::colorize_string; use serde_derive::{Deserialize, Serialize}; use simplelog::{info, paris}; use std::{collections::BTreeMap, env::VarError, error, fmt, fs, io, io::Write, path::PathBuf}; -use url::{ParseError, Url}; // ======================================== // Error @@ -15,7 +14,6 @@ pub type Result = std::result::Result; pub enum Error { IOError(io::Error), MissingConfig, - ParseError(ParseError), SerdeError(serde_yaml::Error), VarError(VarError), } @@ -41,12 +39,6 @@ impl From for Error { } } -impl From for Error { - fn from(err: ParseError) -> Error { - Error::ParseError(err) - } -} - impl From for Error { fn from(err: VarError) -> Error { Error::VarError(err) @@ -58,7 +50,6 @@ impl fmt::Display for Error { match self { Error::IOError(e) => write!(f, "{}", e), Error::MissingConfig => write!(f, "Could not find configuration file"), - Error::ParseError(e) => write!(f, "{}", e), Error::SerdeError(e) => write!(f, "{}", e), Error::VarError(e) => write!(f, "{}", e), } @@ -78,23 +69,36 @@ pub struct User { } #[derive(Debug, Deserialize, Serialize)] -pub struct Package { - pub configs: Vec, +pub struct SSH { + pub public: Option, + pub private: PathBuf, } #[derive(Debug, Deserialize, Serialize)] pub struct Remote { pub name: String, pub branch: String, - pub url: Url, + pub url: String, +} + +impl Remote { + pub fn tracking_branch(&self) -> String { + format!("{}/{}", &self.name, &self.branch) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Repos { + pub local: PathBuf, + pub remote: Remote, } #[derive(Debug, Deserialize, Serialize)] pub struct Config { pub user: User, - pub local: PathBuf, - pub remote: Remote, - pub packages: BTreeMap, + pub ssh: SSH, + pub repos: Repos, + pub packages: BTreeMap>, } impl Config { @@ -160,7 +164,7 @@ pub fn load(candidates: &Vec) -> Result { pub fn reload(pc: &PathConfig) -> Result { info!( "{} configuration reloaded.", - pc.config.local.display() + pc.config.repos.local.display() ); load(&vec![pc.homesync_yml.clone()]) } diff --git a/src/daemon.rs b/src/daemon.rs index 9234ec3..e4d2941 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -123,8 +123,8 @@ impl<'a> WatchState<'a> { } } self.watching.clear(); - for (_, package) in &pc.config.packages { - for path in &package.configs { + for (_, packages) in &pc.config.packages { + for path in packages { match path::soft_resolve(&path) { Ok(None) => self.send_poll(PollEvent::Pending(path.clone())), Ok(Some(n)) => self.watch(n), diff --git a/src/git.rs b/src/git.rs index dff5ec4..09ab154 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,10 +1,10 @@ use super::{config::PathConfig, path}; use git2::{ - Branch, BranchType, Commit, DiffOptions, Direction, IndexAddOption, ObjectType, Remote, - Repository, Signature, + Branch, BranchType, Commit, Cred, DiffOptions, Direction, FetchOptions, Index, IndexAddOption, + ObjectType, Remote, RemoteCallbacks, Repository, Signature, }; use path::ResPathBuf; -use simplelog::{info, paris, warn}; +use simplelog::{info, paris}; use std::{ collections::HashSet, env::VarError, @@ -75,30 +75,12 @@ impl error::Error for Error {} // Initialization // ======================================== -fn clone_or_init(pc: &PathConfig, expanded: &Path) -> Result { - match Repository::clone(&pc.config.remote.url.to_string(), &expanded) { - Ok(repo) => { - info!( - "Cloned remote repository {}.", - &pc.config.remote.url - ); - Ok(repo) - } - Err(e) if e.code() == git2::ErrorCode::NotFound || e.code() == git2::ErrorCode::Auth => { - // TODO(jrpotter): Setup authentication callbacks so private - // repositories work. - // https://docs.rs/git2/0.13.25/git2/build/struct.RepoBuilder.html#example - if e.code() == git2::ErrorCode::Auth { - warn!("Could not authenticate against remote. Are you using a public repository?"); - } - info!( - "Creating local repository at {}.", - pc.config.local.display() - ); - Ok(Repository::init(&expanded)?) - } - Err(e) => Err(e)?, - } +fn clone(pc: &PathConfig, expanded: &Path) -> Result { + let fetch_options = get_fetch_options(&pc)?; + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fetch_options); + + Ok(builder.clone(&pc.config.repos.remote.url, &expanded)?) } /// Sets up a local github repository all configuration files will be synced to. @@ -112,7 +94,7 @@ 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 = path::expand(&pc.config.local)?; + let expanded = path::expand(&pc.config.repos.local)?; // Attempt to open the local path as a git repository if possible. The // `NotFound` error is thrown if: // @@ -125,11 +107,29 @@ pub fn init(pc: &PathConfig) -> Result { Ok(repo) => { info!( "Opened local repository {}.", - &pc.config.local.display() + &pc.config.repos.local.display() ); Ok(repo) } - Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(clone_or_init(&pc, &expanded)?), + Err(e) if e.code() == git2::ErrorCode::NotFound => match clone(&pc, &expanded) { + Ok(repo) => { + info!( + "Cloned remote repository {}.", + &pc.config.repos.remote.url + ); + Ok(repo) + } + Err(Error::GitError(e)) + if e.code() == git2::ErrorCode::Eof && e.class() == git2::ErrorClass::Ssh => + { + info!( + "Creating local repository at {}.", + pc.config.repos.local.display() + ); + Ok(Repository::init(&expanded)?) + } + Err(e) => Err(e)?, + }, Err(e) => Err(e)?, } } @@ -159,8 +159,8 @@ fn find_repo_files(path: &Path) -> Result> { fn find_package_files(pc: &PathConfig) -> Vec { let mut seen = Vec::new(); - for (_, package) in &pc.config.packages { - for path in &package.configs { + for (_, packages) in &pc.config.packages { + for path in packages { if let Ok(resolved) = path::resolve(path) { seen.push(resolved); } @@ -214,16 +214,11 @@ pub fn stage(pc: &PathConfig, repo: &Repository) -> Result<()> { // Syncing // ======================================== -pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { - // First pull to make sure there are no conflicts when we push our changes. - // This will also perform validation and construct our local and remote - // environment. - let _local_branch = pull(&pc, &repo)?; - let mut remote = get_remote(&pc, &repo)?; - - // The index corresponds to our staging area. We add all files and write out - // to a tree. The resulting tree can be found using `git ls-tree `. - // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects +/// Adds all files to our index. +/// +/// Checks explicitly if any changes have been detected in the newly constructed +/// index. If not, return `None`. +pub fn index_add(repo: &Repository) -> Result> { let mut index = repo.index()?; index.add_all(["."].iter(), IndexAddOption::DEFAULT, None)?; let diff_stats = repo @@ -240,11 +235,48 @@ pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { && diff_stats.insertions() == 0 && diff_stats.deletions() == 0 { - info!("Nothing to push. Have you run `homesync stage`?"); - return Ok(()); + Ok(None) + } else { + Ok(Some(index)) } +} - let signature = get_signature(&pc)?; +/// Create or retrieve the remote specified within our configuration. +/// +/// This method also configures the fetchspec for the remote, explicitly mapping +/// the remote branch against our local one. +/// +/// https://git-scm.com/book/en/v2/Git-Internals-The-Refspec +fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { + repo.remote_set_url(&pc.config.repos.remote.name, &pc.config.repos.remote.url)?; + repo.remote_add_fetch( + &pc.config.repos.remote.name, + // We could go with "*" instead of {branch} for all remote branches. + &format!( + "+refs/heads/{}:refs/remotes/{}", + pc.config.repos.remote.branch, + pc.config.repos.remote.tracking_branch(), + ), + )?; + Ok(repo.find_remote(&pc.config.repos.remote.name)?) +} + +pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { + // First pull to make sure there are no conflicts when we push our changes. + // This will also perform validation and construct our local and remote + // environment. + let _local_branch = pull(&pc, &repo)?; + + // The index corresponds to our staging area. We add all files and write out + // to a tree. The resulting tree can be found using `git ls-tree `. + // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects + let mut index = match index_add(&repo)? { + Some(index) => index, + None => { + info!("Nothing to push. Have you run `homesync stage`?"); + return Ok(()); + } + }; // Retrieve the latest commit before writing to the object database. let parent_commit = get_commit(&repo)?; let index_oid = index.write_tree()?; @@ -252,7 +284,8 @@ pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { info!("Writing index to tree `{}`.", index_oid); // Commit our changes and push them to our remote. - let refspec = format!("refs/heads/{}", &pc.config.remote.branch); + let refspec = format!("refs/heads/{}", &pc.config.repos.remote.branch); + let signature = get_signature(&pc)?; repo.commit( Some(&refspec), &signature, @@ -262,11 +295,13 @@ pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { &index_tree, &[&parent_commit], )?; + + let mut remote = get_remote(&pc, &repo)?; remote.connect(Direction::Push)?; remote.push(&[&format!("{r}:{r}", r = refspec)], None)?; info!( - "Pushed changes to remote `{}/{}`.", - &pc.config.remote.name, &pc.config.remote.branch + "Pushed changes to remote `{}`.", + pc.config.repos.remote.tracking_branch(), ); Ok(()) @@ -275,17 +310,25 @@ pub fn push(pc: &PathConfig, repo: &mut Repository) -> Result<()> { pub fn pull<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { validate_repo(&repo)?; + // TODO(jrpotter): Configure our remote to point to the same URL mentioned + // in our config. + // TODO(jrpotter): If changes are available, need to stage them and then + // reapply. + // Establish our remote. If the remote already exists, re-configure it // blindly to point to the appropriate url. Our results should now exist // in a branch called `remotes/origin/`. // https://git-scm.com/book/it/v2/Git-Basics-Working-with-Remotes - // TODO(jrpotter): Configure our remote to point to the same URL mentioned - // in our config. let mut remote = get_remote(&pc, &repo)?; - remote.fetch(&[&pc.config.remote.branch], None, None)?; - let remote_branch_name = format!("{}/{}", &pc.config.remote.name, &pc.config.remote.branch); + let mut fetch_options = get_fetch_options(&pc)?; + remote.fetch( + &[&pc.config.repos.remote.branch], + Some(&mut fetch_options), + None, + )?; + let remote_branch_name = pc.config.repos.remote.tracking_branch(); let remote_branch = repo.find_branch(&remote_branch_name, BranchType::Remote)?; - info!("Fetched remote branch `{}`.", remote_branch_name); + info!("Fetched remote branch `{}`.", &remote_branch_name); // There are two cases we need to consider: // @@ -293,11 +336,8 @@ pub fn pull<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result(pc: &PathConfig, repo: &'repo Repository) -> Result Result { .map_err(|_| git2::Error::from_str("Couldn't find commit"))?) } -/// Create or retrieve the remote specified within our configuration. -/// -/// This method also configures the fetchspec for the remote, explicitly mapping -/// the remote branch against our local one. -/// -/// https://git-scm.com/book/en/v2/Git-Internals-The-Refspec -fn get_remote<'repo>(pc: &PathConfig, repo: &'repo Repository) -> Result> { - repo.remote_set_url(&pc.config.remote.name, &pc.config.remote.url.to_string())?; - repo.remote_add_fetch( - &pc.config.remote.name, - // We could go with "*" instead of {branch} for all remote branches. - &format!( - "+refs/heads/{branch}:refs/remotes/origin/{branch}", - branch = pc.config.remote.branch - ), - )?; - Ok(repo.find_remote(&pc.config.remote.name)?) +/// Construct callbacks to supply authentication information on fetch/clone/etc. +fn get_fetch_options(pc: &PathConfig) -> Result { + let public_path = match &pc.config.ssh.public { + Some(p) => Some(path::resolve(p)?), + None => None, + }; + let private_path = path::resolve(&pc.config.ssh.private)?; + + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(move |_url, username_from_url, _allowed_types| { + Cred::ssh_key( + username_from_url.unwrap(), + public_path.as_ref().map(|p| p.resolved().as_ref()), + private_path.as_ref(), + None, + ) + }); + + let mut fetch_options = FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + Ok(fetch_options) } /// Generate a new signature at the current time.