diff --git a/docs/config/README.md b/docs/config/README.md index 944545246..702d21a51 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -72,6 +72,7 @@ default_prompt_order = [ "hostname", "directory", "git_branch", + "git_state", "git_status", "package", "nodejs", @@ -79,6 +80,7 @@ default_prompt_order = [ "rust", "python", "golang", + "nix_shell", "cmd_duration", "line_break", "jobs", @@ -230,6 +232,37 @@ truncation_length = "4" truncation_symbol = "" ``` +## Git State + +The `git_state` module will show in directories which are part of a git +repository, and where there is an operation in progress, such as: _REBASING_, +_BISECTING_, etc. If there is progress information (e.g., REBASING 3/10), +that information will be shown too. + +### Options + +| Variable | Default | Description | +| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `rebase` | `"REBASING"` | The text displayed when a `rebase` is in progress. | +| `merge` | `"MERGING"` | The text displayed when a `merge` is in progress. | +| `revert` | `"REVERTING"` | The text displayed when a `revert` is in progress. | +| `cherry_pick` | `"CHERRY-PICKING"` | The text displayed when a `cherry-pick` is in progress. | +| `bisect` | `"BISECTING"` | The text displayed when a `bisect` is in progress. | +| `am` | `"AM"` | The text displayed when an `apply-mailbox` (`git am`) is in progress. | +| `am_or_rebase` | `"AM/REBASE"` | The text displayed when an ambiguous `apply-mailbox` or `rebase` is in progress. | +| `progress_divider` | `"/"` | The symbol or text which will separate the current and total progress amounts. (e.g., `" of "`, for `"3 of 10"`) | +| `disabled` | `false` | Disables the `git_state` module. | + +### Example + +```toml +# ~/.config/starship.toml + +[git_state] +progress_divider = " of " +cherry_pick = "🍒 PICKING" +``` + ## Git Status The `git_status` module shows symbols representing the state of the repo in your @@ -276,12 +309,12 @@ The `hostname` module shows the system hostname. ### Options -| Variable | Default | Description | -| ------------ | ------- | ------------------------------------------------------- | -| `ssh_only` | `true` | Only show hostname when connected to an SSH session. | -| `prefix` | `""` | Prefix to display immediately before the hostname. | -| `suffix` | `""` | Suffix to display immediately after the hostname. | -| `disabled` | `false` | Disables the `hostname` module. | +| Variable | Default | Description | +| ---------- | ------- | ---------------------------------------------------- | +| `ssh_only` | `true` | Only show hostname when connected to an SSH session. | +| `prefix` | `""` | Prefix to display immediately before the hostname. | +| `suffix` | `""` | Suffix to display immediately after the hostname. | +| `disabled` | `false` | Disables the `hostname` module. | ### Example @@ -378,10 +411,10 @@ The module will be shown if any of the following conditions are met: ### Options -| Variable | Default | Description | -| ---------- | ------- | --------------------------- | -| `symbol` | `"💎 "` | The symbol used before displaying the version of Ruby. | -| `disabled` | `false` | Disables the `ruby` module. | +| Variable | Default | Description | +| ---------- | ------- | ------------------------------------------------------ | +| `symbol` | `"💎 "` | The symbol used before displaying the version of Ruby. | +| `disabled` | `false` | Disables the `ruby` module. | ### Example diff --git a/src/module.rs b/src/module.rs index 28c309c6d..ab1783e4f 100644 --- a/src/module.rs +++ b/src/module.rs @@ -12,6 +12,7 @@ pub const ALL_MODULES: &[&str] = &[ "cmd_duration", "directory", "git_branch", + "git_state", "git_status", "golang", "hostname", diff --git a/src/modules/git_state.rs b/src/modules/git_state.rs new file mode 100644 index 000000000..55f3d4c11 --- /dev/null +++ b/src/modules/git_state.rs @@ -0,0 +1,166 @@ +use ansi_term::Color; +use git2::{Repository, RepositoryState}; +use std::path::Path; + +use super::{Context, Module}; + +/// Creates a module with the state of the git repository at the current directory +/// +/// During a git operation it will show: REBASING, BISECTING, MERGING, etc. +/// If the progress information is available (e.g. rebasing 3/10), it will show that too. +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("git_state")?; + + let repo_root = context.repo_root.as_ref()?; + let mut repository = Repository::open(repo_root).ok()?; + let state_description = get_state_description(&mut repository); + + if let StateDescription::Clean = state_description { + return None; + } + + module.get_prefix().set_value("("); + module.get_suffix().set_value(") "); + module.set_style(Color::Yellow.bold()); + + let label = match state_description { + StateDescription::Label(label) => label, + StateDescription::LabelAndProgress(label, _) => label, + // Should only be possible if you've added a new variant to StateDescription + _ => panic!("Expected to have a label at this point in the control flow."), + }; + + module.new_segment(label.segment_name, label.message_default); + + if let StateDescription::LabelAndProgress(_, progress) = state_description { + module.new_segment("progress_current", &format!(" {}", progress.current)); + module.new_segment("progress_divider", "/"); + module.new_segment("progress_total", &format!("{}", progress.total)); + } + + Some(module) +} + +static MERGE_LABEL: StateLabel = StateLabel { + segment_name: "merge", + message_default: "MERGING", +}; + +static REVERT_LABEL: StateLabel = StateLabel { + segment_name: "revert", + message_default: "REVERTING", +}; + +static CHERRY_LABEL: StateLabel = StateLabel { + segment_name: "cherry_pick", + message_default: "CHERRY-PICKING", +}; + +static BISECT_LABEL: StateLabel = StateLabel { + segment_name: "bisect", + message_default: "BISECTING", +}; + +static AM_LABEL: StateLabel = StateLabel { + segment_name: "am", + message_default: "AM", +}; + +static REBASE_LABEL: StateLabel = StateLabel { + segment_name: "rebase", + message_default: "REBASING", +}; + +static AM_OR_REBASE_LABEL: StateLabel = StateLabel { + segment_name: "am_or_rebase", + message_default: "AM/REBASE", +}; + +fn get_state_description(repository: &mut Repository) -> StateDescription { + match repository.state() { + RepositoryState::Clean => StateDescription::Clean, + RepositoryState::Merge => StateDescription::Label(&MERGE_LABEL), + RepositoryState::Revert => StateDescription::Label(&REVERT_LABEL), + RepositoryState::RevertSequence => StateDescription::Label(&REVERT_LABEL), + RepositoryState::CherryPick => StateDescription::Label(&CHERRY_LABEL), + RepositoryState::CherryPickSequence => StateDescription::Label(&CHERRY_LABEL), + RepositoryState::Bisect => StateDescription::Label(&BISECT_LABEL), + RepositoryState::ApplyMailbox => StateDescription::Label(&AM_LABEL), + RepositoryState::ApplyMailboxOrRebase => StateDescription::Label(&AM_OR_REBASE_LABEL), + RepositoryState::Rebase => describe_rebase(repository), + RepositoryState::RebaseInteractive => describe_rebase(repository), + RepositoryState::RebaseMerge => describe_rebase(repository), + } +} + +fn describe_rebase(repository: &mut Repository) -> StateDescription { + /* + * Sadly, libgit2 seems to have some issues with reading the state of + * interactive rebases. So, instead, we'll poke a few of the .git files + * ourselves. This might be worth re-visiting this in the future... + * + * The following is based heavily on: https://github.com/magicmonty/bash-git-prompt + */ + + let just_label = StateDescription::Label(&REBASE_LABEL); + + let dot_git = repository + .workdir() + .and_then(|d| Some(d.join(Path::new(".git")))); + + let dot_git = match dot_git { + None => { + // We didn't find the .git directory. + // Something very odd is going on. We'll just back away slowly. + return just_label; + } + Some(path) => path, + }; + + let has_path = |relative_path: &str| { + let path = dot_git.join(Path::new(relative_path)); + path.exists() + }; + + let file_to_usize = |relative_path: &str| { + let path = dot_git.join(Path::new(relative_path)); + let contents = crate::utils::read_file(path).ok()?; + let quantity = contents.trim().parse::().ok()?; + Some(quantity) + }; + + let paths_to_progress = |current_path: &str, total_path: &str| { + let current = file_to_usize(current_path)?; + let total = file_to_usize(total_path)?; + Some(StateProgress { current, total }) + }; + + let progress = if has_path("rebase-merge") { + paths_to_progress("rebase-merge/msgnum", "rebase-merge/end") + } else if has_path("rebase-apply") { + paths_to_progress("rebase-apply/next", "rebase-apply/last") + } else { + None + }; + + match progress { + None => just_label, + Some(progress) => StateDescription::LabelAndProgress(&REBASE_LABEL, progress), + } +} + +enum StateDescription { + Clean, + Label(&'static StateLabel), + LabelAndProgress(&'static StateLabel, StateProgress), +} + +struct StateLabel { + segment_name: &'static str, + message_default: &'static str, +} + +struct StateProgress { + current: usize, + total: usize, +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 23d82601f..83a7d1c40 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -3,6 +3,7 @@ mod character; mod cmd_duration; mod directory; mod git_branch; +mod git_state; mod git_status; mod golang; mod hostname; @@ -34,6 +35,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { "line_break" => line_break::module(context), "package" => package::module(context), "git_branch" => git_branch::module(context), + "git_state" => git_state::module(context), "git_status" => git_status::module(context), "username" => username::module(context), #[cfg(feature = "battery")] diff --git a/src/print.rs b/src/print.rs index 74997b26d..1ea1648ff 100644 --- a/src/print.rs +++ b/src/print.rs @@ -16,6 +16,7 @@ const DEFAULT_PROMPT_ORDER: &[&str] = &[ "hostname", "directory", "git_branch", + "git_state", "git_status", "package", "nodejs", diff --git a/src/utils.rs b/src/utils.rs index 4a0337ae6..c873f45aa 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,8 +1,9 @@ use std::fs::File; use std::io::{Read, Result}; +use std::path::Path; /// Return the string contents of a file -pub fn read_file(file_name: &str) -> Result { +pub fn read_file>(file_name: P) -> Result { let mut file = File::open(file_name)?; let mut data = String::new(); diff --git a/tests/testsuite/common.rs b/tests/testsuite/common.rs index 0f611a209..6698a1d94 100644 --- a/tests/testsuite/common.rs +++ b/tests/testsuite/common.rs @@ -2,7 +2,7 @@ use lazy_static::lazy_static; use std::io::prelude::*; use std::path::{Path, PathBuf}; use std::process::Command; -use std::{env, io, process}; +use std::{env, fs, io, process}; lazy_static! { static ref MANIFEST_DIR: &'static Path = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -25,7 +25,8 @@ pub fn render_prompt() -> process::Command { /// Render a specific starship module by name pub fn render_module(module_name: &str) -> process::Command { - let mut command = process::Command::new("./target/debug/starship"); + let binary = fs::canonicalize("./target/debug/starship").unwrap(); + let mut command = process::Command::new(binary); command .arg("module") diff --git a/tests/testsuite/git_state.rs b/tests/testsuite/git_state.rs new file mode 100644 index 000000000..2de7d5029 --- /dev/null +++ b/tests/testsuite/git_state.rs @@ -0,0 +1,177 @@ +use super::common; +use std::ffi::OsStr; +use std::fs::OpenOptions; +use std::io::{self, Error, ErrorKind, Write}; +use std::process::{Command, Stdio}; + +#[test] +#[ignore] +fn shows_rebasing() -> io::Result<()> { + let repo_dir = create_repo_with_conflict()?; + let path = path_str(&repo_dir)?; + + run_git_cmd(&["rebase", "other-branch"], Some(path), false)?; + + let output = common::render_module("git_state") + .current_dir(path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("REBASING 1/1")); + + Ok(()) +} + +#[test] +#[ignore] +fn shows_merging() -> io::Result<()> { + let repo_dir = create_repo_with_conflict()?; + let path = path_str(&repo_dir)?; + + run_git_cmd(&["merge", "other-branch"], Some(path), false)?; + + let output = common::render_module("git_state") + .current_dir(path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("MERGING")); + + Ok(()) +} + +#[test] +#[ignore] +fn shows_cherry_picking() -> io::Result<()> { + let repo_dir = create_repo_with_conflict()?; + let path = path_str(&repo_dir)?; + + run_git_cmd(&["cherry-pick", "other-branch"], Some(path), false)?; + + let output = common::render_module("git_state") + .current_dir(path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("CHERRY-PICKING")); + + Ok(()) +} + +#[test] +#[ignore] +fn shows_bisecting() -> io::Result<()> { + let repo_dir = create_repo_with_conflict()?; + let path = path_str(&repo_dir)?; + + run_git_cmd(&["bisect", "start"], Some(path), false)?; + + let output = common::render_module("git_state") + .current_dir(path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("BISECTING")); + + Ok(()) +} + +#[test] +#[ignore] +fn shows_reverting() -> io::Result<()> { + let repo_dir = create_repo_with_conflict()?; + let path = path_str(&repo_dir)?; + + run_git_cmd(&["revert", "--no-commit", "HEAD~1"], Some(path), false)?; + + let output = common::render_module("git_state") + .current_dir(path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("REVERTING")); + + Ok(()) +} + +fn run_git_cmd(args: A, dir: Option<&str>, expect_ok: 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 expect_ok && !status.success() { + Err(Error::from(ErrorKind::Other)) + } else { + Ok(()) + } +} + +fn create_repo_with_conflict() -> io::Result { + let repo_dir = common::new_tempdir()?; + let path = path_str(&repo_dir)?; + let conflicted_file = repo_dir.path().join("the_file"); + + let write_file = |text: &str| { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&conflicted_file)?; + write!(file, "{}", text) + }; + + // Initialise a new git repo + run_git_cmd(&["init", "--quiet", path], 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, + )?; + + // Write a file on master and commit it + write_file("Version A")?; + run_git_cmd(&["add", "the_file"], Some(path), true)?; + run_git_cmd(&["commit", "--message", "Commit A"], Some(path), true)?; + + // Switch to another branch, and commit a change to the file + run_git_cmd(&["checkout", "-b", "other-branch"], Some(path), true)?; + write_file("Version B")?; + run_git_cmd( + &["commit", "--all", "--message", "Commit B"], + Some(path), + true, + )?; + + // Switch back to master, and commit a third change to the file + run_git_cmd(&["checkout", "master"], Some(path), true)?; + write_file("Version C")?; + run_git_cmd( + &["commit", "--all", "--message", "Commit C"], + Some(path), + true, + )?; + + Ok(repo_dir) +} + +fn path_str(repo_dir: &tempfile::TempDir) -> io::Result<&str> { + repo_dir + .path() + .to_str() + .ok_or_else(|| Error::from(ErrorKind::Other)) +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 82da9cccf..bf64f3099 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -4,6 +4,7 @@ mod common; mod configuration; mod directory; mod git_branch; +mod git_state; mod git_status; mod golang; mod hostname;