diff options
-rw-r--r-- | Makefile | 11 | ||||
-rw-r--r-- | README.md | 30 | ||||
-rw-r--r-- | src/gfold.rs | 188 | ||||
-rw-r--r-- | src/lib.rs | 225 | ||||
-rw-r--r-- | src/main.rs | 25 | ||||
-rw-r--r-- | src/util.rs | 17 |
6 files changed, 260 insertions, 236 deletions
@@ -8,6 +8,8 @@ MAKEPATH:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) NAME:=gfold VERSION:=0.5.2 +all: build + run: @cd $(MAKEPATH); cargo run -- .. @@ -20,16 +22,15 @@ install: install-local: cargo install --path $(MAKEPATH) -build: fmt test +build: pre-build cd $(MAKEPATH); cargo build -build-release: fmt test +build-release: pre-build cd $(MAKEPATH); cargo build --release -fmt: +pre-build: cd $(MAKEPATH); cargo fmt - -test: + cd $(MAKEPATH); cargo clippy cd $(MAKEPATH); cargo test tree: @@ -30,7 +30,7 @@ However, if you would like to target another directory, you can pass that path ( There are multiple ways to install ```gfold```, but here are the currently recommended methods...
-### 1) Download a GitHub Binary
+### 1. Download a GitHub Binary
Most likely, the easiest method of obtaining ```gfold``` is via the [latest GitHub release](https://github.com/nickgerace/gfold/releases/latest).
@@ -47,30 +47,34 @@ You may have to reload your shell in order to see ```gfold``` in your ```PATH``` #### Advanced Management
You can use symbolic links to swap between versions, and manage multiple at a time.
-Here is a full install workflow...
+Here is a full install workflow example...
```bash
-wget https://github.com/nickgerace/gfold/releases/download/$VERSION/gfold-linux-gnu-amd64)
-mv gfold-linux-gnu-amd64 gfold-$VERSION
+VERSION=0.5.2
+PLATFORM=linux-gnu-amd64
+
+wget https://github.com/nickgerace/gfold/releases/download/$VERSION/gfold-$PLATFORM
+mv gfold-$PLATFORM gfold-$VERSION
chmod +x gfold-$VERSION
-sudo mkdir /usr/local/gfold/
-sudo mv gfold-$VERSION /usr/local/gfold/
+
+mkdir /usr/local/gfold/
+mv gfold-$VERSION /usr/local/gfold/
ln -s /usr/local/gfold/gfold-$VERSION /usr/local/bin/gfold
```
Now, you can add/remove versions of the binary from ```/usr/local/gfold/```, and change the symbolic link as needed.
-### 2) AUR (Arch User Repository)
+### 2. Arch User Repository (AUR)
This application is available for all Linux distributions that support installing packages from the AUR.
-Special thanks to [orhun](https://github.com/orhun) for [maintaining](https://github.com/orhun/PKGBUILDs) these packages.
- [gfold](https://aur.archlinux.org/packages/gfold/) (builds from source)
+- [gfold-bin](https://aur.archlinux.org/packages/gfold-bin/) (uses the GitHub release binary)
- [gfold-git](https://aur.archlinux.org/packages/gfold-git/) (VCS/development package)
Many people choose to use an [AUR helper](https://wiki.archlinux.org/index.php/AUR_helpers), such as [yay](https://github.com/Jguer/yay) (example: ```yay -S gfold```), in order to install their AUR packages.
-### 3) Cargo Install
+### 3. Cargo Install
You can build from source with ```cargo``` by executing the following...
@@ -100,11 +104,11 @@ gfold -r $HOME/path/to/multiple/repositories ## Compatibility
-All external crates were vetted for support on all three major desktop platforms.
-```gfold``` is tested for the latest versions of the following systems, but may work on more...
+```gfold```, and its external crates, support all three major desktop platforms.
+It is tested for the latest versions of the following systems, but may work on more...
- **Linux**: ```linux-gnu-amd64```
-- **macOS**: ```darwin-amd64```
+- **macOS**: ```macos-amd64```
- **Windows 10**: ```windows-amd64```
## Changelog
@@ -120,5 +124,5 @@ It follows the [Keep a Changelog](https://keepachangelog.com/) format. ## Special Thanks
- [@yaahc](https://github.com/yaahc) (mentoring)
-- [@orhun](https://github.com/orhun) (maintaining AUR packages)
+- [@orhun](https://github.com/orhun) (maintaining [all three AUR packages](https://github.com/orhun/PKGBUILDs))
- [@jrcichra](https://github.com/jrcichra) (adding multi-OS support to the original CI pipeline)
diff --git a/src/gfold.rs b/src/gfold.rs deleted file mode 100644 index 2af9385..0000000 --- a/src/gfold.rs +++ /dev/null @@ -1,188 +0,0 @@ -/* - * gfold - * https://github.com/nickgerace/gfold - * Author: Nick Gerace - * License: Apache 2.0 - */ - -use git2::{ErrorClass, ErrorCode, Repository, StatusOptions}; -use prettytable::{format, Table}; - -use std::fs; -use std::path::Path; - -use crate::util::is_git_repo; - -struct TableWrapper { - path_string: String, - table: Table, -} - -struct Results { - recursive: bool, - tables: Vec<TableWrapper>, -} - -impl Results { - fn print_results(self) { - if self.recursive && self.tables.len() > 1 { - for table_wrapper in self.tables { - println!("\n{}", table_wrapper.path_string); - table_wrapper.table.printstd(); - } - } else if self.tables.len() == 1 { - self.tables[0].table.printstd(); - } else { - println!("There are no results to display."); - } - } - - fn sort_results(&mut self) { - // FIXME: find a way to do this without "clone()". - self.tables.sort_by_key(|table| table.path_string.clone()); - } - - fn execute_on_path(&mut self, path: &Path) { - // FIXME: find ways to add concurrent programming (tokio, async, etc.) to this section. - // In such implementation, sort the results at the end after concurrent operations conclude. - let path_entries = fs::read_dir(path).expect("failed to get sub directories"); - let mut repos = Vec::new(); - - for entry in path_entries { - let subpath = &entry.expect("failed to get DirEntry").path(); - if subpath.is_dir() { - if is_git_repo(subpath) { - repos.push(subpath.to_owned()); - } else if self.recursive { - self.execute_on_path(&subpath); - } - } - } - if repos.is_empty() { - return; - } - - // Alphabetically sort the repository paths, and create a mutable Table object. For - // every path, we will create a Table containing its results. - repos.sort(); - let mut table = Table::new(); - table.set_format( - format::FormatBuilder::new() - .column_separator(' ') - .padding(0, 1) - .build(), - ); - - for repo in repos { - let repo_obj = Repository::open(&repo).expect("failed to open"); - - // This match cascade combats the error: remote 'origin' does not exist. If we - // encounter this specific error, then we "continue" to the next iteration. - let origin = match repo_obj.find_remote("origin") { - Ok(origin) => origin, - Err(error) if error.class() == ErrorClass::Config => continue, - Err(error) => panic!("{}", error), - }; - let url = match origin.url() { - Some(url) => url, - None => "none", - }; - let head = repo_obj.head().expect("failed get head"); - let branch = match head.shorthand() { - Some(head) => head, - None => "none", - }; - - // If the repository is bare, then we return "None". This addresses GitHub issue #11 - // (https://github.com/nickgerace/gfold/issues/11), and special thanks to @yaahc_ for - // the recommendation to use a "match guard" here. We also use the Option type instead - // to handle the "None" case later. - let mut opts = StatusOptions::new(); - let statuses = match repo_obj.statuses(Some(&mut opts)) { - Ok(statuses) => Some(statuses), - Err(error) - if error.code() == ErrorCode::BareRepo - && error.class() == ErrorClass::Repository => - { - None - } - Err(error) => panic!("failed to get statuses: {}", error), - }; - - let formatted_name = Path::new(&repo) - .strip_prefix(path) - .expect("failed to format name from Path object"); - let str_name = match formatted_name.to_str() { - Some(x) => x, - None => "none", - }; - - match statuses { - Some(statuses) if statuses.is_empty() => { - table.add_row(row![Flb->str_name, Fgl->"clean", Fl->branch, Fl->url]) - } - Some(_) => table.add_row(row![Flb->str_name, Fyl->"unclean", Fl->branch, Fl->url]), - None => table.add_row(row![Flb->str_name, Frl->"bare", Fl->branch, Fl->url]), - }; - } - - // Only perform the following actions if the Table object is not empty. We only want - // results for directories that contain repositories. Push the resulting TableWrapper - // object aftering creating a heap-allocated string for the path name. - if !table.is_empty() { - let path_string = path - .to_str() - .expect("could not convert &Path object to &str object"); - let table_wrapper = TableWrapper { - path_string: path_string.to_string(), - table: table, - }; - self.tables.push(table_wrapper); - } - } -} - -pub fn harness(path: &Path, recursive: bool, skip_sort: bool) { - let mut results = Results { - recursive: recursive, - tables: Vec::new(), - }; - results.execute_on_path(&path); - if !skip_sort { - results.sort_results(); - } - results.print_results(); -} - -#[cfg(test)] -mod tests { - use super::harness; - use std::env::current_dir; - - #[test] - fn current_directory() { - let current_dir = current_dir().expect("failed to get CWD"); - harness(¤t_dir, false, false); - } - - #[test] - fn parent_directory() { - let mut current_dir = current_dir().expect("failed to get CWD"); - current_dir.pop(); - harness(¤t_dir, false, false); - } - - #[test] - fn parent_directory_recursive() { - let mut current_dir = current_dir().expect("failed to get CWD"); - current_dir.pop(); - harness(¤t_dir, true, false); - } - - #[test] - fn parent_directory_recursive_skip_sort() { - let mut current_dir = current_dir().expect("failed to get CWD"); - current_dir.pop(); - harness(¤t_dir, true, true); - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..674a75a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,225 @@ +/* + * gfold + * https://github.com/nickgerace/gfold + * Author: Nick Gerace + * License: Apache 2.0 + */ + +#[macro_use] +extern crate prettytable; + +use std::cmp::Ordering; +use std::error::Error; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +/// Creating a ```Results``` object requires using this ```struct``` as a pre-requisite. +struct Config { + recursive: bool, + skip_sort: bool, +} + +/// This ```struct``` is a wrapper around the ```prettytable::Table``` object. +/// It exists to provide a label for the table. +struct TableWrapper { + path_string: String, + table: prettytable::Table, +} + +/// Contains all tables with results for each directory. +struct Results(Vec<TableWrapper>); + +impl Results { + /// Create a new ```Results``` object with a given path and config. + fn new(path: &Path, config: &Config) -> Result<Results, Box<dyn Error>> { + let mut results = Results(Vec::new()); + results.execute_in_directory(&config, path)?; + if !&config.skip_sort { + results.sort_results(); + } + Ok(results) + } + + /// Load results into the calling ```Results``` object via a given path and config. + /// This function may be called recursively. + fn execute_in_directory(&mut self, config: &Config, dir: &Path) -> Result<(), Box<dyn Error>> { + // FIXME: find ways to add concurrent programming (tokio, async, etc.) to this section. + let path_entries = fs::read_dir(dir)?; + let mut repos = Vec::new(); + + for entry in path_entries { + let subpath = &entry?.path(); + if subpath.is_dir() { + if git2::Repository::open(subpath).is_ok() { + repos.push(subpath.to_owned()); + } else if config.recursive { + self.execute_in_directory(&config, &subpath)?; + } + } + } + if !repos.is_empty() { + if !&config.skip_sort { + repos.sort(); + } + // If a table was successfully created with the given repositories, add the table. + if let Some(table_wrapper) = create_table_from_paths(repos, &dir) { + self.0.push(table_wrapper); + } + } + Ok(()) + } + + /// Sort the results alphabetically using ```sort_by_key```. + /// This function will only perform the sort if there are at least two ```TableWrapper``` objects. + fn sort_results(&mut self) { + if self.0.len() >= 2 { + // FIXME: find a way to do this without "clone()". + self.0.sort_by_key(|table| table.path_string.clone()); + } + } + + /// Iterate through every table and print each to STDOUT. + /// If there is only one table, this function avoids using a loop. + fn print_results(self) { + match self.0.len().cmp(&1) { + Ordering::Greater => { + for table_wrapper in self.0 { + println!("\n{}", table_wrapper.path_string); + table_wrapper.table.printstd(); + } + } + Ordering::Equal => { + self.0[0].table.printstd(); + } + Ordering::Less => { + println!("There are no results to display."); + } + }; + } +} + +/// Create a ```TableWrapper``` object from a given vector of paths (```Vec<PathBuf>```). +fn create_table_from_paths(repos: Vec<PathBuf>, path: &Path) -> Option<TableWrapper> { + // For every path, we will create a mutable Table containing its results. + let mut table = prettytable::Table::new(); + table.set_format( + prettytable::format::FormatBuilder::new() + .column_separator(' ') + .padding(0, 1) + .build(), + ); + + // FIXME: handle all panic and fatal scenarios in this loop. + for repo in repos { + let repo_obj = git2::Repository::open(&repo).expect("failed to open"); + + // This match cascade combats the error: remote 'origin' does not exist. If we + // encounter this specific error, then we "continue" to the next iteration. + let origin = match repo_obj.find_remote("origin") { + Ok(origin) => origin, + Err(error) if error.class() == git2::ErrorClass::Config => continue, + Err(error) => panic!("{}", error), + }; + let url = match origin.url() { + Some(url) => url, + None => "none", + }; + let head = repo_obj.head().expect("failed get head"); + let branch = match head.shorthand() { + Some(head) => head, + None => "none", + }; + + // Special thanks to @yaahc_ for the original recommendation to use a "match guard" here. + // The code has evolved since the original implementation, but the core idea still stands! + let mut opts = git2::StatusOptions::new(); + let statuses = match repo_obj.statuses(Some(&mut opts)) { + Ok(statuses) => Ok(Some(statuses)), + Err(error) + if error.code() == git2::ErrorCode::BareRepo + && error.class() == git2::ErrorClass::Repository => + { + Ok(None) + } + Err(error) => Err(error), + }; + + let formatted_name = Path::new(&repo) + .strip_prefix(path) + .expect("failed to format name from Path object"); + let str_name = match formatted_name.to_str() { + Some(x) => x, + None => "none", + }; + + match statuses { + Ok(statuses_contents) => match statuses_contents { + Some(statuses_contents) if statuses_contents.is_empty() => { + table.add_row(row![Flb->str_name, Fgl->"clean", Fl->branch, Fl->url]) + } + Some(_) => table.add_row(row![Flb->str_name, Fyl->"unclean", Fl->branch, Fl->url]), + None => table.add_row(row![Flb->str_name, Frl->"bare", Fl->branch, Fl->url]), + }, + Err(_) => table.add_row(row![Flb->str_name, Frl->"error", Fl->branch, Fl->url]), + }; + } + + // After looping over all the paths, check if the table contains any rows. We perform this + // check because we only want results for directories that contain Git repositories. Push + // the resulting TableWrapper object after creating a heap-allocated string for the path name. + if table.is_empty() { + return None; + } + let path_string = path + .to_str() + .expect("could not convert &Path object to &str object"); + Some(TableWrapper { + path_string: path_string.to_string(), + table, + }) +} + +/// This function is the primary driver for this file, ```lib.rs```. +pub fn run(path: &Path, recursive: bool, skip_sort: bool) -> Result<(), Box<dyn Error>> { + let config = Config { + recursive, + skip_sort, + }; + let results = Results::new(path, &config)?; + results.print_results(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn current_directory() { + let current_dir = env::current_dir().expect("failed to get CWD"); + assert_ne!(run(¤t_dir, false, false).is_err(), true); + } + + #[test] + fn parent_directory() { + let mut current_dir = env::current_dir().expect("failed to get CWD"); + current_dir.pop(); + assert_ne!(run(¤t_dir, false, false).is_err(), true); + } + + #[test] + fn parent_directory_recursive() { + let mut current_dir = env::current_dir().expect("failed to get CWD"); + current_dir.pop(); + assert_ne!(run(¤t_dir, true, false).is_err(), true); + } + + #[test] + fn parent_directory_recursive_skip_sort() { + let mut current_dir = env::current_dir().expect("failed to get CWD"); + current_dir.pop(); + assert_ne!(run(¤t_dir, true, true).is_err(), true); + } +} diff --git a/src/main.rs b/src/main.rs index f5d4650..887bfc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,23 +5,17 @@ * License: Apache 2.0 */ -#[macro_use] -extern crate prettytable; - -use structopt::StructOpt; - -use std::env::current_dir; +use std::env; use std::path::PathBuf; +use std::process; -mod gfold; -mod util; -use gfold::harness; +use structopt::StructOpt; #[derive(StructOpt, Debug)] #[structopt( name = "gfold", about = "https://github.com/nickgerace/gfold\n\n\ -This application helps your organize multiple Git repositories via CLI.\n\ +This application helps you keep track of multiple Git repositories via CLI.\n\ By default, it displays relevant information for all repos in the current\n\ working directory." )] @@ -34,14 +28,19 @@ struct Opt { skip_sort: bool, } +/// This file, ```main.rs```, serves as the primary driver for the ```gfold``` library. +/// It is intended to be used as a command-line interface. fn main() { - let mut path = current_dir().expect("failed to get CWD"); + let mut path = env::current_dir().expect("failed to get CWD"); let opt = Opt::from_args(); if let Some(provided_path) = opt.path { path.push(provided_path) }; - path = path.canonicalize().expect("failed to canonicalize path"); - harness(&path, opt.recursive, opt.skip_sort); + + if let Err(error) = gfold::run(&path, opt.recursive, opt.skip_sort) { + eprintln!("Encountered fatal error: {}", error); + process::exit(1); + }; } diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 0e097e6..0000000 --- a/src/util.rs +++ /dev/null @@ -1,17 +0,0 @@ -/* - * gfold - * https://github.com/nickgerace/gfold - * Author: Nick Gerace - * License: Apache 2.0 - */ - -use git2::Repository; - -use std::path::Path; - -pub fn is_git_repo(target: &Path) -> bool { - match Repository::open(target) { - Ok(_) => true, - Err(_) => false, - } -} |