Initial commit

This commit is contained in:
2021-11-15 16:16:15 +01:00
commit f6a51c70cc
12 changed files with 1504 additions and 0 deletions

51
src/cmd.rs Normal file
View File

@@ -0,0 +1,51 @@
use clap::{AppSettings, Parser};
#[derive(Parser)]
#[clap(
name = clap::crate_name!(),
version = clap::crate_version!(),
author = clap::crate_authors!("\n"),
about = clap::crate_description!(),
long_version = clap::crate_version!(),
license = clap::crate_license!(),
setting = AppSettings::DeriveDisplayOrder,
setting = AppSettings::PropagateVersion,
setting = AppSettings::HelpRequired,
)]
pub struct Opts {
#[clap(subcommand)]
pub subcmd: SubCommand,
}
#[derive(Parser)]
pub enum SubCommand {
#[clap(
visible_alias = "run",
about = "Synchronize the repositories to the configured values"
)]
Sync(Sync),
#[clap(about = "Generate a repository configuration from an existing file tree")]
Find(Find),
}
#[derive(Parser)]
#[clap()]
pub struct Sync {
#[clap(
short,
long,
default_value = "./config.toml",
about = "Path to the configuration file"
)]
pub config: String,
}
#[derive(Parser)]
pub struct Find {
#[clap(about = "The path to search through")]
pub path: String,
}
pub fn parse() -> Opts {
Opts::parse()
}

40
src/config.rs Normal file
View File

@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use super::repo::Repo;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub trees: Vec<Tree>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tree {
pub root: Option<String>,
pub repos: Option<Vec<Repo>>,
}
pub fn read_config(path: &str) -> Result<Config, String> {
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
return Err(format!(
"Error reading configuration file \"{}\": {}",
path, e
))
}
};
let config: Config = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
return Err(format!(
"Error parsing configuration file \"{}\": {}",
path, e
))
}
};
Ok(config)
}

395
src/lib.rs Normal file
View File

@@ -0,0 +1,395 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
mod cmd;
mod config;
mod output;
mod repo;
use config::{Config, Tree};
use output::*;
use repo::{clone_repo, detect_remote_type, init_repo, open_repo, Remote, Repo};
fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap()
}
fn env_home() -> PathBuf {
match std::env::var("HOME") {
Ok(path) => Path::new(&path).to_path_buf(),
Err(e) => {
print_error(&format!("Unable to read HOME: {}", e));
process::exit(1);
}
}
}
fn sync_trees(config: Config) {
for tree in config.trees {
let repos = tree.repos.unwrap_or_default();
let root_path = match &tree.root {
Some(root) => {
fn home_dir() -> Option<PathBuf> {
Some(env_home())
}
let expanded_path = match shellexpand::full_with_context(
&root,
home_dir,
|name| -> Result<Option<String>, &'static str> {
match name {
"HOME" => Ok(Some(path_as_string(home_dir().unwrap().as_path()))),
_ => Ok(None),
}
},
) {
Ok(std::borrow::Cow::Borrowed(path)) => path.to_owned(),
Ok(std::borrow::Cow::Owned(path)) => path,
Err(e) => {
print_error(&format!("Unable to expand root: {}", e));
process::exit(1);
}
};
Path::new(&expanded_path).to_path_buf()
}
None => std::env::current_dir().unwrap(),
};
for repo in &repos {
let name = &repo.name;
let repo_path = root_path.join(&repo.name);
let mut repo_handle = None;
if repo_path.exists() {
repo_handle = Some(open_repo(&repo_path).unwrap_or_else(|error| {
print_repo_error(name, &format!("Opening repository failed: {}", error));
process::exit(1);
}));
} else {
match &repo.remotes {
None => {
print_repo_action(
name,
"Repository does not have remotes configured, initializing new",
);
repo_handle = match init_repo(&repo_path) {
Ok(r) => {
print_repo_success(name, "Repository created");
Some(r)
}
Err(e) => {
print_repo_error(
name,
&format!("Repository failed during init: {}", e),
);
None
}
}
}
Some(r) => {
let first = match r.first() {
Some(e) => e,
None => {
panic!("Repos is an empty array. This is a bug");
}
};
match clone_repo(first, &repo_path) {
Ok(_) => {
print_repo_success(name, "Repository successfully cloned");
}
Err(e) => {
print_repo_error(
name,
&format!("Repository failed during clone: {}", e),
);
continue;
}
};
}
}
}
if let Some(remotes) = &repo.remotes {
let repo_handle = repo_handle
.unwrap_or_else(|| open_repo(&repo_path).unwrap_or_else(|_| process::exit(1)));
let current_remotes: Vec<String> = match repo_handle.remotes() {
Ok(r) => r,
Err(e) => {
print_repo_error(
name,
&format!("Repository failed during getting the remotes: {}", e),
);
continue;
}
}
.iter()
.flatten()
.map(|r| r.to_owned())
.collect();
for remote in remotes {
if !current_remotes.iter().any(|r| *r == remote.name) {
print_repo_action(
name,
&format!(
"Setting up new remote \"{}\" to \"{}\"",
&remote.name, &remote.url
),
);
if let Err(e) = repo_handle.remote(&remote.name, &remote.url) {
print_repo_error(
name,
&format!("Repository failed during setting the remotes: {}", e),
);
continue;
}
} else {
let current_remote = repo_handle.find_remote(&remote.name).unwrap();
let current_url = match current_remote.url() {
Some(url) => url,
None => {
print_repo_error(name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name));
continue;
}
};
if remote.url != current_url {
if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) {
print_repo_error(name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e));
continue;
};
}
}
}
for current_remote in &current_remotes {
if !remotes.iter().any(|r| &r.name == current_remote) {
print_repo_action(
name,
&format!("Deleting remote \"{}\"", &current_remote,),
);
if let Err(e) = repo_handle.remote_delete(current_remote) {
print_repo_error(
name,
&format!(
"Repository failed during deleting remote \"{}\": {}",
&current_remote, e
),
);
continue;
}
}
}
}
print_repo_success(&repo.name, "OK");
}
let current_repos = find_repos_without_details(&root_path).unwrap();
for repo in current_repos {
let name = path_as_string(repo.strip_prefix(&root_path).unwrap());
if !repos.iter().any(|r| r.name == name) {
print_warning(&format!("Found unmanaged repository: {}", name));
}
}
}
}
fn find_repos_without_details(path: &Path) -> Option<Vec<PathBuf>> {
let mut repos: Vec<PathBuf> = Vec::new();
let git_dir = path.join(".git");
if git_dir.exists() {
repos.push(path.to_path_buf());
} else {
match fs::read_dir(path) {
Ok(contents) => {
for content in contents {
match content {
Ok(entry) => {
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
if let Some(mut r) = find_repos_without_details(&path) {
repos.append(&mut r);
};
}
}
Err(e) => {
print_error(&format!("Error accessing directory: {}", e));
continue;
}
};
}
}
Err(e) => {
print_error(&format!("Failed to open \"{}\": {}", &path.display(), &e));
return None;
}
};
}
Some(repos)
}
fn find_repos(root: &Path, at_root: bool) -> Option<Vec<Repo>> {
let mut repos: Vec<Repo> = Vec::new();
for path in find_repos_without_details(root).unwrap() {
let repo = match open_repo(&path) {
Ok(r) => r,
Err(e) => {
print_error(&format!("Error opening repo {}: {}", path.display(), e));
return None;
}
};
let remotes = match repo.remotes() {
Ok(remotes) => {
let mut results: Vec<Remote> = Vec::new();
for remote in remotes.iter() {
match remote {
Some(remote_name) => {
match repo.find_remote(remote_name) {
Ok(remote) => {
let name = match remote.name() {
Some(name) => name.to_string(),
None => {
print_repo_error(&path_as_string(&path), &format!("Falied getting name of remote \"{}\". This is most likely caused by a non-utf8 remote name", remote_name));
process::exit(1);
}
};
let url = match remote.url() {
Some(url) => url.to_string(),
None => {
print_repo_error(&path_as_string(&path), &format!("Falied getting URL of remote \"{}\". This is most likely caused by a non-utf8 URL", name));
process::exit(1);
}
};
let remote_type = match detect_remote_type(&url) {
Some(t) => t,
None => {
print_repo_error(
&path_as_string(&path),
&format!(
"Could not detect remote type of \"{}\"",
&url
),
);
process::exit(1);
}
};
results.push(Remote {
name,
url,
remote_type,
});
}
Err(e) => {
print_repo_error(
&path_as_string(&path),
&format!("Error getting remote {}: {}", remote_name, e),
);
process::exit(1);
}
};
}
None => {
print_repo_error(&path_as_string(&path), "Error getting remote. This is most likely caused by a non-utf8 remote name");
process::exit(1);
}
};
}
Some(results)
}
Err(e) => {
print_repo_error(
&path_as_string(&path),
&format!("Error getting remotes: {}", e),
);
process::exit(1);
}
};
repos.push(Repo {
name: match at_root {
true => match &root.parent() {
Some(parent) => path_as_string(path.strip_prefix(parent).unwrap()),
None => {
print_error("Getting name of the search root failed. Do you have a git repository in \"/\"?");
process::exit(1);
},
}
false => path_as_string(path.strip_prefix(&root).unwrap()),
},
remotes,
});
}
Some(repos)
}
fn find_in_tree(path: &Path) -> Option<Tree> {
let repos: Vec<Repo> = match find_repos(path, true) {
Some(vec) => vec,
None => Vec::new(),
};
let mut root = path.to_path_buf();
let home = env_home();
if root.starts_with(&home) {
// The tilde is not handled differently, it's just a normal path component for `Path`.
// Therefore we can treat it like that during **output**.
root = Path::new("~").join(root.strip_prefix(&home).unwrap());
}
Some(Tree {
root: Some(root.into_os_string().into_string().unwrap()),
repos: Some(repos),
})
}
pub fn run() {
let opts = cmd::parse();
match opts.subcmd {
cmd::SubCommand::Sync(sync) => {
let config = match config::read_config(&sync.config) {
Ok(c) => c,
Err(e) => {
print_error(&e);
process::exit(1);
}
};
sync_trees(config);
}
cmd::SubCommand::Find(find) => {
let path = Path::new(&find.path);
if !path.exists() {
print_error(&format!("Path \"{}\" does not exist", path.display()));
process::exit(1);
}
let path = &path.canonicalize().unwrap();
if !path.is_dir() {
print_error(&format!("Path \"{}\" is not a directory", path.display()));
process::exit(1);
}
let config = Config {
trees: vec![find_in_tree(path).unwrap()],
};
let toml = toml::to_string(&config).unwrap();
print!("{}", toml);
}
}
}

5
src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
use grm::run;
fn main() {
run();
}

58
src/output.rs Normal file
View File

@@ -0,0 +1,58 @@
use console::{Style, Term};
pub fn print_repo_error(repo: &str, message: &str) {
print_error(&format!("{}: {}", repo, message));
}
pub fn print_error(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().red();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2718}'), &message))
.unwrap();
}
pub fn print_repo_action(repo: &str, message: &str) {
print_action(&format!("{}: {}", repo, message));
}
pub fn print_action(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().yellow();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2699}'), &message))
.unwrap();
}
pub fn print_warning(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().yellow();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('!'), &message))
.unwrap();
}
pub fn print_repo_success(repo: &str, message: &str) {
print_success(&format!("{}: {}", repo, message));
}
pub fn print_success(message: &str) {
let stderr = Term::stderr();
let mut style = Style::new().green();
if stderr.is_term() {
style = style.force_styling(true);
}
stderr
.write_line(&format!("[{}] {}", style.apply_to('\u{2714}'), &message))
.unwrap();
}

150
src/repo.rs Normal file
View File

@@ -0,0 +1,150 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use git2::{Cred, RemoteCallbacks, Repository};
use crate::output::*;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteType {
Ssh,
Https,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Remote {
pub name: String,
pub url: String,
#[serde(alias = "type")]
pub remote_type: RemoteType,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repo {
pub name: String,
pub remotes: Option<Vec<Remote>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_ssh_remote() {
assert_eq!(
detect_remote_type("ssh://git@example.com"),
Some(RemoteType::Ssh)
);
assert_eq!(detect_remote_type("git@example.git"), Some(RemoteType::Ssh));
}
#[test]
fn check_https_remote() {
assert_eq!(
detect_remote_type("https://example.com"),
Some(RemoteType::Https)
);
assert_eq!(
detect_remote_type("https://example.com/test.git"),
Some(RemoteType::Https)
);
}
#[test]
fn check_invalid_remotes() {
assert_eq!(detect_remote_type("https//example.com"), None);
assert_eq!(detect_remote_type("https:example.com"), None);
assert_eq!(detect_remote_type("ssh//example.com"), None);
assert_eq!(detect_remote_type("ssh:example.com"), None);
assert_eq!(detect_remote_type("git@example.com"), None);
}
#[test]
#[should_panic]
fn check_unsupported_protocol_http() {
detect_remote_type("http://example.com");
}
#[test]
#[should_panic]
fn check_unsupported_protocol_git() {
detect_remote_type("git://example.com");
}
#[test]
#[should_panic]
fn check_unsupported_protocol_file() {
detect_remote_type("file:///");
}
}
pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").unwrap();
if remote_url.starts_with("ssh://") {
return Some(RemoteType::Ssh);
}
if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
return Some(RemoteType::Ssh);
}
if remote_url.starts_with("https://") {
return Some(RemoteType::Https);
}
if remote_url.starts_with("http://") {
unimplemented!("Remotes using HTTP protocol are not supported");
}
if remote_url.starts_with("git://") {
unimplemented!("Remotes using git protocol are not supported");
}
if remote_url.starts_with("file://") || remote_url.starts_with('/') {
unimplemented!("Remotes using local protocol are not supported");
}
None
}
pub fn open_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::open(path) {
Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
}
}
pub fn init_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::init(path) {
Ok(r) => Ok(r),
Err(e) => Err(Box::new(e)),
}
}
pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
print_action(&format!(
"Cloning into \"{}\" from \"{}\"",
&path.display(),
&remote.url
));
match remote.remote_type {
RemoteType::Https => match Repository::clone(&remote.url, &path) {
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
},
RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
Cred::ssh_key_from_agent(username_from_url.unwrap())
});
let mut fo = git2::FetchOptions::new();
fo.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fo);
match builder.clone(&remote.url, path) {
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
}
}