Add status command

This commit is contained in:
2021-11-19 22:12:31 +01:00
parent c0172f9af5
commit 5f0ec0fec8
6 changed files with 542 additions and 2 deletions

172
Cargo.lock generated
View File

@@ -80,6 +80,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "console" name = "console"
version = "0.15.0" version = "0.15.0"
@@ -95,6 +107,31 @@ dependencies = [
"winapi", "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]] [[package]]
name = "dirs-next" name = "dirs-next"
version = "2.0.0" version = "2.0.0"
@@ -148,6 +185,7 @@ name = "git-repo-manager"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"comfy-table",
"console", "console",
"git2", "git2",
"regex", "regex",
@@ -216,6 +254,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "instant"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.24" version = "0.1.24"
@@ -277,6 +324,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "lock_api"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109"
dependencies = [
"scopeguard",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.14" version = "0.4.14"
@@ -298,6 +354,37 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.8.0" version = "1.8.0"
@@ -332,6 +419,31 @@ dependencies = [
"memchr", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.1.0" version = "2.1.0"
@@ -422,6 +534,12 @@ version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.130" version = "1.0.130"
@@ -451,12 +569,66 @@ dependencies = [
"dirs-next", "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]] [[package]]
name = "strsim" name = "strsim"
version = "0.10.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 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]] [[package]]
name = "syn" name = "syn"
version = "1.0.81" version = "1.0.81"

View File

@@ -3,7 +3,7 @@ name = "git-repo-manager"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = [ authors = [
"Hannes Körber <hannes@hkoerber.de>", "Hannes Körber <hannes@hkoerber.de>",
] ]
description = """ description = """
Manage multiple git repositories. Manage multiple git repositories.
@@ -46,3 +46,6 @@ version = "0.15.0"
[dependencies.regex] [dependencies.regex]
version = "1.5" version = "1.5"
[dependencies.comfy-table]
version = "5.0"

View File

@@ -53,6 +53,32 @@ $ grm find ~/your/project/root > config.toml
This will detect all repositories and remotes and write them to `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 <origin/master> ✔ | master | github |
| | | | | origin |
|------------------+------------+----------------------------------+--------+---------|
| dotfiles | No changes | branch: master <origin/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 <origin/master> ✔ | master | origin |
+----------+------------+----------------------------------+--------+---------+
```
# Why? # Why?
I have a **lot** of repositories on my machines. My own stuff, forks, quick I have a **lot** of repositories on my machines. My own stuff, forks, quick

View File

@@ -26,6 +26,8 @@ pub enum SubCommand {
Sync(Sync), Sync(Sync),
#[clap(about = "Generate a repository configuration from an existing file tree")] #[clap(about = "Generate a repository configuration from an existing file tree")]
Find(Find), Find(Find),
#[clap(about = "Show status of configured repositories")]
Status(OptionalConfig),
} }
#[derive(Parser)] #[derive(Parser)]
@@ -40,6 +42,17 @@ pub struct Sync {
pub config: String, pub config: String,
} }
#[derive(Parser)]
#[clap()]
pub struct OptionalConfig {
#[clap(
short,
long,
about = "Path to the configuration file"
)]
pub config: Option<String>,
}
#[derive(Parser)] #[derive(Parser)]
pub struct Find { pub struct Find {
#[clap(about = "The path to search through")] #[clap(about = "The path to search through")]

View File

@@ -10,7 +10,12 @@ mod repo;
use config::{Config, Tree}; use config::{Config, Tree};
use output::*; 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 { fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap() path.to_path_buf().into_os_string().into_string().unwrap()
@@ -358,6 +363,137 @@ fn find_in_tree(path: &Path) -> Option<Tree> {
}) })
} }
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::<String>().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(" <!local>"),
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::<String>().trim().to_string(),
&match repo_status.head {
Some(head) => head,
None => String::from("Empty"),
},
&repo_status.remotes.iter().map(|r| format!("{}\n", r)).collect::<String>().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() { pub fn run() {
let opts = cmd::parse(); let opts = cmd::parse();
@@ -372,6 +508,31 @@ pub fn run() {
}; };
sync_trees(config); 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) => { cmd::SubCommand::Find(find) => {
let path = Path::new(&find.path); let path = Path::new(&find.path);
if !path.exists() { if !path.exists() {

View File

@@ -53,6 +53,44 @@ pub struct Repo {
pub remotes: Option<Vec<Remote>>, pub remotes: Option<Vec<Remote>>,
} }
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<git2::RepositoryState>,
pub empty: bool,
pub remotes: Vec<String>,
pub head: Option<String>,
pub changes: Option<RepoChanges>,
pub worktrees: usize,
pub submodules: Vec<(String, SubmoduleStatus)>,
pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -178,3 +216,130 @@ pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error
} }
} }
} }
pub fn get_repo_status(repo: &git2::Repository) -> 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::<Vec<String>>();
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,
}
}