Files
starship/src/modules/git_status.rs
T
renovate[bot] 6a604b4671 build(deps): update rust crate gix to 0.74.0 (#7061)
* build(deps): update rust crate gix to 0.74.0

* chore: fix for enum change

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: David Knaack <davidkna@users.noreply.github.com>
2025-10-25 09:41:49 +02:00

1793 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{Context, Module, ModuleConfig};
use crate::configs::git_status::GitStatusConfig;
use crate::formatter::StringFormatter;
use crate::segment::Segment;
use crate::{context, num_configured_starship_threads, num_rayon_threads};
use gix::bstr::ByteVec;
use gix::status::Submodule;
use regex::Regex;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock};
const ALL_STATUS_FORMAT: &str =
"$conflicted$stashed$deleted$renamed$modified$typechanged$staged$untracked";
/// Creates a module with the Git branch in the current directory
///
/// Will display the branch name if the current directory is a git repo
/// By default, the following symbols will be used to represent the repo's status:
/// - `=` This branch has merge conflicts
/// - `⇡` This branch is ahead of the branch being tracked
/// - `⇣` This branch is behind of the branch being tracked
/// - `⇕` This branch has diverged from the branch being tracked
/// - `` This branch is up-to-date with the branch being tracked
/// - `?` — There are untracked files in the working directory
/// - `$` — A stash exists for the local repository
/// - `!` — There are file modifications in the working directory
/// - `+` — A new file has been added to the staging area
/// - `»` — A renamed file has been added to the staging area
/// - `✘` — A file's deletion has been added to the staging area
pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
let mut module = context.new_module("git_status");
let config: GitStatusConfig = GitStatusConfig::try_load(module.config);
// Return None if not in git repository
let repo = context.get_repo().ok()?;
if repo.kind.is_bare() {
log::debug!("This is a bare repository, git_status is not applicable");
return None;
}
if let Some(git_status) = git_status_wsl(context, &config) {
if git_status.is_empty() {
return None;
}
module.set_segments(Segment::from_text(None, git_status));
return Some(module);
}
let info = GitStatusInfo::load(context, repo, config.clone());
let parsed = StringFormatter::new(config.format).and_then(|formatter| {
formatter
.map_meta(|variable, _| match variable {
"all_status" => Some(ALL_STATUS_FORMAT),
_ => None,
})
.map_style(|variable: &str| match variable {
"style" => Some(Ok(config.style)),
_ => None,
})
.map_variables_to_segments(|variable: &str| {
let segments = match variable {
"stashed" => info.get_stashed().and_then(|count| {
format_count(config.stashed, "git_status.stashed", context, count)
}),
"ahead_behind" => info.get_ahead_behind().and_then(|(ahead, behind)| {
let (ahead, behind) = (ahead?, behind?);
if ahead > 0 && behind > 0 {
format_text(
config.diverged,
"git_status.diverged",
context,
|variable| match variable {
"ahead_count" => Some(ahead.to_string()),
"behind_count" => Some(behind.to_string()),
_ => None,
},
)
} else if ahead > 0 && behind == 0 {
format_count(config.ahead, "git_status.ahead", context, ahead)
} else if behind > 0 && ahead == 0 {
format_count(config.behind, "git_status.behind", context, behind)
} else {
format_symbol(config.up_to_date, "git_status.up_to_date", context)
}
}),
"conflicted" => info.get_conflicted().and_then(|count| {
format_count(config.conflicted, "git_status.conflicted", context, count)
}),
"deleted" => info.get_deleted().and_then(|count| {
format_count(config.deleted, "git_status.deleted", context, count)
}),
"renamed" => info.get_renamed().and_then(|count| {
format_count(config.renamed, "git_status.renamed", context, count)
}),
"modified" => info.get_modified().and_then(|count| {
format_count(config.modified, "git_status.modified", context, count)
}),
"staged" => info.get_staged().and_then(|count| {
format_count(config.staged, "git_status.staged", context, count)
}),
"untracked" => info.get_untracked().and_then(|count| {
format_count(config.untracked, "git_status.untracked", context, count)
}),
"typechanged" => info.get_typechanged().and_then(|count| {
format_count(config.typechanged, "git_status.typechanged", context, count)
}),
_ => None,
};
segments.map(Ok)
})
.parse(None, Some(context))
});
module.set_segments(match parsed {
Ok(segments) => {
if segments.is_empty() {
return None;
}
segments
}
Err(error) => {
log::warn!("Error in module `git_status`:\n{error}");
return None;
}
});
Some(module)
}
struct GitStatusInfo<'a> {
context: &'a Context<'a>,
repo: &'a context::Repo,
config: GitStatusConfig<'a>,
repo_status: OnceLock<Option<Arc<RepoStatus>>>,
stashed_count: OnceLock<Option<usize>>,
}
impl<'a> GitStatusInfo<'a> {
pub fn load(
context: &'a Context,
repo: &'a context::Repo,
config: GitStatusConfig<'a>,
) -> Self {
Self {
context,
repo,
config,
repo_status: OnceLock::new(),
stashed_count: OnceLock::new(),
}
}
pub fn get_ahead_behind(&self) -> Option<(Option<usize>, Option<usize>)> {
self.get_repo_status().map(|data| (data.ahead, data.behind))
}
pub fn get_repo_status(&self) -> Option<&RepoStatus> {
self.repo_status
.get_or_init(|| {
get_static_repo_status(self.context, self.repo, &self.config).or_else(|| {
log::debug!("get_repo_status: git status execution failed");
None
})
})
.as_deref()
}
pub fn get_stashed(&self) -> &Option<usize> {
self.stashed_count.get_or_init(|| {
get_stashed_count(self.repo).or_else(|| {
log::debug!("get_stashed_count: git stash execution failed");
None
})
})
}
pub fn get_conflicted(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.conflicted)
}
pub fn get_deleted(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.deleted)
}
pub fn get_renamed(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.renamed)
}
pub fn get_modified(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.modified)
}
pub fn get_staged(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.staged)
}
pub fn get_untracked(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.untracked)
}
pub fn get_typechanged(&self) -> Option<usize> {
self.get_repo_status().map(|data| data.typechanged)
}
}
/// Return a globally shared version the repository status so it can be reused.
/// It's shared so those who received a copy can keep it, even if the next call uses a different
/// path so the cache is trashed.
///
/// The trashing is only expected when tests run though, as otherwise one path is used with a variety of modules.
pub(crate) fn get_static_repo_status(
context: &Context,
repo: &context::Repo,
config: &GitStatusConfig,
) -> Option<Arc<RepoStatus>> {
static REPO_STATUS: parking_lot::Mutex<Option<(Arc<RepoStatus>, PathBuf)>> =
parking_lot::Mutex::new(None);
let mut status = REPO_STATUS.lock();
let needs_update = status
.as_ref()
.is_none_or(|(_status, status_path)| status_path != &context.current_dir);
if needs_update {
*status = get_repo_status(context, repo, config)
.map(|status| (Arc::new(status), context.current_dir.clone()));
}
status.as_ref().map(|(status, _)| Arc::clone(status))
}
/// Gets the number of files in various git states (staged, modified, deleted, etc...)
fn get_repo_status(
context: &Context,
repo: &context::Repo,
config: &GitStatusConfig,
) -> Option<RepoStatus> {
log::debug!("New repo status created");
let mut repo_status = RepoStatus::default();
let gix_repo = repo.open();
// TODO: remove this special case once `gitoxide` can handle sparse indices for tree-index comparisons.
let has_untracked = !config.untracked.is_empty();
let git_config = gix_repo.config_snapshot();
if config.use_git_executable
|| repo.fs_monitor_value_is_true
|| gix_repo.index_or_empty().ok()?.is_sparse()
{
let mut args = vec!["status", "--porcelain=2"];
// for performance reasons, only pass flags if necessary...
let has_ahead_behind = !config.ahead.is_empty() || !config.behind.is_empty();
let has_up_to_date_diverged = !config.up_to_date.is_empty() || !config.diverged.is_empty();
if has_ahead_behind || has_up_to_date_diverged {
args.push("--branch");
}
// ... and add flags that omit information the user doesn't want
if !has_untracked {
args.push("--untracked-files=no");
}
if config.ignore_submodules {
args.push("--ignore-submodules=dirty");
} else if !has_untracked {
args.push("--ignore-submodules=untracked");
}
let status_output = repo.exec_git(context, &args)?;
let statuses = status_output.stdout.lines();
statuses.for_each(|status| {
if status.starts_with("# branch.ab ") {
repo_status.set_ahead_behind(status);
} else if !status.starts_with('#') {
repo_status.add(status);
}
});
} else {
let is_interrupted = Arc::new(AtomicBool::new(false));
std::thread::Builder::new()
.name("starship timer".into())
.stack_size(256 * 1024)
.spawn({
let is_interrupted = is_interrupted.clone();
let abort_after =
std::time::Duration::from_millis(context.root_config.command_timeout);
move || {
std::thread::sleep(abort_after);
is_interrupted.store(true, std::sync::atomic::Ordering::SeqCst);
}
})
.expect("should be able to spawn timer thread");
// We don't show details in submodules.
let check_dirty = true;
let status = gix_repo
.status(gix::features::progress::Discard)
.ok()?
.index_worktree_submodules(if config.ignore_submodules {
Submodule::Given {
ignore: gix::submodule::config::Ignore::Dirty,
check_dirty,
}
} else if !has_untracked {
Submodule::Given {
ignore: gix::submodule::config::Ignore::Untracked,
check_dirty,
}
} else {
Submodule::AsConfigured { check_dirty }
})
.index_worktree_options_mut(|opts| {
opts.thread_limit = if cfg!(target_os = "macos") {
Some(num_configured_starship_threads().unwrap_or(
// TODO: figure out good defaults for other platforms, maybe make it configurable.
// Git uses everything (if repo-size permits), but that's not the best choice for MacOS.
3,
))
} else {
Some(num_rayon_threads())
};
if config.untracked.is_empty() {
opts.dirwalk_options.take();
} else if let Some(opts) = opts.dirwalk_options.as_mut() {
opts.set_emit_untracked(gix::dir::walk::EmissionMode::Matching)
.set_emit_ignored(None)
.set_emit_pruned(false)
.set_emit_empty_directories(false);
}
})
.tree_index_track_renames(if config.renamed.is_empty() {
gix::status::tree_index::TrackRenames::Disabled
} else {
gix::status::tree_index::TrackRenames::Given(sanitize_rename_tracking(
// Get configured diff-rename configuration, or use default settings.
gix::diff::new_rewrites(&git_config, true)
.unwrap_or_default()
.0
.unwrap_or_default(),
))
})
.should_interrupt_owned(is_interrupted.clone());
// This will start the status machinery, collecting status items in the background.
// Thus, we can do some work in this thread without blocking, before starting to count status items.
let status = status.into_iter(None).ok()?;
// for performance reasons, only pass flags if necessary...
let has_ahead_behind = !config.ahead.is_empty() || !config.behind.is_empty();
let has_up_to_date_or_diverged =
!config.up_to_date.is_empty() || !config.diverged.is_empty();
if has_ahead_behind || has_up_to_date_or_diverged {
if let Some(branch_name) = gix_repo.head_name().ok().flatten().and_then(|ref_name| {
Vec::from(gix::bstr::BString::from(ref_name))
.into_string()
.ok()
}) {
let output = repo.exec_git(
context,
["for-each-ref", "--format", "%(upstream) %(upstream:track)"]
.into_iter()
.map(ToOwned::to_owned)
.chain(Some(branch_name)),
)?;
if let Some(line) = output.stdout.lines().next() {
repo_status.set_ahead_behind_for_each_ref(line);
}
}
}
for change in status.filter_map(Result::ok) {
use gix::status;
match &change {
status::Item::TreeIndex(change) => {
use gix::diff::index::Change;
match change {
Change::Addition { .. } | Change::Modification { .. } => {
repo_status.staged += 1;
}
Change::Deletion { .. } => {
repo_status.deleted += 1;
}
Change::Rewrite { .. } => {
repo_status.renamed += 1;
}
}
}
status::Item::IndexWorktree(change) => {
use gix::status::index_worktree::Item;
use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
match change {
Item::Modification {
status: EntryStatus::Conflict { .. },
..
} => {
repo_status.conflicted += 1;
}
Item::Modification {
status: EntryStatus::Change(Change::Removed),
..
} => {
repo_status.deleted += 1;
}
Item::Modification {
status:
EntryStatus::IntentToAdd
| EntryStatus::Change(
Change::Modification { .. } | Change::SubmoduleModification(_),
),
..
} => {
repo_status.modified += 1;
}
Item::Modification {
status: EntryStatus::Change(Change::Type { .. }),
..
} => {
repo_status.typechanged += 1;
}
Item::DirectoryContents {
entry:
gix::dir::Entry {
status: gix::dir::entry::Status::Untracked,
..
},
..
} => {
repo_status.untracked += 1;
}
Item::Rewrite { .. } => {
unreachable!(
"this kind of rename tracking isn't enabled by default and specific to gitoxide"
)
}
_ => {}
}
}
}
// Keep it for potential reuse by `git_metrics`
repo_status.changes.push(change);
}
if is_interrupted.load(std::sync::atomic::Ordering::Relaxed) {
repo_status = RepoStatus {
ahead: repo_status.ahead,
behind: repo_status.behind,
..Default::default()
};
}
}
Some(repo_status)
}
fn sanitize_rename_tracking(mut config: gix::diff::Rewrites) -> gix::diff::Rewrites {
config.limit = 100;
config
}
fn get_stashed_count(repo: &context::Repo) -> Option<usize> {
let repo = repo.open();
let reference = match repo.try_find_reference("refs/stash") {
// Only proceed if the found reference has the expected name (not tags/refs/stash etc.)
Ok(Some(reference)) if reference.name().as_bstr() == b"refs/stash".as_slice() => reference,
// No stash reference found
Ok(_) => return Some(0),
Err(err) => {
log::debug!("Error finding stash reference: {err}");
return None;
}
};
match reference.log_iter().all() {
Ok(Some(log)) => Some(log.count()),
Ok(None) => {
log::debug!("No reflog found for stash");
Some(0)
}
Err(err) => {
log::debug!("Error getting stash log: {err}");
None
}
}
}
#[derive(Default, Debug, Clone)]
pub(crate) struct RepoStatus {
ahead: Option<usize>,
behind: Option<usize>,
pub(crate) changes: Vec<gix::status::Item>,
conflicted: usize,
deleted: usize,
renamed: usize,
modified: usize,
staged: usize,
typechanged: usize,
untracked: usize,
}
impl RepoStatus {
fn is_deleted(short_status: &str) -> bool {
// is_wt_deleted || is_index_deleted
short_status.contains('D')
}
fn is_modified(short_status: &str) -> bool {
// is_wt_modified || is_wt_added
short_status.ends_with('M') || short_status.ends_with('A')
}
fn is_staged(short_status: &str) -> bool {
// is_index_modified || is_index_added || is_index_typechanged
short_status.starts_with('M')
|| short_status.starts_with('A')
|| short_status.starts_with('T')
}
fn is_typechanged(short_status: &str) -> bool {
short_status.ends_with('T')
}
fn parse_normal_status(&mut self, short_status: &str) {
if Self::is_deleted(short_status) {
self.deleted += 1;
}
if Self::is_modified(short_status) {
self.modified += 1;
}
if Self::is_staged(short_status) {
self.staged += 1;
}
if Self::is_typechanged(short_status) {
self.typechanged += 1;
}
}
fn add(&mut self, s: &str) {
match s.chars().next() {
Some('1') => self.parse_normal_status(&s[2..4]),
Some('2') => {
self.renamed += 1;
self.parse_normal_status(&s[2..4]);
}
Some('u') => self.conflicted += 1,
Some('?') => self.untracked += 1,
Some('!') => (),
Some(_) => log::error!("Unknown line type in git status output"),
None => log::error!("Missing line type in git status output"),
}
}
fn set_ahead_behind(&mut self, s: &str) {
let re = Regex::new(r"branch\.ab \+([0-9]+) \-([0-9]+)").unwrap();
if let Some(caps) = re.captures(s) {
self.ahead = caps.get(1).unwrap().as_str().parse::<usize>().ok();
self.behind = caps.get(2).unwrap().as_str().parse::<usize>().ok();
}
}
fn set_ahead_behind_for_each_ref(&mut self, mut s: &str) {
if s == " " || s.ends_with(" [gone]") {
self.ahead = None;
self.behind = None;
return;
}
s = s
.split_once(' ')
.unwrap()
.1
.trim_matches(|c| c == '[' || c == ']');
for pair in s.split(',') {
let mut tokens = pair.trim().splitn(2, ' ');
if let (Some(name), Some(number)) = (tokens.next(), tokens.next()) {
let storage = match name {
"ahead" => &mut self.ahead,
"behind" => &mut self.behind,
_ => return,
};
*storage = number.parse().ok();
}
}
for field in [&mut self.ahead, &mut self.behind] {
if field.is_none() {
*field = Some(0);
}
}
}
}
fn format_text<F>(
format_str: &str,
config_path: &str,
context: &Context,
mapper: F,
) -> Option<Vec<Segment>>
where
F: Fn(&str) -> Option<String> + Send + Sync,
{
if let Ok(formatter) = StringFormatter::new(format_str) {
formatter
.map(|variable| mapper(variable).map(Ok))
.parse(None, Some(context))
.ok()
} else {
log::warn!("Error parsing format string `{}`", &config_path);
None
}
}
fn format_count(
format_str: &str,
config_path: &str,
context: &Context,
count: usize,
) -> Option<Vec<Segment>> {
if count == 0 {
return None;
}
format_text(
format_str,
config_path,
context,
|variable| match variable {
"count" => Some(count.to_string()),
_ => None,
},
)
}
fn format_symbol(format_str: &str, config_path: &str, context: &Context) -> Option<Vec<Segment>> {
format_text(format_str, config_path, context, |_variable| None)
}
#[cfg(target_os = "linux")]
fn git_status_wsl(context: &Context, conf: &GitStatusConfig) -> Option<String> {
use crate::utils::create_command;
use nix::sys::utsname::uname;
use std::env;
use std::ffi::OsString;
use std::io::ErrorKind;
let starship_exe = conf.windows_starship?;
// Ensure this is WSL
// This is lowercase in WSL1 and uppercase in WSL2, just skip the first letter
if !uname()
.ok()?
.release()
.to_string_lossy()
.contains("icrosoft")
{
return None;
}
log::trace!("Using WSL mode");
// Get Windows path
let wslpath = create_command("wslpath")
.map(|mut c| {
c.arg("-w").arg(&context.current_dir);
c
})
.and_then(|mut c| c.output());
let winpath = match wslpath {
Ok(r) => r,
Err(e) => {
// Not found might means this might not be WSL after all
let level = if e.kind() == ErrorKind::NotFound {
log::Level::Debug
} else {
log::Level::Error
};
log::log!(level, "Failed to get Windows path:\n{e:?}");
return None;
}
};
let winpath = match std::str::from_utf8(&winpath.stdout) {
Ok(r) => r.trim_end(),
Err(e) => {
log::error!("Failed to parse Windows path:\n{e:?}");
return None;
}
};
log::trace!("Windows path: {winpath}");
// In Windows or Linux dir?
if winpath.starts_with(r"\\wsl") {
log::trace!("Not a Windows path");
return None;
}
// Get foreign starship to use WSL config
// https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/
let wslenv = env::var("WSLENV").map_or_else(
|_| "STARSHIP_CONFIG/wp".to_string(),
|e| e + ":STARSHIP_CONFIG/wp",
);
let exe = create_command(starship_exe)
.map(|mut c| {
c.env(
"STARSHIP_CONFIG",
context
.get_config_path_os()
.unwrap_or_else(|| OsString::from("/dev/null")),
)
.env("WSLENV", wslenv)
.args(["module", "git_status", "--path", winpath]);
c
})
.and_then(|mut c| c.output());
let out = match exe {
Ok(r) => r,
Err(e) => {
log::error!("Failed to run Git Status module on Windows:\n{e}");
return None;
}
};
match String::from_utf8(out.stdout) {
Ok(r) => Some(r),
Err(e) => {
log::error!("Failed to parse Windows Git Status module status output:\n{e}");
None
}
}
}
#[cfg(not(target_os = "linux"))]
fn git_status_wsl(_context: &Context, _conf: &GitStatusConfig) -> Option<String> {
None
}
#[cfg(test)]
pub(crate) mod tests {
use crate::test::{FixtureProvider, ModuleRenderer, fixture_repo};
use crate::utils::create_command;
use nu_ansi_term::{AnsiStrings, Color};
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{self, prelude::*};
use std::path::Path;
#[allow(clippy::unnecessary_wraps)]
fn format_output(symbols: &str) -> Option<String> {
Some(format!(
"{} ",
Color::Red.bold().paint(format!("[{symbols}]"))
))
}
#[test]
fn show_nothing_on_empty_dir() -> io::Result<()> {
let repo_dir = tempfile::tempdir()?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = None;
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_behind() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
behind(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_behind_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
behind(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
behind = "⇣$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("⇣1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_ahead() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
File::create(repo_dir.path().join("readme.md"))?.sync_all()?;
ahead(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_ahead_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
File::create(repo_dir.path().join("readme.md"))?.sync_all()?;
ahead(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
ahead="⇡$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("⇡1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_diverged() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
diverge(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_diverged_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
diverge(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
diverged=r"⇕⇡$ahead_count⇣$behind_count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("⇕⇡1⇣1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_up_to_date_with_upstream() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
up_to_date=""
})
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn hides_up_to_date_on_untracked_branch() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_branch(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
up_to_date=""
})
.path(repo_dir.path())
.collect();
let expected = None;
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn hides_up_to_date_on_gone_branch() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_branch_with_gone_upstream(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
up_to_date=""
})
.path(repo_dir.path())
.collect();
let expected = None;
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_conflicted() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_conflict(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("=");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_conflicted_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_conflict(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
conflicted = "=$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("=1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_untracked_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_untracked(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("?");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_untracked_file_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_untracked(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
untracked = "?$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("?1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn doesnt_show_untracked_file_if_disabled() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_untracked(repo_dir.path())?;
create_command("git")?
.args(["config", "status.showUntrackedFiles", "no"])
.current_dir(repo_dir.path())
.output()?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = None;
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
#[cfg(unix)]
fn doesnt_run_fsmonitor() -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let repo_dir = fixture_repo(FixtureProvider::Git)?;
let mut f = File::create(repo_dir.path().join("do_not_execute"))?;
write!(f, "#!/bin/sh\necho executed > executed\nsync executed")?;
let metadata = f.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o700);
f.set_permissions(permissions)?;
f.sync_all()?;
create_command("git")?
.args(["config", "core.fsmonitor"])
.arg(repo_dir.path().join("do_not_execute"))
.current_dir(repo_dir.path())
.output()?;
ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let created_file = repo_dir.path().join("executed").exists();
assert!(!created_file);
repo_dir.close()
}
#[test]
fn shows_stashed() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_stash(repo_dir.path())?;
create_command("git")?
.args(["reset", "--hard", "HEAD"])
.current_dir(repo_dir.path())
.output()?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
format = "$stashed"
})
.path(repo_dir.path())
.collect();
let expected = Some(String::from("$"));
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_no_stashed_after_undo() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_stash(repo_dir.path())?;
undo_stash(repo_dir.path())?;
create_command("git")?
.args(["reset", "--hard", "HEAD"])
.current_dir(repo_dir.path())
.output()?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
format = "$stashed"
})
.path(repo_dir.path())
.collect();
let expected = None;
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_stashed_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_stash(repo_dir.path())?;
undo_stash(repo_dir.path())?;
create_stash(repo_dir.path())?;
create_stash(repo_dir.path())?;
create_command("git")?
.args(["reset", "--hard", "HEAD"])
.current_dir(repo_dir.path())
.output()?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
stashed = r"\$$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("$2");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_typechanged() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_typechanged(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
typechanged = ""
})
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_typechanged_in_index() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_typechanged_in_index(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("+");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_modified() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_modified(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("!");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_modified_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_modified(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
modified = "!$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("!1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_modified_with_count_sparse() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
make_sparse(repo_dir.path())?;
create_modified(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
modified = "!$count"
ahead = ""
})
.path(repo_dir.path())
.collect();
let expected = format_output("!1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_added() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_added(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("!");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_staged_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_staged(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("+");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_staged_file_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_staged(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
staged = "+[$count](green)"
})
.path(repo_dir.path())
.collect();
let expected = Some(format!(
"{} ",
AnsiStrings(&[
Color::Red.bold().paint("[+"),
Color::Green.paint("1"),
Color::Red.bold().paint("]"),
])
));
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_staged_typechange_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_staged_typechange(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
staged = "+[$count](green)"
})
.path(repo_dir.path())
.collect();
let expected = Some(format!(
"{} ",
AnsiStrings(&[
Color::Red.bold().paint("[+"),
Color::Green.paint("1"),
Color::Red.bold().paint("]"),
])
));
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_staged_and_modified_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_staged_and_modified(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("!+");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_renamed_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_renamed(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("»");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_renamed_file_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_renamed(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
renamed = "»$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("»1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_renamed_and_modified_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_renamed_and_modified(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("»!");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_deleted_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_deleted(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_deleted_file_in_index() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_deleted_in_index(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn shows_deleted_file_with_count() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_deleted(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.config(toml::toml! {
[git_status]
deleted = "✘$count"
})
.path(repo_dir.path())
.collect();
let expected = format_output("✘1");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn doesnt_show_ignored_file() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_staged_and_ignored(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("+");
assert_eq!(expected, actual);
repo_dir.close()
}
#[test]
fn worktree_in_different_dir() -> io::Result<()> {
let worktree_dir = tempfile::tempdir()?;
let repo_dir = fixture_repo(FixtureProvider::Git)?;
create_command("git")?
.args([
OsStr::new("config"),
OsStr::new("core.worktree"),
worktree_dir.path().as_os_str(),
])
.current_dir(repo_dir.path())
.output()?;
File::create(worktree_dir.path().join("test_file"))?.sync_all()?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
let expected = format_output("✘?");
assert_eq!(expected, actual);
worktree_dir.close()?;
repo_dir.close()
}
// Whenever a file is manually renamed, git itself ('git status') does not treat such file as renamed,
// but as untracked instead. The following test checks if manually deleted and manually renamed
// files are tracked by git_status module in the same way 'git status' does.
#[test]
fn ignore_manually_renamed() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::Git)?;
File::create(repo_dir.path().join("a"))?.sync_all()?;
File::create(repo_dir.path().join("b"))?.sync_all()?;
create_command("git")?
.args(["add", "--all"])
.current_dir(repo_dir.path())
.output()?;
create_command("git")?
.args(["commit", "-m", "add new files", "--no-gpg-sign"])
.current_dir(repo_dir.path())
.output()?;
fs::remove_file(repo_dir.path().join("a"))?;
fs::rename(repo_dir.path().join("b"), repo_dir.path().join("c"))?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.config(toml::toml! {
[git_status]
ahead = "A"
deleted = "D"
untracked = "U"
renamed = "R"
})
.collect();
let expected = format_output("DUA");
assert_eq!(actual, expected);
repo_dir.close()
}
#[test]
fn doesnt_generate_git_status_for_bare_repo() -> io::Result<()> {
let repo_dir = fixture_repo(FixtureProvider::GitBare)?;
create_added(repo_dir.path())?;
let actual = ModuleRenderer::new("git_status")
.path(repo_dir.path())
.collect();
assert_eq!(None, actual);
repo_dir.close()
}
fn ahead(repo_dir: &Path) -> io::Result<()> {
File::create(repo_dir.join("readme.md"))?.sync_all()?;
create_command("git")?
.args(["commit", "-am", "Update readme", "--no-gpg-sign"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn behind(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["reset", "--hard", "HEAD^"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn diverge(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["reset", "--hard", "HEAD^"])
.current_dir(repo_dir)
.output()?;
fs::write(repo_dir.join("Cargo.toml"), " ")?;
create_command("git")?
.args(["commit", "-am", "Update readme", "--no-gpg-sign"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_typechanged(repo_dir: &Path) -> io::Result<()> {
fs::remove_file(repo_dir.join("readme.md"))?;
#[cfg(not(target_os = "windows"))]
std::os::unix::fs::symlink(repo_dir.join("Cargo.toml"), repo_dir.join("readme.md"))?;
#[cfg(target_os = "windows")]
std::os::windows::fs::symlink_file(
repo_dir.join("Cargo.toml"),
repo_dir.join("readme.md"),
)?;
Ok(())
}
fn create_typechanged_in_index(repo_dir: &Path) -> io::Result<()> {
create_typechanged(repo_dir)?;
create_command("git")?
.args(["add", "readme.md"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_staged_typechange(repo_dir: &Path) -> io::Result<()> {
create_typechanged(repo_dir)?;
create_command("git")?
.args(["add", "."])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_conflict(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["reset", "--hard", "HEAD^"])
.current_dir(repo_dir)
.output()?;
fs::write(repo_dir.join("readme.md"), "# goodbye")?;
create_command("git")?
.args(["add", "."])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["commit", "-m", "Change readme", "--no-gpg-sign"])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["pull", "--rebase"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_stash(repo_dir: &Path) -> io::Result<()> {
let (file, _path) = tempfile::NamedTempFile::new_in(repo_dir)?.keep()?;
file.sync_all()?;
create_command("git")?
.args(["stash", "--all"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn undo_stash(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["stash", "pop"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_untracked(repo_dir: &Path) -> io::Result<()> {
File::create(repo_dir.join("license"))?.sync_all()?;
Ok(())
}
fn create_added(repo_dir: &Path) -> io::Result<()> {
File::create(repo_dir.join("license"))?.sync_all()?;
create_command("git")?
.args(["add", "-A", "-N"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_modified(repo_dir: &Path) -> io::Result<()> {
File::create(repo_dir.join("readme.md"))?.sync_all()?;
Ok(())
}
pub(crate) fn make_sparse(repo_dir: &Path) -> io::Result<()> {
let sparse_dirname = "sparse-dir";
let dir = repo_dir.join(sparse_dirname);
std::fs::create_dir(&dir)?;
File::create(dir.join("still-visible"))?.sync_all()?;
let subdir = dir.join("not-checked-out");
std::fs::create_dir(&subdir)?;
File::create(subdir.join("invisible"))?.sync_all()?;
create_command("git")?
.args(["add", "sparse-dir"])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["commit", "-m", "add new directory", "--no-gpg-sign"])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["sparse-checkout", "set", sparse_dirname, "--sparse-index"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_staged(repo_dir: &Path) -> io::Result<()> {
File::create(repo_dir.join("license"))?.sync_all()?;
create_command("git")?
.args(["add", "."])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_staged_and_modified(repo_dir: &Path) -> io::Result<()> {
let mut file = File::create(repo_dir.join("readme.md"))?;
file.sync_all()?;
create_command("git")?
.args(["add", "."])
.current_dir(repo_dir)
.output()?;
writeln!(&mut file, "modified")?;
file.sync_all()?;
Ok(())
}
fn create_renamed(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["mv", "readme.md", "readme.md.bak"])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["add", "-A"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_renamed_and_modified(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["mv", "readme.md", "readme.md.bak"])
.current_dir(repo_dir)
.output()?;
create_command("git")?
.args(["add", "-A"])
.current_dir(repo_dir)
.output()?;
let mut file = File::create(repo_dir.join("readme.md.bak"))?;
writeln!(&mut file, "modified")?;
file.sync_all()?;
Ok(())
}
fn create_deleted(repo_dir: &Path) -> io::Result<()> {
fs::remove_file(repo_dir.join("readme.md"))?;
Ok(())
}
fn create_deleted_in_index(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["rm", "readme.md"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_staged_and_ignored(repo_dir: &Path) -> io::Result<()> {
let mut file = File::create(repo_dir.join(".gitignore"))?;
writeln!(&mut file, "ignored.txt")?;
file.sync_all()?;
create_command("git")?
.args(["add", ".gitignore"])
.current_dir(repo_dir)
.output()?;
let mut file = File::create(repo_dir.join("ignored.txt"))?;
writeln!(&mut file, "modified")?;
file.sync_all()?;
Ok(())
}
fn create_branch(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["switch", "-c", "new-branch"])
.current_dir(repo_dir)
.output()?;
Ok(())
}
fn create_branch_with_gone_upstream(repo_dir: &Path) -> io::Result<()> {
create_command("git")?
.args(["switch", "-c", "gone-branch"])
.current_dir(repo_dir)
.output()?;
let config_path = repo_dir.join(".git").join("config");
let mut config_file = OpenOptions::new().append(true).open(&config_path)?;
writeln!(
config_file,
"\n[branch \"gone-branch\"]\n\tremote = origin\n\tmerge = refs/heads/gone-upstream\n"
)?;
Ok(())
}
}