diff --git a/Cargo.lock b/Cargo.lock index be27528..438dec3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,18 @@ dependencies = [ "syn", ] +[[package]] +name = "comfy-table" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42350b81f044f576ff88ac750419f914abb46a03831bb1747134344ee7a4e64" +dependencies = [ + "crossterm", + "strum", + "strum_macros", + "unicode-width", +] + [[package]] name = "console" version = "0.15.0" @@ -95,6 +107,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85525306c4291d1b73ce93c8acf9c339f9b213aef6c1d85c3830cbf1c16325c" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -148,6 +185,7 @@ name = "git-repo-manager" version = "0.1.0" dependencies = [ "clap", + "comfy-table", "console", "git2", "regex", @@ -216,6 +254,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + [[package]] name = "jobserver" version = "0.1.24" @@ -277,6 +324,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lock_api" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.14" @@ -298,6 +354,37 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "mio" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + [[package]] name = "once_cell" version = "1.8.0" @@ -332,6 +419,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -422,6 +534,12 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + [[package]] name = "serde" version = "1.0.130" @@ -451,12 +569,66 @@ dependencies = [ "dirs-next", ] +[[package]] +name = "signal-hook" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fd5867f1c4f2c5be079aee7a2adf1152ebb04a4bc4d341f504b7dece607ed4" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e" + +[[package]] +name = "strum_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "1.0.81" diff --git a/Cargo.toml b/Cargo.toml index cccaf42..b44ea8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "git-repo-manager" version = "0.1.0" edition = "2021" authors = [ - "Hannes Körber ", + "Hannes Körber ", ] description = """ Manage multiple git repositories. @@ -46,3 +46,6 @@ version = "0.15.0" [dependencies.regex] version = "1.5" + +[dependencies.comfy-table] +version = "5.0" diff --git a/README.md b/README.md index d15de39..54a00a1 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,32 @@ $ grm find ~/your/project/root > config.toml This will detect all repositories and remotes and write them to `config.toml`. +### Show the state of your projects + +```bash +$ grm status --config example.config.toml ++------------------+------------+----------------------------------+--------+---------+ +| Repo | Status | Branches | HEAD | Remotes | ++=====================================================================================+ +| git-repo-manager | | branch: master ✔ | master | github | +| | | | | origin | +|------------------+------------+----------------------------------+--------+---------| +| dotfiles | No changes | branch: master ✔ | master | origin | ++------------------+------------+----------------------------------+--------+---------+ +``` + +You can also use `status` without `--config` to check the current directory: + +``` +$ cd ./dotfiles +$ grm status ++----------+------------+----------------------------------+--------+---------+ +| Repo | Status | Branches | HEAD | Remotes | ++=============================================================================+ +| dotfiles | No changes | branch: master ✔ | master | origin | ++----------+------------+----------------------------------+--------+---------+ +``` + # Why? I have a **lot** of repositories on my machines. My own stuff, forks, quick diff --git a/src/cmd.rs b/src/cmd.rs index 5c3c4dc..0c70efd 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -26,6 +26,8 @@ pub enum SubCommand { Sync(Sync), #[clap(about = "Generate a repository configuration from an existing file tree")] Find(Find), + #[clap(about = "Show status of configured repositories")] + Status(OptionalConfig), } #[derive(Parser)] @@ -40,6 +42,17 @@ pub struct Sync { pub config: String, } +#[derive(Parser)] +#[clap()] +pub struct OptionalConfig { + #[clap( + short, + long, + about = "Path to the configuration file" + )] + pub config: Option, +} + #[derive(Parser)] pub struct Find { #[clap(about = "The path to search through")] diff --git a/src/lib.rs b/src/lib.rs index db4b522..87886b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,12 @@ mod repo; use config::{Config, Tree}; use output::*; -use repo::{clone_repo, detect_remote_type, init_repo, open_repo, Remote, Repo}; +use comfy_table::{Table, Cell}; + +use repo::{ + clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, Remote, Repo, + RepoErrorKind, RemoteTrackingStatus +}; fn path_as_string(path: &Path) -> String { path.to_path_buf().into_os_string().into_string().unwrap() @@ -358,6 +363,137 @@ fn find_in_tree(path: &Path) -> Option { }) } +fn add_table_header(table: &mut Table) { + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Repo"), + Cell::new("Status"), + Cell::new("Branches"), + Cell::new("HEAD"), + Cell::new("Remotes"), + ]); +} + +fn add_repo_status(table: &mut Table, repo_name: &String, repo_handle: &git2::Repository) { + let repo_status = get_repo_status(repo_handle); + + table.add_row(vec![ + repo_name, + &match repo_status.changes { + Some(changes) => { + let mut out = Vec::new(); + if changes.files_new > 0 { + out.push(format!("New: {}\n", changes.files_new)) + } + if changes.files_modified > 0 { + out.push(format!("Modified: {}\n", changes.files_modified)) + } + if changes.files_deleted > 0 { + out.push(format!("Deleted: {}\n", changes.files_deleted)) + } + out.into_iter().collect::().trim().to_string() + }, + None => String::from("No changes"), + }, + &repo_status.branches.iter().map(|(branch_name, remote_branch)| { + format!("branch: {}{}\n", + &branch_name, + &match remote_branch { + None => String::from(" "), + Some((remote_branch_name, remote_tracking_status)) => { + format!(" <{}>{}", + remote_branch_name, + &match remote_tracking_status { + RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"), + RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d), + RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d), + RemoteTrackingStatus::Diverged(d1, d2) => format!(" [-{}/+{}]", &d1,&d2), + } + ) + } + } + ) + }).collect::().trim().to_string(), + &match repo_status.head { + Some(head) => head, + None => String::from("Empty"), + }, + &repo_status.remotes.iter().map(|r| format!("{}\n", r)).collect::().trim().to_string(), + ]); +} + +fn show_single_repo_status(path: &PathBuf) { + let mut table = Table::new(); + add_table_header(&mut table); + + let repo_handle = open_repo(path); + + if let Err(error) = repo_handle { + if error.kind == RepoErrorKind::NotFound { + print_error(&"Directory is not a git directory".to_string()); + } else { + print_error(&format!("Opening repository failed: {}", error)); + } + process::exit(1); + }; + + let repo_name = match path.file_name() { + None => { + print_warning("Cannot detect repo name. Are you working in /?"); + String::from("unknown") + }, + Some(file_name) => match file_name.to_str() { + None => { + print_warning("Name of current directory is not valid UTF-8"); + String::from("invalid") + }, + Some(name) => name.to_string(), + } + }; + + add_repo_status(&mut table, &repo_name, &repo_handle.unwrap()); + + println!("{}", table); +} + +fn show_status(config: Config) { + for tree in config.trees { + let repos = tree.repos.unwrap_or_default(); + + let root_path = expand_path(Path::new(&tree.root)); + + let mut table = Table::new(); + add_table_header(&mut table); + + for repo in &repos { + let repo_path = root_path.join(&repo.name); + + if !repo_path.exists() { + print_repo_error(&repo.name, &"Repository does not exist. Run sync?".to_string()); + continue; + } + + let repo_handle = open_repo(&repo_path); + + if let Err(error) = repo_handle { + if error.kind == RepoErrorKind::NotFound { + print_repo_error(&repo.name, &"No git repository found. Run sync?".to_string()); + } else { + print_repo_error(&repo.name, &format!("Opening repository failed: {}", error)); + } + continue; + }; + + let repo_handle = repo_handle.unwrap(); + + add_repo_status(&mut table, &repo.name, &repo_handle); + } + println!("{}", table); + } +} + pub fn run() { let opts = cmd::parse(); @@ -372,6 +508,31 @@ pub fn run() { }; sync_trees(config); } + cmd::SubCommand::Status(args) => { + match &args.config { + Some(config_path) => { + let config = match config::read_config(config_path) { + Ok(c) => c, + Err(e) => { + print_error(&e); + process::exit(1); + } + }; + show_status(config); + }, + None => { + let dir = match std::env::current_dir(){ + Ok(d) => d, + Err(e) => { + print_error(&format!("Could not open current directory: {}", e)); + process::exit(1); + }, + }; + + show_single_repo_status(&dir); + } + } + } cmd::SubCommand::Find(find) => { let path = Path::new(&find.path); if !path.exists() { diff --git a/src/repo.rs b/src/repo.rs index df9f2bc..a779b5c 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -53,6 +53,44 @@ pub struct Repo { pub remotes: Option>, } +pub struct RepoChanges { + pub files_new: usize, + pub files_modified: usize, + pub files_deleted: usize, +} + +pub enum SubmoduleStatus { + Clean, + Uninitialized, + Changed, + OutOfDate, +} + +pub enum RemoteTrackingStatus { + UpToDate, + Ahead(usize), + Behind(usize), + Diverged(usize, usize), +} + +pub struct RepoStatus { + pub operation: Option, + + pub empty: bool, + + pub remotes: Vec, + + pub head: Option, + + pub changes: Option, + + pub worktrees: usize, + + pub submodules: Vec<(String, SubmoduleStatus)>, + + pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>, +} + #[cfg(test)] mod tests { use super::*; @@ -178,3 +216,130 @@ pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box RepoStatus { + let operation = match repo.state() { + git2::RepositoryState::Clean => None, + state => Some(state), + }; + + let empty = repo.is_empty().unwrap(); + + let remotes = repo + .remotes() + .unwrap() + .iter() + .map(|repo_name| repo_name.unwrap().to_string()) + .collect::>(); + + let head = match empty { + true => None, + false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), + }; + + let statuses = repo.statuses(None).unwrap(); + + let changes = match statuses.is_empty() { + true => None, + false => { + let mut files_new = 0; + let mut files_modified = 0; + let mut files_deleted = 0; + for status in statuses.iter() { + let status_bits = status.status(); + if status_bits.intersects( + git2::Status::INDEX_MODIFIED + | git2::Status::INDEX_RENAMED + | git2::Status::INDEX_TYPECHANGE + | git2::Status::WT_MODIFIED + | git2::Status::WT_RENAMED + | git2::Status::WT_TYPECHANGE, + ) { + files_modified += 1; + } else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) { + files_new += 1; + } else if status_bits + .intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) + { + files_deleted += 1; + } + } + Some(RepoChanges { + files_new, + files_modified, + files_deleted, + }) + } + }; + + let worktrees = repo.worktrees().unwrap().len(); + + let mut submodules = Vec::new(); + for submodule in repo.submodules().unwrap() { + let submodule_name = submodule.name().unwrap().to_string(); + + let submodule_status; + let status = repo + .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) + .unwrap(); + + if status.intersects( + git2::SubmoduleStatus::WD_INDEX_MODIFIED + | git2::SubmoduleStatus::WD_WD_MODIFIED + | git2::SubmoduleStatus::WD_UNTRACKED, + ) { + submodule_status = SubmoduleStatus::Changed; + } else if status.is_wd_uninitialized() { + submodule_status = SubmoduleStatus::Uninitialized; + } else if status.is_wd_modified() { + submodule_status = SubmoduleStatus::OutOfDate; + } else { + submodule_status = SubmoduleStatus::Clean; + } + + submodules.push((submodule_name, submodule_status)); + } + + let mut branches = Vec::new(); + for (local_branch, _) in repo + .branches(Some(git2::BranchType::Local)) + .unwrap() + .map(|branch_name| branch_name.unwrap()) + { + let branch_name = local_branch.name().unwrap().unwrap().to_string(); + let remote_branch = match local_branch.upstream() { + Ok(remote_branch) => { + let remote_branch_name = remote_branch.name().unwrap().unwrap().to_string(); + + let (ahead, behind) = repo + .graph_ahead_behind( + local_branch.get().peel_to_commit().unwrap().id(), + remote_branch.get().peel_to_commit().unwrap().id(), + ) + .unwrap(); + + let remote_tracking_status = match (ahead, behind) { + (0, 0) => RemoteTrackingStatus::UpToDate, + (0, d) => RemoteTrackingStatus::Behind(d), + (d, 0) => RemoteTrackingStatus::Ahead(d), + (d1, d2) => RemoteTrackingStatus::Diverged(d1, d2), + }; + Some((remote_branch_name, remote_tracking_status)) + } + // Err => no remote branch + Err(_) => None, + }; + branches.push((branch_name, remote_branch)); + } + + RepoStatus { + operation, + empty, + remotes, + head, + changes, + worktrees, + submodules, + branches, + } +}