diff --git a/docs/config/README.md b/docs/config/README.md index b7d62422..2f4c3f37 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -194,6 +194,7 @@ $vcsh\ $git_branch\ $git_commit\ $git_state\ +$git_metrics\ $git_status\ $hg_branch\ $docker_context\ @@ -1232,6 +1233,48 @@ format = '[\($state( $progress_current of $progress_total)\)]($style) ' cherry_pick = "[🍒 PICKING](bold red)" ``` +## Git Metrics + +The `git_metrics` module will show the number of added and deleted lines in +the current git repository. + +::: tip + +This module is disabled by default. +To enable it, set `disabled` to `false` in your configuration file. + +::: + +### Options + +| Option | Default | Description | +| ------------------------- | -------------------------------------------------------------------- | ---------------------------------------| +| `added_style` | `"bold green"` | The style for the added count. | +| `deleted_style` | `"bold red"` | The style for the deleted count. | +| `format` | `'[+$added]($added_style) [-$deleted]($deleted_style) '` | The format for the module. | +| `disabled` | `true` | Disables the `git_metrics` module. | + +### Variables + +| Variable | Example | Description | +| ---------------- | ---------- | ----------------------------------- | +| added | `1` | The current number of added lines | +| deleted | `2` | The current number of deleted lines | +| added_style\* | | Mirrors the value of option `added_style` | +| deleted_style\* | | Mirrors the value of option `deleted_style` | + +\*: This variable can only be used as a part of a style string + +### Example + +```toml +# ~/.config/starship.toml + +[git_metrics] +added_style = "bold blue" +format = '[+$added]($added_style)/[-$deleted]($deleted_style) ' +``` + ## Git Status The `git_status` module shows symbols representing the state of the repo in your diff --git a/src/configs/git_metrics.rs b/src/configs/git_metrics.rs new file mode 100644 index 00000000..f3e52e20 --- /dev/null +++ b/src/configs/git_metrics.rs @@ -0,0 +1,23 @@ +use crate::config::ModuleConfig; + +use serde::Serialize; +use starship_module_config_derive::ModuleConfig; + +#[derive(Clone, ModuleConfig, Serialize)] +pub struct GitMetricsConfig<'a> { + pub added_style: &'a str, + pub deleted_style: &'a str, + pub format: &'a str, + pub disabled: bool, +} + +impl<'a> Default for GitMetricsConfig<'a> { + fn default() -> Self { + GitMetricsConfig { + added_style: "bold green", + deleted_style: "bold red", + format: "[+$added]($added_style) [-$deleted]($deleted_style) ", + disabled: true, + } + } +} diff --git a/src/configs/mod.rs b/src/configs/mod.rs index ec5434fc..658c4553 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -23,6 +23,7 @@ pub mod erlang; pub mod gcloud; pub mod git_branch; pub mod git_commit; +pub mod git_metrics; pub mod git_state; pub mod git_status; pub mod go; @@ -95,6 +96,7 @@ pub struct FullConfig<'a> { gcloud: gcloud::GcloudConfig<'a>, git_branch: git_branch::GitBranchConfig<'a>, git_commit: git_commit::GitCommitConfig<'a>, + git_metrics: git_metrics::GitMetricsConfig<'a>, git_state: git_state::GitStateConfig<'a>, git_status: git_status::GitStatusConfig<'a>, golang: go::GoConfig<'a>, @@ -164,6 +166,7 @@ impl<'a> Default for FullConfig<'a> { gcloud: Default::default(), git_branch: Default::default(), git_commit: Default::default(), + git_metrics: Default::default(), git_state: Default::default(), git_status: Default::default(), golang: Default::default(), diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index bf656ebe..7d5bfcda 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -26,6 +26,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "git_branch", "git_commit", "git_state", + "git_metrics", "git_status", "hg_branch", "docker_context", diff --git a/src/module.rs b/src/module.rs index 7beb1305..eb8874c4 100644 --- a/src/module.rs +++ b/src/module.rs @@ -28,6 +28,7 @@ pub const ALL_MODULES: &[&str] = &[ "gcloud", "git_branch", "git_commit", + "git_metrics", "git_state", "git_status", "golang", diff --git a/src/modules/git_metrics.rs b/src/modules/git_metrics.rs new file mode 100644 index 00000000..833faec3 --- /dev/null +++ b/src/modules/git_metrics.rs @@ -0,0 +1,275 @@ +use regex::Regex; + +use crate::{ + config::RootModuleConfig, configs::git_metrics::GitMetricsConfig, formatter::StringFormatter, + module::Module, +}; + +use super::Context; + +/// Creates a module with the current added/deleted lines in the git repository at the +/// current directory +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("git_metrics"); + let config: GitMetricsConfig = GitMetricsConfig::try_load(module.config); + + // As we default to disabled=true, we have to check here after loading our config module, + // before it was only checking against whatever is in the config starship.toml + if config.disabled { + return None; + }; + + let repo = context.get_repo().ok()?; + let repo_root = repo.root.as_ref()?; + + let diff = context + .exec_cmd( + "git", + &[ + "-C", + &repo_root.to_string_lossy(), + "--no-optional-locks", + "diff", + "--shortstat", + ], + )? + .stdout; + + let stats = GitDiff::parse(&diff); + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_style(|variable| match variable { + "added_style" => Some(Ok(config.added_style)), + "deleted_style" => Some(Ok(config.deleted_style)), + _ => None, + }) + .map(|variable| match variable { + "added" => Some(Ok(stats.added)), + "deleted" => Some(Ok(stats.deleted)), + _ => None, + }) + .parse(None) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `git_metrics`:\n{}", error); + return None; + } + }); + + Some(module) +} + +/// Represents the parsed output from a git diff. +struct GitDiff<'a> { + added: &'a str, + deleted: &'a str, +} + +impl<'a> GitDiff<'a> { + /// Returns the first capture group given a regular expression and a string. + /// If it fails to get the capture group it will return "0". + fn get_matched_str(diff: &'a str, re: &Regex) -> &'a str { + match re.captures(diff) { + Some(caps) => caps.get(1).unwrap().as_str(), + _ => "0", + } + } + + /// Parses the result of 'git diff --shortstat' as a `GitDiff` struct. + pub fn parse(diff: &'a str) -> Self { + let added_re = Regex::new(r"(\d+) \w+\(\+\)").unwrap(); + let deleted_re = Regex::new(r"(\d+) \w+\(\-\)").unwrap(); + + Self { + added: GitDiff::get_matched_str(diff, &added_re), + deleted: GitDiff::get_matched_str(diff, &deleted_re), + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + use std::fs::OpenOptions; + use std::io::{self, Error, ErrorKind, Write}; + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + + use ansi_term::Color; + + use crate::test::ModuleRenderer; + + #[test] + fn shows_nothing_on_empty_dir() -> io::Result<()> { + let repo_dir = tempfile::tempdir()?; + let path = repo_dir.path(); + + let actual = render_metrics(path); + + let expected = None; + + assert_eq!(expected, actual); + repo_dir.close() + } + + #[test] + fn shows_added_lines() -> io::Result<()> { + let repo_dir = create_repo_with_commit()?; + let path = repo_dir.path(); + + let the_file = path.join("the_file"); + let mut the_file = OpenOptions::new().append(true).open(&the_file)?; + writeln!(the_file, "Added line")?; + the_file.sync_all()?; + + let actual = render_metrics(path); + + let expected = Some(format!( + "{} {} ", + Color::Green.bold().paint("+1"), + Color::Red.bold().paint("-0") + )); + + assert_eq!(expected, actual); + repo_dir.close() + } + + #[test] + fn shows_deleted_lines() -> io::Result<()> { + let repo_dir = create_repo_with_commit()?; + let path = repo_dir.path(); + + let file_path = path.join("the_file"); + write_file(file_path, "First Line\nSecond Line")?; + + let actual = render_metrics(path); + + let expected = Some(format!( + "{} {} ", + Color::Green.bold().paint("+0"), + Color::Red.bold().paint("-1") + )); + + assert_eq!(expected, actual); + repo_dir.close() + } + + #[test] + fn shows_all_changes() -> io::Result<()> { + let repo_dir = create_repo_with_commit()?; + let path = repo_dir.path(); + + let file_path = path.join("the_file"); + write_file(file_path, "\nSecond Line\n\nModified\nAdded")?; + + let actual = render_metrics(path); + + let expected = Some(format!( + "{} {} ", + Color::Green.bold().paint("+4"), + Color::Red.bold().paint("-2") + )); + + assert_eq!(expected, actual); + repo_dir.close() + } + + fn render_metrics(path: &Path) -> Option { + ModuleRenderer::new("git_metrics") + .config(toml::toml! { + [git_metrics] + disabled = false + }) + .path(path) + .collect() + } + + fn run_git_cmd(args: A, dir: Option<&Path>, should_succeed: bool) -> io::Result<()> + where + A: IntoIterator, + S: AsRef, + { + let mut command = Command::new("git"); + command + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + + if let Some(dir) = dir { + command.current_dir(dir); + } + + let status = command.status()?; + + if should_succeed && !status.success() { + Err(Error::from(ErrorKind::Other)) + } else { + Ok(()) + } + } + + fn write_file(file: PathBuf, text: &str) -> io::Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&file)?; + writeln!(file, "{}", text)?; + file.sync_all() + } + + fn create_repo_with_commit() -> io::Result { + let repo_dir = tempfile::tempdir()?; + let path = repo_dir.path(); + let file = repo_dir.path().join("the_file"); + + // Initialize a new git repo + run_git_cmd( + &[ + "init", + "--quiet", + path.to_str().expect("Path was not UTF-8"), + ], + None, + true, + )?; + + // Set local author info + run_git_cmd( + &["config", "--local", "user.email", "starship@example.com"], + Some(path), + true, + )?; + run_git_cmd( + &["config", "--local", "user.name", "starship"], + Some(path), + true, + )?; + + // Ensure on the expected branch. + // If build environment has `init.defaultBranch` global set + // it will default to an unknown branch, so need to make & change branch + run_git_cmd( + &["checkout", "-b", "master"], + Some(path), + // command expected to fail if already on the expected branch + false, + )?; + + // Write a file on master and commit it + write_file(file, "First Line\nSecond Line\nThird Line")?; + run_git_cmd(&["add", "the_file"], Some(path), true)?; + run_git_cmd( + &["commit", "--message", "Commit A", "--no-gpg-sign"], + Some(path), + true, + )?; + + Ok(repo_dir) + } +} diff --git a/src/modules/git_state.rs b/src/modules/git_state.rs index cfd25903..f2d1e1c9 100644 --- a/src/modules/git_state.rs +++ b/src/modules/git_state.rs @@ -337,7 +337,7 @@ mod tests { // Ensure on the expected branch. // If build environment has `init.defaultBranch` global set - // it will default to an unknown branch, so neeed to make & change branch + // it will default to an unknown branch, so need to make & change branch run_git_cmd( &["checkout", "-b", "master"], Some(path), diff --git a/src/modules/mod.rs b/src/modules/mod.rs index f64b016b..49c746e7 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -18,6 +18,7 @@ mod erlang; mod gcloud; mod git_branch; mod git_commit; +mod git_metrics; mod git_state; mod git_status; mod golang; @@ -98,6 +99,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "gcloud" => gcloud::module(context), "git_branch" => git_branch::module(context), "git_commit" => git_commit::module(context), + "git_metrics" => git_metrics::module(context), "git_state" => git_state::module(context), "git_status" => git_status::module(context), "golang" => golang::module(context), @@ -180,6 +182,7 @@ pub fn description(module: &str) -> &'static str { "gcloud" => "The current GCP client configuration", "git_branch" => "The active branch of the repo in your current directory", "git_commit" => "The active commit (and tag if any) of the repo in your current directory", + "git_metrics" => "The currently added/deleted lines in your repo", "git_state" => "The current git operation, and it's progress", "git_status" => "Symbol representing the state of the repo", "golang" => "The currently installed version of Golang",