diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2e0b444..de3a46f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Add [Git integration](https://github.com/Peltoche/lsd/issues/7) from [hpwxf](https://github.com/hpwxf) - In keeping with the coreutils change, add quotes and escapes for necessary filenames from [merelymyself](https://github.com/merelymyself) - Add support for icon theme from [zwpaper](https://github.com/zwpaper) - Add icon for kt and kts from [LeeWeeder](https://github.com/LeeWeeder) diff --git a/Cargo.lock b/Cargo.lock index 4261ddaed..0e5ed12ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,9 @@ name = "cc" version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +dependencies = [ + "jobserver", +] [[package]] name = "cfg-if" @@ -295,6 +298,19 @@ dependencies = [ "wasi 0.10.2+wasi-snapshot-preview1", ] +[[package]] +name = "git2" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glob" version = "0.3.0" @@ -431,6 +447,15 @@ dependencies = [ "either", ] +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.58" @@ -452,6 +477,30 @@ version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +[[package]] +name = "libgit2-sys" +version = "0.14.2+1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -503,6 +552,7 @@ dependencies = [ "clap_complete", "crossterm", "dirs", + "git2", "globset", "human-sort", "libc", @@ -643,6 +693,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + [[package]] name = "predicates" version = "1.0.8" @@ -1104,6 +1160,12 @@ dependencies = [ "log", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index dff0f7a27..50e8cbd23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ clap_complete = "4.1" version_check = "0.9.*" [dependencies] -crossterm = { version = "0.24.0", features = ["serde"]} +crossterm = { version = "0.24.0", features = ["serde"] } dirs = "3.0.*" libc = "0.2.*" human-sort = "0.2.2" @@ -42,6 +42,10 @@ serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.8" url = "2.1.*" +[target."cfg(not(all(windows, target_arch = \"x86\", target_env = \"gnu\")))".dependencies] +# if ssl feature is enabled compilation will fail on arm-unknown-linux-gnueabihf and i686-pc-windows-gnu +git2 = { version = "0.16", optional = true, default-features = false } + [target.'cfg(unix)'.dependencies] users = "0.11.*" xattr = "0.2.*" @@ -61,7 +65,9 @@ tempfile = "3" serial_test = "0.5" [features] +default = ["git2"] sudo = [] +no-git = [] # force disabling git even if available by default [profile.release] lto = true diff --git a/README.md b/README.md index dc14c8178..227ee075e 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ classic: false # == Blocks == # This specifies the columns and their order when using the long and the tree # layout. -# Possible values: permission, user, group, context, size, date, name, inode, links +# Possible values: permission, user, group, context, size, date, name, inode, links, git blocks: - permission - user diff --git a/build.rs b/build.rs index 8e3f05ce6..68540c9b5 100644 --- a/build.rs +++ b/build.rs @@ -34,4 +34,15 @@ fn main() { generate_to(Zsh, &mut app, bin_name, &outdir).expect("Failed to generate Zsh completions"); generate_to(PowerShell, &mut app, bin_name, &outdir) .expect("Failed to generate PowerShell completions"); + + // Disable git feature for these target where git2 is not well supported + if !std::env::var("CARGO_FEATURE_GIT2") + .map(|flag| flag == "1") + .unwrap_or(false) + || std::env::var("TARGET") + .map(|target| target == "i686-pc-windows-gnu") + .unwrap_or(false) + { + println!(r#"cargo:rustc-cfg=feature="no-git""#); + } } diff --git a/ci/before_deploy.bash b/ci/before_deploy.bash index e4afd5ca5..9c8f04566 100644 --- a/ci/before_deploy.bash +++ b/ci/before_deploy.bash @@ -4,7 +4,7 @@ set -ex build() { - cargo build --target "$TARGET" --release --verbose + cargo build --target "$TARGET" --features="$FEATURES" --release --verbose } pack() { diff --git a/doc/lsd.md b/doc/lsd.md index 1a3cc3ed1..0adec7b16 100644 --- a/doc/lsd.md +++ b/doc/lsd.md @@ -38,6 +38,9 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich `-X`, `--extensionsort` : Sort by file extension +`--git` +: Display git status. Directory git status is a reduction of included file statuses (recursively). + `--help` : Prints help information @@ -90,7 +93,7 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich : Natural sort of (version) numbers within text `--blocks ...` -: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode] +: Specify the blocks that will be displayed and in what order [possible values: permission, user, group, size, date, name, inode, git] `--color ...` : When to use terminal colours [default: auto] [possible values: always, auto, never] @@ -126,7 +129,7 @@ lsd is a ls command with a lot of pretty colours and some other stuff to enrich : How to display size [default: default] [possible values: default, short, bytes] `--sort ...` -: Sort by WORD instead of name [possible values: size, time, version, extension] +: Sort by WORD instead of name [possible values: size, time, version, extension, git] `-U`, `--no-sort` : Do not sort. List entries in directory order diff --git a/src/app.rs b/src/app.rs index 5003866f0..bd6defaeb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -96,6 +96,10 @@ pub struct Cli { #[arg(short = 'X', long)] pub extensionsort: bool, + /// Sort by git status + #[arg(short = 'G', long)] + pub gitsort: bool, + /// Natural sort of (version) numbers within text #[arg(short = 'v', long)] pub versionsort: bool, @@ -104,13 +108,13 @@ pub struct Cli { #[arg( long, value_name = "TYPE", - value_parser = ["size", "time", "version", "extension", "none"], - overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "no_sort"] + value_parser = ["size", "time", "version", "extension", "git", "none"], + overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "gitsort", "no_sort"] )] pub sort: Option, /// Do not sort. List entries in directory order - #[arg(short = 'U', long, overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "sort"])] + #[arg(short = 'U', long, overrides_with_all = ["timesort", "sizesort", "extensionsort", "versionsort", "gitsort", "sort"])] pub no_sort: bool, /// Reverse the order of the sort @@ -127,9 +131,9 @@ pub struct Cli { /// Specify the blocks that will be displayed and in what order #[arg( - long, - value_delimiter = ',', - value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links"], + long, + value_delimiter = ',', + value_parser = ["permission", "user", "group", "context", "size", "date", "name", "inode", "links", "git"], )] pub blocks: Vec, @@ -150,6 +154,11 @@ pub struct Cli { #[arg(short, long)] pub inode: bool, + /// Show git status on file and directory" + /// Only when used with --long option + #[arg(short, long)] + pub git: bool, + /// When showing file information for a symbolic link, /// show information for the file the link references rather than for the link itself #[arg(short = 'L', long)] @@ -196,15 +205,15 @@ pub fn validate_time_format(formatter: &str) -> Result { Some('f') => (), Some(n @ ('3' | '6' | '9')) => match chars.next() { Some('f') => (), - Some(c) => return Err(format!("invalid format specifier: %.{}{}", n, c)), + Some(c) => return Err(format!("invalid format specifier: %.{n}{c}")), None => return Err("missing format specifier".to_owned()), }, - Some(c) => return Err(format!("invalid format specifier: %.{}", c)), + Some(c) => return Err(format!("invalid format specifier: %.{c}")), None => return Err("missing format specifier".to_owned()), }, Some(n @ (':' | '#')) => match chars.next() { Some('z') => (), - Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)), + Some(c) => return Err(format!("invalid format specifier: %{n}{c}")), None => return Err("missing format specifier".to_owned()), }, Some(n @ ('-' | '_' | '0')) => match chars.next() { @@ -212,7 +221,7 @@ pub fn validate_time_format(formatter: &str) -> Result { 'C' | 'd' | 'e' | 'f' | 'G' | 'g' | 'H' | 'I' | 'j' | 'k' | 'l' | 'M' | 'm' | 'S' | 's' | 'U' | 'u' | 'V' | 'W' | 'w' | 'Y' | 'y', ) => (), - Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)), + Some(c) => return Err(format!("invalid format specifier: %{n}{c}")), None => return Err("missing format specifier".to_owned()), }, Some( @@ -223,10 +232,10 @@ pub fn validate_time_format(formatter: &str) -> Result { ) => (), Some(n @ ('3' | '6' | '9')) => match chars.next() { Some('f') => (), - Some(c) => return Err(format!("invalid format specifier: %{}{}", n, c)), + Some(c) => return Err(format!("invalid format specifier: %{n}{c}")), None => return Err("missing format specifier".to_owned()), }, - Some(c) => return Err(format!("invalid format specifier: %{}", c)), + Some(c) => return Err(format!("invalid format specifier: %{c}")), None => return Err("missing format specifier".to_owned()), }, None => break, @@ -235,3 +244,19 @@ pub fn validate_time_format(formatter: &str) -> Result { } Ok(formatter.to_owned()) } + +// Wrapper for value_parser to simply remove non supported option (mainly git flag) +// required since value_parser requires impl Into that Vec do not support +// should be located here, since this file is included by build.rs +struct LabelFilter bool, const C: usize>([&'static str; C], Filter); + +impl bool, const C: usize> From> + for clap::builder::ValueParser +{ + fn from(label_filter: LabelFilter) -> Self { + let filter = label_filter.1; + let values = label_filter.0.into_iter().filter(|x| filter(x)); + let inner = clap::builder::PossibleValuesParser::from(values); + Self::from(inner) + } +} diff --git a/src/color.rs b/src/color.rs index c054d5ab2..ed69849a3 100644 --- a/src/color.rs +++ b/src/color.rs @@ -4,6 +4,7 @@ use lscolors::{Indicator, LsColors}; use std::path::Path; pub use crate::flags::color::ThemeOption; +use crate::git::GitStatus; use crate::theme::{color::ColorTheme, Theme}; #[allow(dead_code)] @@ -61,6 +62,10 @@ pub enum Elem { }, TreeEdge, + + GitStatus { + status: GitStatus, + }, } impl Elem { @@ -121,6 +126,7 @@ impl Elem { Elem::TreeEdge => theme.tree_edge, Elem::Links { valid: false } => theme.links.invalid, Elem::Links { valid: true } => theme.links.valid, + Elem::GitStatus { .. } => theme.git_status.default, } } } @@ -389,6 +395,7 @@ mod elem { invalid: Color::AnsiValue(245), // Grey }, tree_edge: Color::AnsiValue(245), // Grey + git_status: Default::default(), } } diff --git a/src/config_file.rs b/src/config_file.rs index d2c6d7c95..c9392c838 100644 --- a/src/config_file.rs +++ b/src/config_file.rs @@ -203,7 +203,7 @@ classic: false # == Blocks == # This specifies the columns and their order when using the long and the tree # layout. -# Possible values: permission, user, group, context, size, date, name, inode +# Possible values: permission, user, group, context, size, date, name, inode, git blocks: - permission - user @@ -388,7 +388,7 @@ mod tests { total_size: Some(false), symlink_arrow: Some("⇒".into()), hyperlink: Some(HyperlinkOption::Never), - header: None + header: None, }, c ); diff --git a/src/core.rs b/src/core.rs index 9377c07ff..b0d0efc9c 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,7 +1,9 @@ use crate::color::Colors; use crate::display; use crate::flags::{ColorOption, Display, Flags, HyperlinkOption, Layout, SortOrder, ThemeOption}; +use crate::git::GitCache; use crate::icon::Icons; + use crate::meta::Meta; use crate::{print_error, print_output, sort, ExitCode}; use std::path::PathBuf; @@ -11,6 +13,8 @@ use std::io; #[cfg(not(target_os = "windows"))] use std::os::unix::io::AsRawFd; +use crate::flags::blocks::Block; +use crate::git_theme::GitTheme; #[cfg(target_os = "windows")] use terminal_size::terminal_size; @@ -18,6 +22,7 @@ pub struct Core { flags: Flags, icons: Icons, colors: Colors, + git_theme: GitTheme, sorters: Vec<(SortOrder, sort::SortFn)>, } @@ -75,6 +80,7 @@ impl Core { flags, colors: Colors::new(color_theme), icons: Icons::new(tty_available, icon_when, icon_theme, icon_separator), + git_theme: GitTheme::new(), sorters, } } @@ -106,12 +112,19 @@ impl Core { } }; + let cache = if self.flags.blocks.0.contains(&Block::GitStatus) { + Some(GitCache::new(&path)) + } else { + None + }; + let recurse = self.flags.layout == Layout::Tree || self.flags.display != Display::DirectoryOnly; if recurse { - match meta.recurse_into(depth, &self.flags) { + match meta.recurse_into(depth, &self.flags, cache.as_ref()) { Ok((content, path_exit_code)) => { meta.content = content; + meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true)); meta_list.push(meta); exit_code.set_if_greater(path_exit_code); } @@ -122,6 +135,7 @@ impl Core { } }; } else { + meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true)); meta_list.push(meta); }; } @@ -147,9 +161,21 @@ impl Core { fn display(&self, metas: &[Meta]) { let output = if self.flags.layout == Layout::Tree { - display::tree(metas, &self.flags, &self.colors, &self.icons) + display::tree( + metas, + &self.flags, + &self.colors, + &self.icons, + &self.git_theme, + ) } else { - display::grid(metas, &self.flags, &self.colors, &self.icons) + display::grid( + metas, + &self.flags, + &self.colors, + &self.icons, + &self.git_theme, + ) }; print_output!("{}", output); diff --git a/src/display.rs b/src/display.rs index 8e7c251c0..6fea6e9a3 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,5 +1,7 @@ use crate::color::{Colors, Elem}; -use crate::flags::{Block, Display, Flags, HyperlinkOption, Layout}; +use crate::flags::blocks::Block; +use crate::flags::{Display, Flags, HyperlinkOption, Layout}; +use crate::git_theme::GitTheme; use crate::icon::Icons; use crate::meta::name::DisplayOption; use crate::meta::{FileType, Meta}; @@ -13,7 +15,13 @@ const LINE: &str = "\u{2502} "; // "│ " const CORNER: &str = "\u{2514}\u{2500}\u{2500}"; // "└──" const BLANK: &str = " "; -pub fn grid(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> String { +pub fn grid( + metas: &[Meta], + flags: &Flags, + colors: &Colors, + icons: &Icons, + git_theme: &GitTheme, +) -> String { let term_width = terminal_size().map(|(w, _)| w.0 as usize); inner_display_grid( @@ -22,12 +30,19 @@ pub fn grid(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> St flags, colors, icons, + git_theme, 0, term_width, ) } -pub fn tree(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> String { +pub fn tree( + metas: &[Meta], + flags: &Flags, + colors: &Colors, + icons: &Icons, + git_theme: &GitTheme, +) -> String { let mut grid = Grid::new(GridOptions { filling: Filling::Spaces(1), direction: Direction::LeftToRight, @@ -42,19 +57,30 @@ pub fn tree(metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons) -> St } } - for cell in inner_display_tree(metas, flags, colors, icons, (0, ""), &padding_rules, index) { + for cell in inner_display_tree( + metas, + flags, + colors, + icons, + git_theme, + (0, ""), + &padding_rules, + index, + ) { grid.add(cell); } grid.fit_into_columns(flags.blocks.0.len()).to_string() } +#[allow(clippy::too_many_arguments)] // should wrap flags, colors, icons, git_theme into one struct fn inner_display_grid( display_option: &DisplayOption, metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons, + git_theme: &GitTheme, depth: usize, term_width: Option, ) -> String { @@ -93,6 +119,7 @@ fn inner_display_grid( meta, colors, icons, + git_theme, flags, display_option, &padding_rules, @@ -152,6 +179,7 @@ fn inner_display_grid( flags, colors, icons, + git_theme, depth + 1, term_width, ); @@ -192,11 +220,13 @@ fn add_header(flags: &Flags, cells: &[Cell], grid: &mut Grid) { } } +#[allow(clippy::too_many_arguments)] fn inner_display_tree( metas: &[Meta], flags: &Flags, colors: &Colors, icons: &Icons, + git_theme: &GitTheme, tree_depth_prefix: (usize, &str), padding_rules: &HashMap, tree_index: usize, @@ -220,6 +250,7 @@ fn inner_display_tree( meta, colors, icons, + git_theme, flags, &DisplayOption::FileName, padding_rules, @@ -248,6 +279,7 @@ fn inner_display_tree( flags, colors, icons, + git_theme, (tree_depth_prefix.0 + 1, &new_prefix), padding_rules, tree_index, @@ -279,10 +311,12 @@ fn display_folder_path(meta: &Meta) -> String { format!("\n{}:\n", meta.path.to_string_lossy()) } +#[allow(clippy::too_many_arguments)] fn get_output( meta: &Meta, colors: &Colors, icons: &Icons, + git_theme: &GitTheme, flags: &Flags, display_option: &DisplayOption, padding_rules: &HashMap, @@ -366,6 +400,11 @@ fn get_output( block_vec.push(meta.symlink.render(colors, flags)) } } + Block::GitStatus => { + if let Some(_s) = &meta.git_status { + block_vec.push(_s.render(colors, git_theme)); + } + } }; strings.push( block_vec @@ -457,6 +496,7 @@ mod tests { use assert_fs::prelude::*; use clap::Parser; use std::path::Path; + use tempfile::tempdir; #[test] fn test_display_get_visible_width_without_icons() { @@ -559,8 +599,7 @@ mod tests { // check if the color is present. assert!( output.starts_with("\u{1b}[38;5;"), - "{:?} should start with color", - output, + "{output:?} should start with color" ); assert!(output.ends_with("[39m"), "reset foreground color"); @@ -646,7 +685,7 @@ mod tests { dir.child("one.d/.hidden").touch().unwrap(); let mut metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(42, &flags) + .recurse_into(42, &flags, None) .unwrap() .0 .unwrap(); @@ -656,6 +695,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); assert_eq!("one.d\n├── .hidden\n└── two\n", output); @@ -678,7 +718,7 @@ mod tests { dir.child("dir/file").touch().unwrap(); let metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(42, &flags) + .recurse_into(42, &flags, None) .unwrap() .0 .unwrap(); @@ -687,6 +727,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); let length_before_b = |i| -> usize { @@ -718,7 +759,7 @@ mod tests { dir.child("dir/file").touch().unwrap(); let metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(42, &flags) + .recurse_into(42, &flags, None) .unwrap() .0 .unwrap(); @@ -727,6 +768,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); assert_eq!(output.lines().nth(1).unwrap().chars().next().unwrap(), '└'); @@ -757,7 +799,7 @@ mod tests { dir.child("one.d/two").touch().unwrap(); let metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(42, &flags) + .recurse_into(42, &flags, None) .unwrap() .0 .unwrap(); @@ -766,6 +808,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); assert!(output.ends_with("└── two\n")); @@ -787,7 +830,7 @@ mod tests { dir.child("test").touch().unwrap(); let metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(1, &flags) + .recurse_into(1, &flags, None) .unwrap() .0 .unwrap(); @@ -796,6 +839,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); dir.close().unwrap(); @@ -820,7 +864,7 @@ mod tests { dir.child("testdir").create_dir_all().unwrap(); let metas = Meta::from_path(Path::new(dir.path()), false) .unwrap() - .recurse_into(1, &flags) + .recurse_into(1, &flags, None) .unwrap() .0 .unwrap(); @@ -829,6 +873,7 @@ mod tests { &flags, &Colors::new(color::ThemeOption::NoColor), &Icons::new(false, IconOption::Never, FlagTheme::Fancy, " ".to_string()), + &GitTheme::new(), ); dir.close().unwrap(); @@ -840,4 +885,115 @@ mod tests { assert!(!output.contains("Date Modified")); assert!(!output.contains("Name")); } + + #[test] + fn test_folder_path() { + let tmp_dir = tempdir().expect("failed to create temp dir"); + + let file_path = tmp_dir.path().join("file"); + std::fs::File::create(&file_path).expect("failed to create the file"); + let file = Meta::from_path(&file_path, false).unwrap(); + + let dir_path = tmp_dir.path().join("dir"); + std::fs::create_dir(&dir_path).expect("failed to create the dir"); + let dir = Meta::from_path(&dir_path, false).unwrap(); + + assert_eq!( + display_folder_path(&dir), + format!( + "\n{}{}dir:\n", + tmp_dir.path().to_string_lossy(), + std::path::MAIN_SEPARATOR + ) + ); + + const YES: bool = true; + const NO: bool = false; + + assert_eq!( + should_display_folder_path(0, &[file.clone()], &Flags::default()), + YES // doesn't matter since there is no folder + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone()], &Flags::default()), + NO + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), dir.clone()], &Flags::default()), + YES + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone(), dir.clone()], &Flags::default()), + YES + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), file.clone()], &Flags::default()), + YES // doesn't matter since there is no folder + ); + + drop(dir); // to avoid clippy complains about previous .clone() + drop(file); + } + + #[cfg(unix)] + #[test] + fn test_folder_path_with_links() { + let tmp_dir = tempdir().expect("failed to create temp dir"); + + let file_path = tmp_dir.path().join("file"); + std::fs::File::create(&file_path).expect("failed to create the file"); + let file = Meta::from_path(&file_path, false).unwrap(); + + let dir_path = tmp_dir.path().join("dir"); + std::fs::create_dir(&dir_path).expect("failed to create the dir"); + let dir = Meta::from_path(&dir_path, false).unwrap(); + + let link_path = tmp_dir.path().join("link"); + std::os::unix::fs::symlink("dir", &link_path).unwrap(); + let link = Meta::from_path(&link_path, false).unwrap(); + + let grid_flags = Flags { + layout: Layout::Grid, + ..Flags::default() + }; + + let oneline_flags = Flags { + layout: Layout::OneLine, + ..Flags::default() + }; + + const YES: bool = true; + const NO: bool = false; + + assert_eq!( + should_display_folder_path(0, &[link.clone()], &grid_flags), + NO + ); + assert_eq!( + should_display_folder_path(0, &[link.clone()], &oneline_flags), + YES // doesn't matter since this link will be expanded as a directory + ); + + assert_eq!( + should_display_folder_path(0, &[file.clone(), link.clone()], &grid_flags), + YES + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), link.clone()], &oneline_flags), + YES // doesn't matter since this link will be expanded as a directory + ); + + assert_eq!( + should_display_folder_path(0, &[dir.clone(), link.clone()], &grid_flags), + YES + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone(), link.clone()], &oneline_flags), + YES + ); + + drop(dir); // to avoid clippy complains about previous .clone() + drop(file); + drop(link); + } } diff --git a/src/flags.rs b/src/flags.rs index 1afe913e4..a73ac6534 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -17,7 +17,6 @@ pub mod symlink_arrow; pub mod symlinks; pub mod total_size; -pub use blocks::Block; pub use blocks::Blocks; pub use color::Color; pub use color::{ColorOption, ThemeOption}; diff --git a/src/flags/blocks.rs b/src/flags/blocks.rs index d51834a79..0ae53c71c 100644 --- a/src/flags/blocks.rs +++ b/src/flags/blocks.rs @@ -66,6 +66,28 @@ impl Blocks { None => self.0.insert(0, Block::Context), } } + + /// Checks whether `self` already contains a [Block] of variant [GitStatus](Block::GitSatus). + fn contains_git_status(&self) -> bool { + self.0.contains(&Block::GitStatus) + } + + /// Put a [Block] of variant [GitStatus](Block::GitSatus) on the left of [GitStatus](Block::Name) to `self`. + fn add_git_status(&mut self) { + if let Some(position) = self.0.iter().position(|&b| b == Block::Name) { + self.0.insert(position, Block::GitStatus); + } else { + self.0.push(Block::GitStatus); + } + } + + /// Prepends a [Block] of variant [GitStatus](Block::GitSatus), if `self` does not already contain a + /// Block of that variant. + fn optional_add_git_status(&mut self) { + if !self.contains_git_status() { + self.add_git_status() + } + } } impl Configurable for Blocks { @@ -103,6 +125,10 @@ impl Configurable for Blocks { blocks.optional_prepend_inode(); } + if !cfg!(feature = "no-git") && cli.git && cli.long { + blocks.optional_add_git_status(); + } + blocks } @@ -168,6 +194,7 @@ pub enum Block { Name, INode, Links, + GitStatus, } impl Block { @@ -183,6 +210,7 @@ impl Block { Block::SizeValue => "SizeValue", Block::Date => "Date Modified", Block::Name => "Name", + Block::GitStatus => "Git", } } } @@ -202,6 +230,7 @@ impl TryFrom<&str> for Block { "name" => Ok(Self::Name), "inode" => Ok(Self::INode), "links" => Ok(Self::Links), + "git" => Ok(Self::GitStatus), _ => Err(format!("Not a valid block name: {string}")), } } @@ -371,6 +400,30 @@ mod test_blocks { assert_eq!(Blocks::from_cli(&cli), Some(test_blocks)); } + #[cfg(not(feature = "no-git"))] + #[test] + fn test_from_cli_implicit_add_git_block() { + let argv = vec![ + "lsd", + "--blocks", + "permission,name,group,date", + "--git", + "--long", + ]; + let cli = Cli::try_parse_from(argv).unwrap(); + let test_blocks = Blocks(vec![ + Block::Permission, + Block::GitStatus, + Block::Name, + Block::Group, + Block::Date, + ]); + assert_eq!( + Blocks::configure_from(&cli, &Config::with_none()), + test_blocks + ); + } + #[test] fn test_from_config_none() { assert_eq!(None, Blocks::from_config(&Config::with_none())); @@ -541,5 +594,11 @@ mod test_block { assert_eq!(Block::SizeValue.get_header(), "SizeValue"); assert_eq!(Block::Date.get_header(), "Date Modified"); assert_eq!(Block::Name.get_header(), "Name"); + assert_eq!(Block::GitStatus.get_header(), "Git"); + } + + #[test] + fn test_git_status() { + assert_eq!(Ok(Block::GitStatus), Block::try_from("git")); } } diff --git a/src/flags/sorting.rs b/src/flags/sorting.rs index 07b6b09d9..3e895933d 100644 --- a/src/flags/sorting.rs +++ b/src/flags/sorting.rs @@ -44,6 +44,7 @@ pub enum SortColumn { Time, Size, Version, + GitStatus, } impl Configurable for SortColumn { @@ -62,6 +63,8 @@ impl Configurable for SortColumn { Some(Self::Extension) } else if cli.versionsort || sort == Some("version") { Some(Self::Version) + } else if cli.gitsort || sort == Some("git") { + Some(Self::GitStatus) } else if cli.no_sort || sort == Some("none") { Some(Self::None) } else { @@ -212,6 +215,13 @@ mod test_sort_column { assert_eq!(Some(SortColumn::Size), SortColumn::from_cli(&cli)); } + #[test] + fn test_from_cli_git() { + let argv = ["lsd", "--gitsort"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_cli(&cli)); + } + #[test] fn test_from_cli_version() { let argv = ["lsd", "--versionsort"]; @@ -249,6 +259,14 @@ mod test_sort_column { assert_eq!(Some(SortColumn::None), SortColumn::from_cli(&cli)); } + #[cfg(not(feature = "no-git"))] + #[test] + fn test_from_arg_cli_sort_git() { + let argv = ["lsd", "--sort", "git"]; + let cli = Cli::try_parse_from(argv).unwrap(); + assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_cli(&cli)); + } + #[test] fn test_multi_sort() { let argv = ["lsd", "--sort", "size", "--sort", "time"]; @@ -334,6 +352,17 @@ mod test_sort_column { }); assert_eq!(Some(SortColumn::Version), SortColumn::from_config(&c)); } + + #[test] + fn test_from_config_git_status() { + let mut c = Config::with_none(); + c.sorting = Some(Sorting { + column: Some(SortColumn::GitStatus), + reverse: None, + dir_grouping: None, + }); + assert_eq!(Some(SortColumn::GitStatus), SortColumn::from_config(&c)); + } } #[cfg(test)] diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 000000000..197e10cca --- /dev/null +++ b/src/git.rs @@ -0,0 +1,460 @@ +use crate::meta::git_file_status::GitFileStatus; +use std::path::{Path, PathBuf}; + +#[allow(dead_code)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub enum GitStatus { + /// No status info + #[default] + Default, + /// No changes (got from git status) + Unmodified, + /// Entry is ignored item in workdir + Ignored, + /// Entry does not exist in old version (now in stage) + NewInIndex, + /// Entry does not exist in old version (not in stage) + NewInWorkdir, + /// Type of entry changed between old and new + Typechange, + /// Entry does not exist in new version + Deleted, + /// Entry was renamed between old and new + Renamed, + /// Entry content changed between old and new + Modified, + /// Entry in the index is conflicted + Conflicted, +} + +pub struct GitCache { + #[cfg(not(feature = "no-git"))] + statuses: Vec<(PathBuf, git2::Status)>, +} + +#[cfg(feature = "no-git")] +impl GitCache { + pub fn new(_: &Path) -> Self { + Self {} + } + + pub fn get(&self, _filepath: &PathBuf, _is_directory: bool) -> Option { + None + } +} + +#[cfg(not(feature = "no-git"))] +impl GitCache { + pub fn new(path: &Path) -> GitCache { + let repo = match git2::Repository::discover(path) { + Ok(r) => r, + Err(_e) => { + // Unable to retrieve Git info; it doesn't seem to be a git directory + return Self::empty(); + } + }; + + if let Some(workdir) = repo.workdir().and_then(|x| std::fs::canonicalize(x).ok()) { + let mut statuses = Vec::new(); + // Retrieving Git statuses for workdir + match repo.statuses(None) { + Ok(status_list) => { + for status_entry in status_list.iter() { + // git2-rs provides / separated path even on Windows. We have to rebuild it + let str_path = status_entry.path().unwrap(); + let path: PathBuf = + str_path.split('/').collect::>().iter().collect(); + let path = workdir.join(path); + let elem = (path, status_entry.status()); + statuses.push(elem); + } + } + Err(err) => { + crate::print_error!( + "Cannot retrieve Git statuses for directory {:?}: {}", + workdir, + err + ); + } + } + + GitCache { statuses } + } else { + // No workdir + Self::empty() + } + } + + pub fn empty() -> Self { + GitCache { + statuses: Vec::new(), + } + } + + pub fn get(&self, filepath: &PathBuf, is_directory: bool) -> Option { + match std::fs::canonicalize(filepath) { + Ok(filename) => Some(self.inner_get(&filename, is_directory)), + Err(err) => { + crate::print_error!("Cannot get git status for {:?}: {}", filepath, err); + None + } + } + } + + fn inner_get(&self, filepath: &PathBuf, is_directory: bool) -> GitFileStatus { + if is_directory { + self.statuses + .iter() + .filter(|&x| x.0.starts_with(filepath)) + .map(|x| GitFileStatus::new(x.1)) + .fold(GitFileStatus::default(), |acc, x| GitFileStatus { + index: std::cmp::max(acc.index, x.index), + workdir: std::cmp::max(acc.workdir, x.workdir), + }) + } else { + self.statuses + .iter() + .find(|&x| filepath == &x.0) + .map(|e| GitFileStatus::new(e.1)) + .unwrap_or_default() + } + } +} + +#[cfg(not(feature = "no-git"))] +#[cfg(test)] +mod tests { + use super::*; + use assert_fs::prelude::*; + use assert_fs::TempDir; + use git2::build::CheckoutBuilder; + use git2::{CherrypickOptions, Index, Oid, Repository, RepositoryInitOptions}; + use std::collections::HashMap; + use std::fs::remove_file; + #[allow(unused)] + use std::process::Command; + + #[test] + fn compare_git_status() { + assert!(GitStatus::Unmodified < GitStatus::Conflicted); + } + + macro_rules! t { + ($e:expr) => { + match $e { + Ok(e) => e, + Err(e) => panic!("{} failed with {}", stringify!($e), e), + } + }; + } + + fn repo_init() -> (TempDir, Repository) { + let td = t!(TempDir::new()); + let mut opts = RepositoryInitOptions::new(); + opts.initial_head("master"); + let repo = Repository::init_opts(td.path(), &opts).unwrap(); + { + let mut config = t!(repo.config()); + t!(config.set_str("user.name", "name")); + t!(config.set_str("user.email", "email")); + let mut index = t!(repo.index()); + let id = t!(index.write_tree()); + let tree = t!(repo.find_tree(id)); + let sig = t!(repo.signature()); + t!(repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])); + } + (td, repo) + } + + fn commit(repo: &Repository, index: &mut Index, msg: &str) -> (Oid, Oid) { + let tree_id = t!(index.write_tree()); + let tree = t!(repo.find_tree(tree_id)); + let sig = t!(repo.signature()); + let head_id = t!(repo.refname_to_id("HEAD")); + let parent = t!(repo.find_commit(head_id)); + let commit = t!(repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent])); + (commit, tree_id) + } + + fn check_cache(root: &Path, statuses: &HashMap<&PathBuf, GitFileStatus>, msg: &str) { + let cache = GitCache::new(root); + for (&path, status) in statuses.iter() { + if let Ok(filename) = std::fs::canonicalize(&root.join(path)) { + let is_directory = filename.is_dir(); + assert_eq!( + &cache.inner_get(&filename, is_directory), + status, + "Invalid status for file {} at stage {}", + filename.to_string_lossy(), + msg + ); + } + } + } + + #[test] + fn test_git_workflow() { + // rename as test_git_workflow + let (root, repo) = repo_init(); + let mut index = repo.index().unwrap(); + let mut expected_statuses = HashMap::new(); + + // Check now + check_cache(root.path(), &expected_statuses, "initialization"); + + let f0 = PathBuf::from(".gitignore"); + root.child(&f0).write_str("*.bak").unwrap(); + expected_statuses.insert( + &f0, + GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::NewInWorkdir, + }, + ); + + let _success = Command::new("git") + .current_dir(root.path()) + .arg("status") + .status() + .expect("Git status failed") + .success(); + + // Check now + check_cache(root.path(), &expected_statuses, "new .gitignore"); + + index.add_path(f0.as_path()).unwrap(); + + // Check now + check_cache(root.path(), &expected_statuses, "unstaged .gitignore"); + + index.write().unwrap(); + *expected_statuses.get_mut(&f0).unwrap() = GitFileStatus { + index: GitStatus::NewInIndex, + workdir: GitStatus::Unmodified, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "staged .gitignore"); + + commit(&repo, &mut index, "Add gitignore"); + *expected_statuses.get_mut(&f0).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Committed .gitignore"); + + let d1 = PathBuf::from("d1"); + let f1 = d1.join("f1"); + root.child(&f1).touch().unwrap(); + let f2 = d1.join("f2.bak"); + root.child(&f2).touch().unwrap(); + expected_statuses.insert( + &d1, + GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::NewInWorkdir, + }, + ); + expected_statuses.insert( + &f1, + GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::NewInWorkdir, + }, + ); + expected_statuses.insert( + &f2, + GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Ignored, + }, + ); + + // Check now + check_cache(root.path(), &expected_statuses, "New files"); + + index.add_path(f1.as_path()).unwrap(); + index.write().unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::NewInIndex, + workdir: GitStatus::Ignored, + }; + *expected_statuses.get_mut(&f1).unwrap() = GitFileStatus { + index: GitStatus::NewInIndex, + workdir: GitStatus::Unmodified, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Unstaged new files"); + + index.add_path(f2.as_path()).unwrap(); + index.write().unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::NewInIndex, + workdir: GitStatus::Unmodified, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::NewInIndex, + workdir: GitStatus::Unmodified, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Staged new files"); + + let (commit1_oid, _) = commit(&repo, &mut index, "Add new files"); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + *expected_statuses.get_mut(&f1).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Committed new files"); + + remove_file(root.child(&f2).path()).unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Deleted, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Deleted, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Remove file"); + + root.child(&f1).write_str("New content").unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Modified, + }; // more important to see modified vs deleted ? + *expected_statuses.get_mut(&f1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Modified, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Change file"); + + index.remove_path(&f2).unwrap(); + index.write().unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Deleted, + workdir: GitStatus::Modified, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::Deleted, + workdir: GitStatus::Unmodified, + }; + + // Check now + check_cache(root.path(), &expected_statuses, "Staged changes"); + + commit(&repo, &mut index, "Remove backup file"); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Modified, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + + // Check now + check_cache( + root.path(), + &expected_statuses, + "Committed changes (first part)", + ); + + index.add_path(&f1).unwrap(); + index.write().unwrap(); + commit(&repo, &mut index, "Save modified file"); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + *expected_statuses.get_mut(&f1).unwrap() = GitFileStatus { + index: GitStatus::Default, + workdir: GitStatus::Default, + }; + + // Check now + check_cache( + root.path(), + &expected_statuses, + "Committed changes (second part)", + ); + + let branch_commit = repo.find_commit(commit1_oid).unwrap(); + let branch = repo + .branch("conflict-branch", &branch_commit, true) + .unwrap(); + repo.set_head(format!("refs/heads/{}", branch.name().unwrap().unwrap()).as_str()) + .unwrap(); + let mut checkout_opts = CheckoutBuilder::new(); + checkout_opts.force(); + repo.checkout_head(Some(&mut checkout_opts)).unwrap(); + + root.child(&f1) + .write_str("New conflicting content") + .unwrap(); + root.child(&f2) + .write_str("New conflicting content") + .unwrap(); + index.add_path(&f1).unwrap(); + index.add_path(&f2).unwrap(); + index.write().unwrap(); + let (commit2_oid, _) = commit(&repo, &mut index, "Save conflicting changes"); + + // Check now + check_cache( + root.path(), + &expected_statuses, + "Committed changes in branch", + ); + + repo.set_head("refs/heads/master").unwrap(); + repo.checkout_head(Some(&mut checkout_opts)).unwrap(); + let mut cherrypick_opts = CherrypickOptions::new(); + let branch_commit = repo.find_commit(commit2_oid).unwrap(); + repo.cherrypick(&branch_commit, Some(&mut cherrypick_opts)) + .unwrap(); + *expected_statuses.get_mut(&d1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Conflicted, + }; + *expected_statuses.get_mut(&f1).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Conflicted, + }; + *expected_statuses.get_mut(&f2).unwrap() = GitFileStatus { + index: GitStatus::Unmodified, + workdir: GitStatus::Conflicted, + }; + + // let _success = Command::new("git") + // .current_dir(root.path()) + // .arg("status") + // .status() + // .expect("Git status failed") + // .success(); + + // Check now + check_cache( + root.path(), + &expected_statuses, + "Conflict between master and branch", + ); + } +} diff --git a/src/git_theme.rs b/src/git_theme.rs new file mode 100644 index 000000000..27bd965e6 --- /dev/null +++ b/src/git_theme.rs @@ -0,0 +1,31 @@ +use crate::git::GitStatus; +use crate::theme::git::GitThemeSymbols; + +pub struct GitTheme { + symbols: GitThemeSymbols, +} + +impl GitTheme { + pub fn new() -> GitTheme { + let git_symbols = GitThemeSymbols::default(); + Self { + symbols: git_symbols, + } + } + + pub fn get_symbol(&self, status: &GitStatus) -> String { + let symbol = match status { + GitStatus::Default => &self.symbols.default, + GitStatus::Unmodified => &self.symbols.unmodified, + GitStatus::Ignored => &self.symbols.ignored, + GitStatus::NewInIndex => &self.symbols.new_in_index, + GitStatus::NewInWorkdir => &self.symbols.new_in_workdir, + GitStatus::Typechange => &self.symbols.typechange, + GitStatus::Deleted => &self.symbols.deleted, + GitStatus::Renamed => &self.symbols.renamed, + GitStatus::Modified => &self.symbols.modified, + GitStatus::Conflicted => &self.symbols.conflicted, + }; + symbol.to_string() + } +} diff --git a/src/icon.rs b/src/icon.rs index a3eb6bdc9..3d1e32a48 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -224,7 +224,7 @@ mod test { let tmp_dir = tempdir().expect("failed to create temp dir"); for (ext, file_icon) in &IconTheme::get_default_icons_by_extension() { - let file_path = tmp_dir.path().join(format!("file.{}", ext)); + let file_path = tmp_dir.path().join(format!("file.{ext}")); File::create(&file_path).expect("failed to create file"); let meta = Meta::from_path(&file_path, false).unwrap(); diff --git a/src/main.rs b/src/main.rs index 2b5097f9e..1f7dd227c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,8 @@ mod config_file; mod core; mod display; mod flags; +mod git; +mod git_theme; mod icon; mod meta; mod sort; diff --git a/src/meta/git_file_status.rs b/src/meta/git_file_status.rs new file mode 100644 index 000000000..160517d9b --- /dev/null +++ b/src/meta/git_file_status.rs @@ -0,0 +1,67 @@ +use crate::color::{self, ColoredString, Colors}; +use crate::git::GitStatus; +use crate::git_theme::GitTheme; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GitFileStatus { + pub index: GitStatus, + pub workdir: GitStatus, +} + +impl Default for GitFileStatus { + fn default() -> Self { + Self { + index: GitStatus::Default, + workdir: GitStatus::Default, + } + } +} + +impl GitFileStatus { + #[cfg(not(feature = "no-git"))] + pub fn new(status: git2::Status) -> Self { + Self { + index: match status { + s if s.contains(git2::Status::INDEX_NEW) => GitStatus::NewInIndex, + s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Deleted, + s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified, + s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed, + s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::Typechange, + _ => GitStatus::Unmodified, + }, + + workdir: match status { + s if s.contains(git2::Status::WT_NEW) => GitStatus::NewInWorkdir, + s if s.contains(git2::Status::WT_DELETED) => GitStatus::Deleted, + s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified, + s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed, + s if s.contains(git2::Status::IGNORED) => GitStatus::Ignored, + s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::Typechange, + s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflicted, + _ => GitStatus::Unmodified, + }, + } + } + + pub fn render(&self, colors: &Colors, git_theme: &GitTheme) -> ColoredString { + let res = [ + colors.colorize( + git_theme.get_symbol(&self.index), + &color::Elem::GitStatus { status: self.index }, + ), + colors.colorize( + git_theme.get_symbol(&self.workdir), + &color::Elem::GitStatus { + status: self.workdir, + }, + ), + ] + .into_iter() + // From the experiment, the maximum string size is 153 bytes + .fold(String::with_capacity(160), |mut acc, x| { + acc.push_str(&x.to_string()); + acc + }); + ColoredString::new(Colors::default_style(), res) + } +} diff --git a/src/meta/mod.rs b/src/meta/mod.rs index 697cecb5c..3d1e2f4dc 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -1,6 +1,7 @@ mod access_control; mod date; mod filetype; +pub mod git_file_status; mod indicator; mod inode; mod links; @@ -17,6 +18,7 @@ mod windows_utils; pub use self::access_control::AccessControl; pub use self::date::Date; pub use self::filetype::FileType; +pub use self::git_file_status::GitFileStatus; pub use self::indicator::Indicator; pub use self::inode::INode; pub use self::links::Links; @@ -30,6 +32,7 @@ pub use crate::icon::Icons; use crate::flags::{Display, Flags, Layout}; use crate::{print_error, ExitCode}; +use crate::git::GitCache; use std::io::{self, Error, ErrorKind}; use std::path::{Component, Path, PathBuf}; @@ -48,6 +51,7 @@ pub struct Meta { pub links: Option, pub content: Option>, pub access_control: Option, + pub git_status: Option, } impl Meta { @@ -55,6 +59,7 @@ impl Meta { &self, depth: usize, flags: &Flags, + cache: Option<&GitCache>, ) -> io::Result<(Option>, ExitCode)> { if depth == 0 { return Ok((None, ExitCode::OK)); @@ -94,6 +99,9 @@ impl Meta { Self::from_path(&self.path.join(Component::ParentDir), flags.dereference.0)?; parent_meta.name.name = "..".to_owned(); + current_meta.git_status = cache.and_then(|cache| cache.get(¤t_meta.path, true)); + parent_meta.git_status = cache.and_then(|cache| cache.get(&parent_meta.path, true)); + content.push(current_meta); content.push(parent_meta); } @@ -150,7 +158,7 @@ impl Meta { // check dereferencing if flags.dereference.0 || !matches!(entry_meta.file_type, FileType::SymLink { .. }) { - match entry_meta.recurse_into(depth - 1, flags) { + match entry_meta.recurse_into(depth - 1, flags, cache) { Ok((content, rec_exit_code)) => { entry_meta.content = content; exit_code.set_if_greater(rec_exit_code); @@ -163,6 +171,9 @@ impl Meta { }; } + let is_directory = entry.file_type()?.is_dir(); + entry_meta.git_status = + cache.and_then(|cache| cache.get(&entry_meta.path, is_directory)); content.push(entry_meta); } @@ -300,6 +311,7 @@ impl Meta { file_type, content: None, access_control, + git_status: None, }) } } diff --git a/src/meta/name.rs b/src/meta/name.rs index 53c044e2b..fbd4e1730 100644 --- a/src/meta/name.rs +++ b/src/meta/name.rs @@ -120,7 +120,7 @@ impl Name { if let Ok(url) = Url::from_file_path(rp) { // Crossterm does not support hyperlinks as of now // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda - format!("\x1B]8;;{}\x1B\x5C{}\x1B]8;;\x1B\x5C", url, name) + format!("\x1B]8;;{url}\x1B\x5C{name}\x1B]8;;\x1B\x5C") } else { print_error!("{}: unable to form url.", name); name diff --git a/src/sort.rs b/src/sort.rs index 87092830f..d2a1dfa09 100644 --- a/src/sort.rs +++ b/src/sort.rs @@ -23,6 +23,7 @@ pub fn assemble_sorters(flags: &Flags) -> Vec<(SortOrder, SortFn)> { SortColumn::Time => sorters.push((flags.sorting.order, by_date)), SortColumn::Version => sorters.push((flags.sorting.order, by_version)), SortColumn::Extension => sorters.push((flags.sorting.order, by_extension)), + SortColumn::GitStatus => sorters.push((flags.sorting.order, by_git_status)), SortColumn::None => {} } sorters @@ -72,6 +73,10 @@ fn by_extension(a: &Meta, b: &Meta) -> Ordering { a.name.extension().cmp(&b.name.extension()) } +fn by_git_status(a: &Meta, b: &Meta) -> Ordering { + a.git_status.cmp(&b.git_status) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/theme.rs b/src/theme.rs index 4ff78bf00..f18b33106 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,5 @@ pub mod color; +pub mod git; pub mod icon; use std::path::Path; @@ -11,6 +12,7 @@ use crate::config_file; use crate::print_error; use color::ColorTheme; +use git::GitThemeSymbols; use icon::IconTheme; #[derive(Debug, Deserialize, Default, PartialEq, Eq)] @@ -20,6 +22,7 @@ use icon::IconTheme; pub struct Theme { pub color: ColorTheme, pub icon: IconTheme, + pub git_theme: GitThemeSymbols, } #[derive(Error, Debug)] diff --git a/src/theme/color.rs b/src/theme/color.rs index 2176791de..86f84dfcd 100644 --- a/src/theme/color.rs +++ b/src/theme/color.rs @@ -96,6 +96,7 @@ pub struct ColorTheme { #[serde(deserialize_with = "deserialize_color")] pub tree_edge: Color, pub links: Links, + pub git_status: GitStatus, #[serde(skip)] pub file_type: FileType, @@ -233,6 +234,15 @@ pub struct Links { pub invalid: Color, } +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub struct GitStatus { + #[serde(deserialize_with = "deserialize_color")] + pub default: Color, +} + impl Default for Permission { fn default() -> Self { Permission { @@ -324,6 +334,14 @@ impl Default for Links { } } +impl Default for GitStatus { + fn default() -> Self { + GitStatus { + default: Color::AnsiValue(13), // Pink + } + } +} + impl Default for ColorTheme { fn default() -> Self { // TODO(zwpaper): check terminal color and return light or dark @@ -343,6 +361,7 @@ impl ColorTheme { inode: INode::default(), links: Links::default(), tree_edge: Color::AnsiValue(245), // Grey + git_status: Default::default(), } } } diff --git a/src/theme/git.rs b/src/theme/git.rs new file mode 100644 index 000000000..e475e9736 --- /dev/null +++ b/src/theme/git.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +#[serde(default)] +pub struct GitThemeSymbols { + pub default: String, + pub unmodified: String, + pub new_in_index: String, + pub new_in_workdir: String, + pub deleted: String, + pub modified: String, + pub renamed: String, + pub ignored: String, + pub typechange: String, + pub conflicted: String, +} + +impl Default for GitThemeSymbols { + fn default() -> GitThemeSymbols { + GitThemeSymbols { + default: "-".into(), + unmodified: ".".into(), + new_in_index: "N".into(), + new_in_workdir: "?".into(), + deleted: "D".into(), + modified: "M".into(), + renamed: "R".into(), + ignored: "I".into(), + typechange: "T".into(), + conflicted: "C".into(), + } + } +}