diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 7562e6df6..911091004 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -6,7 +6,7 @@ env: PROJECT_NAME: lsd PROJECT_DESC: "An ls command with a lot of pretty colors." PROJECT_AUTH: "Peltoche " - RUST_MIN_SRV: "1.43.1" + RUST_MIN_SRV: "1.45" on: [push, pull_request] diff --git a/.travis.yml b/.travis.yml index 65869eeda..cc14c782a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ matrix: # Stable channel. - os: linux rust: stable - env: TARGET=x86_64-unknown-linux-gnu + env: TARGET=x86_64-unknown-linux-gnu FEATURES=git - os: linux rust: stable env: TARGET=i686-unknown-linux-gnu @@ -19,7 +19,7 @@ matrix: env: TARGET=i686-unknown-linux-musl - os: osx rust: stable - env: TARGET=x86_64-apple-darwin + env: TARGET=x86_64-apple-darwin FEATURES=git - os: linux rust: stable env: @@ -30,13 +30,13 @@ matrix: # The other platforms are disabled in order to reduce the total CI time - os: linux rust: beta - env: TARGET=x86_64-unknown-linux-gnu + env: TARGET=x86_64-unknown-linux-gnu FEATURES=git # Nightly channel. # The other platforms are disabled in order to reduce the total CI time - os: linux rust: nightly - env: TARGET=x86_64-unknown-linux-gnu + env: TARGET=x86_64-unknown-linux-gnu FEATURES=git # Minimum Rust supported channel. - os: linux diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fab7f3a7..cf8b7941b 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] - ReleaseDate ### Added +- Add Git integration [Pascal Havé](https://github.com/hpwxf) [#7](https://github.com/Peltoche/lsd/issues/7) ### Changed - Change size to use btyes in classic mode from [meain](https://github.com/meain) - Show tree edge before name block or first column if no name block from [zwpaper](https://github.com/zwpaper) [#468](https://github.com/Peltoche/lsd/issues/468) diff --git a/Cargo.lock b/Cargo.lock index b18797588..4d3d20083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "aho-corasick" version = "0.7.13" @@ -115,6 +117,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "cc" +version = "1.0.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +dependencies = [ + "jobserver", +] + [[package]] name = "cfg-if" version = "0.1.10" @@ -233,6 +244,16 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + [[package]] name = "getrandom" version = "0.1.15" @@ -244,6 +265,19 @@ dependencies = [ "wasi 0.9.0+wasi-snapshot-preview1", ] +[[package]] +name = "git2" +version = "0.13.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d250f5f82326884bd39c2853577e70a121775db76818ffa452ed1e80de12986" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glob" version = "0.3.0" @@ -273,6 +307,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "heck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "hermit-abi" version = "0.1.16" @@ -288,6 +331,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "140a09c9305e6d5e557e2ed7cbc68e05765a7d4213975b87cb04920689cc6219" +[[package]] +name = "idna" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89829a5d69c23d348314a7ac337fe39173b61149a9864deabd260983aed48c21" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "ignore" version = "0.4.16" @@ -315,6 +369,15 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "jobserver" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71313ebb9439f74b00d9d2dcec36440beaf57a6aa0623068441dd7cd81a7f2" +dependencies = [ + "libc", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -327,6 +390,30 @@ version = "0.2.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f96b10ec2560088a8e76961b00d47107b3a625fecb76dedb29ee7ccbf98235" +[[package]] +name = "libgit2-sys" +version = "0.12.18+1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da6a42da88fc37ee1ecda212ffa254c25713532980005d5f7c0b0fbe7e6e885" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113192b08db8f38796c4e85c39e960c145965140e918018bcde1952429655" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.3" @@ -371,6 +458,7 @@ dependencies = [ "chrono-humanize", "clap", "dirs", + "git2", "globset", "human-sort", "libc", @@ -379,6 +467,7 @@ dependencies = [ "serde", "serde_yaml", "serial_test", + "strum", "tempfile", "term_grid", "terminal_size", @@ -391,6 +480,12 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + [[package]] name = "memchr" version = "2.3.3" @@ -447,6 +542,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + [[package]] name = "ppv-lite86" version = "0.2.9" @@ -560,21 +667,20 @@ dependencies = [ [[package]] name = "regex" -version = "1.3.9" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3780fcf44b193bc4d09f36d2a3c87b251da4a046c87795a0d35f4f927ad8e6" +checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19" dependencies = [ "aho-corasick", "memchr", "regex-syntax", - "thread_local", ] [[package]] name = "regex-syntax" -version = "0.6.18" +version = "0.6.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26412eb97c6b088a6997e05f69403a802a92d520de2f8e63c2b65f9e0f47c4e8" +checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" [[package]] name = "remove_dir_all" @@ -678,6 +784,27 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "strum" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7318c509b5ba57f18533982607f24070a55d353e90d4cae30c467cdb2ad5ac5c" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8bc6b87a5112aeeab1f4a9f7ab634fe6cbefc4850006df31267f4cfb9e3149" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "1.0.48" @@ -762,12 +889,51 @@ dependencies = [ "winapi", ] +[[package]] +name = "tinyvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317cca572a0e89c3ce0ca1f1bdc9369547fe318a683418e42ac8f59d14701023" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + [[package]] name = "treeline" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" +[[package]] +name = "unicode-bidi" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + [[package]] name = "unicode-width" version = "0.1.8" @@ -780,6 +946,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +[[package]] +name = "url" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + [[package]] name = "users" version = "0.11.0" @@ -790,6 +968,12 @@ dependencies = [ "log", ] +[[package]] +name = "vcpkg" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index eb33d3643..781b63923 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,12 +36,16 @@ xdg = "2.1.*" yaml-rust = "0.4.*" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.8" +strum = { version = "0.20", features = ["derive"] } [target.'cfg(unix)'.dependencies] users = "0.11.*" [target.'cfg(windows)'.dependencies] -winapi = {version = "0.3.*", features = ["aclapi", "accctrl", "winnt", "winerror", "securitybaseapi", "winbase"]} +winapi = { version = "0.3.*", features = ["aclapi", "accctrl", "winnt", "winerror", "securitybaseapi", "winbase"] } + +[target."cfg(not(any(all(target_os = \"linux\", target_arch = \"arm\"), all(windows, target_arch = \"x86\", target_env = \"gnu\"))))".dependencies] +git2 = { version = "0.13", optional = true, default-features = false } [dependencies.clap] features = ["suggestions", "color", "wrap_help"] @@ -55,4 +59,6 @@ tempfile = "3" serial_test = "0.5" [features] +default = ["git"] sudo = [] +git = ["git2"] 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 19ff79810..3af0cc59a 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 @@ -87,7 +90,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] @@ -114,7 +117,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] # ARGS diff --git a/src/app.rs b/src/app.rs index a262f1a21..83953994f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ use clap::{App, Arg}; pub fn build() -> App<'static, 'static> { - App::new("lsd") + let app = App::new("lsd") .version(crate_version!()) .about(crate_description!()) .arg(Arg::with_name("FILE").multiple(true).default_value(".")) @@ -192,7 +192,14 @@ pub fn build() -> App<'static, 'static> { Arg::with_name("sort") .long("sort") .multiple(true) - .possible_values(&["size", "time", "version", "extension"]) + .possible_values(&[ + "size", + "time", + "version", + "extension", + #[cfg(not(any(all(target_os = "linux", target_arch = "arm"),all(windows, target_arch = "x86", target_env = "gnu"))))] + "git", + ]) .takes_value(true) .value_name("WORD") .overrides_with("timesort") @@ -234,12 +241,14 @@ pub fn build() -> App<'static, 'static> { "name", "inode", "links", + #[cfg(all(feature="git", not(any(all(target_os = "linux", target_arch = "arm"), all(windows, target_arch = "x86", target_env = "gnu")))))] + "git", ]) .help("Specify the blocks that will be displayed and in what order"), ) .arg( Arg::with_name("classic") - .long("classic") + .long("classic") .help("Enable classic mode (display output similar to ls)"), ) .arg( @@ -271,7 +280,17 @@ pub fn build() -> App<'static, 'static> { .long("dereference") .multiple(true) .help("When showing file information for a symbolic link, show information for the file the link references rather than for the link itself"), + ); + if cfg!(feature = "git") { + app.arg( + Arg::with_name("git") + .long("git") + .multiple(true) + .help("Show git status on file and directory"), ) + } else { + app + } } fn validate_date_argument(arg: String) -> Result<(), String> { diff --git a/src/color.rs b/src/color.rs index 5670e334f..6697febc6 100644 --- a/src/color.rs +++ b/src/color.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::path::Path; #[allow(dead_code)] +#[derive(strum::EnumIter)] // for tests #[derive(Hash, Debug, Eq, PartialEq, Clone)] pub enum Elem { /// Node type @@ -53,7 +54,17 @@ pub enum Elem { valid: bool, }, - TreeEdge, + // TreeEdge, // TODO enable it as we enable a theme for it + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + GitStatus { + status: crate::git::GitStatus, + }, } impl Elem { @@ -258,6 +269,107 @@ impl Colors { // TODO add this after we can use file to configure theme // m.insert(Elem::TreeEdge, Colour::Fixed(44)); // DarkTurquoise + + // GitStatus + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + { + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Default, + }, + Colour::White, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Unmodified, + }, + Colour::White, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Ignored, + }, + Colour::Fixed(245), + ); // Grey + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::NewInIndex, + }, + Colour::Green, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::NewInWorkdir, + }, + Colour::White, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Typechange, + }, + Colour::White, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Deleted, + }, + Colour::Red, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Renamed, + }, + Colour::Fixed(172), + ); // Orange3 + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Modified, + }, + Colour::Blue, + ); + m.insert( + Elem::GitStatus { + status: crate::git::GitStatus::Conflicted, + }, + Colour::Red, + ); + } m } } + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + + #[test] + fn test_elem_map_completeness() { + let m = Colors::get_light_theme_colour_map(); + for elem in Elem::iter() { + assert!(m.contains_key(&elem)); + } + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_git_status_map_completeness() { + let m = Colors::get_light_theme_colour_map(); + for status in crate::git::GitStatus::iter() { + let elem = Elem::GitStatus { status }; + assert!(m.contains_key(&elem)); + } + } +} diff --git a/src/config_file.rs b/src/config_file.rs index c7c1b3d5c..28fba0fb8 100644 --- a/src/config_file.rs +++ b/src/config_file.rs @@ -170,7 +170,7 @@ classic: false # == Blocks == # This specifies the columns and their order when using the long and the tree # layout. -# Possible values: permission, user, group, size, size_value, date, name, inode +# Possible values: permission, user, group, size, size_value, date, name, inode, git blocks: - permission - user diff --git a/src/core.rs b/src/core.rs index 07ce931e8..00149e83a 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,6 +1,21 @@ use crate::color::{self, Colors}; use crate::display; -use crate::flags::{ColorOption, Display, Flags, IconOption, IconTheme, Layout, SortOrder}; +use crate::flags::{Block, ColorOption, Display, Flags, IconOption, IconTheme, Layout, SortOrder}; +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +use crate::git::GitCache; +#[cfg(any( + not(feature = "git"), + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") +))] +use crate::git_stub::GitCache; + use crate::icon::{self, Icons}; use crate::meta::Meta; use crate::{print_error, print_output, sort}; @@ -24,7 +39,7 @@ pub struct Core { impl Core { pub fn new(flags: Flags) -> Self { - // Check through libc if stdout is a tty. Unix specific so not on windows. + // Check through libc if stdout is a tty. Unix specific so not on Windows. // Determine color output availability (and initialize color output (for Windows 10)) #[cfg(not(target_os = "windows"))] let tty_available = unsafe { libc::isatty(io::stdout().as_raw_fd()) == 1 }; @@ -38,7 +53,7 @@ impl Core { #[cfg(target_os = "windows")] let console_color_ok = ansi_term::enable_ansi_support().is_ok(); - let mut inner_flags = flags.clone(); + let mut inner_flags = flags.clone(); // FIXME not used ? let color_theme = match (tty_available && console_color_ok, flags.color.when) { (_, ColorOption::Never) | (false, ColorOption::Auto) => color::Theme::NoColor, @@ -96,12 +111,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) => { meta.content = content; + meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true)); meta_list.push(meta); } Err(err) => { @@ -110,6 +132,7 @@ impl Core { } }; } else { + meta.git_status = cache.and_then(|cache| cache.get(&meta.path, true)); meta_list.push(meta); }; } diff --git a/src/display.rs b/src/display.rs index 132341bd0..d5b8fc05f 100644 --- a/src/display.rs +++ b/src/display.rs @@ -300,6 +300,24 @@ fn get_output<'a>( block_vec.push(meta.symlink.render(colors, &flags)) } } + Block::GitStatus => { + if let Some(_s) = &meta.git_status { + #[cfg(any( + not(feature = "git"), + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + ))] + panic!("git feature is disabled"); + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + block_vec.push(_s.render(colors, icons)); + } + } }; strings.push(ColoredString::from(ANSIStrings(&block_vec).to_string())); } @@ -368,6 +386,7 @@ mod tests { use crate::{app, flags, icon, sort}; use assert_fs::prelude::*; use std::path::Path; + use tempfile::tempdir; #[test] fn test_display_get_visible_width_without_icons() { @@ -525,7 +544,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() .unwrap(); sort(&mut metas, &sort::assemble_sorters(&flags)); @@ -556,7 +575,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() .unwrap(); let output = tree( @@ -595,7 +614,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() .unwrap(); let output = tree( @@ -633,7 +652,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() .unwrap(); let output = tree( @@ -645,4 +664,102 @@ mod tests { assert!(output.ends_with("└── two\n")); } + + #[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 + ) + ); + + assert_eq!( + should_display_folder_path(0, &[file.clone()], &Flags::default()), + true // doesn't matter since there is no folder + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone()], &Flags::default()), + false + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), dir.clone()], &Flags::default()), + true + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone(), dir.clone()], &Flags::default()), + true + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), file.clone()], &Flags::default()), + true // doesn't matter since there is no folder + ); + } + + #[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() + }; + + assert_eq!( + should_display_folder_path(0, &[link.clone()], &grid_flags), + false + ); + assert_eq!( + should_display_folder_path(0, &[link.clone()], &oneline_flags), + true // 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), + true + ); + assert_eq!( + should_display_folder_path(0, &[file.clone(), link.clone()], &oneline_flags), + true // 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), + true + ); + assert_eq!( + should_display_folder_path(0, &[dir.clone(), link.clone()], &oneline_flags), + true + ); + } } diff --git a/src/flags.rs b/src/flags.rs index 676a84fae..a8ae87e73 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -3,6 +3,15 @@ pub mod color; pub mod date; pub mod dereference; pub mod display; + +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +pub mod git_icons; pub mod icons; pub mod ignore_globs; pub mod indicators; @@ -112,14 +121,17 @@ where /// out warnings. fn configure_from(matches: &ArgMatches, config: &Config) -> T { if let Some(value) = Self::from_arg_matches(matches) { + // println!("from arg {}", std::any::type_name::()); return value; } if let Some(value) = Self::from_environment() { + // println!("from env {}", std::any::type_name::()); return value; } if let Some(value) = Self::from_config(config) { + // println!("from config {}", std::any::type_name::()); return value; } diff --git a/src/flags/blocks.rs b/src/flags/blocks.rs index 0732dc32c..0ca350e79 100644 --- a/src/flags/blocks.rs +++ b/src/flags/blocks.rs @@ -51,6 +51,12 @@ impl Blocks { } } + if matches.is_present("git") && matches.is_present("long") { + if let Ok(blocks) = result.as_mut() { + blocks.optional_add_git_status(); + } + } + result } @@ -144,6 +150,28 @@ impl Blocks { self.prepend_inode() } } + + /// 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() + } + } } /// The default value for `Blocks` contains a [Vec] of [Name](Block::Name). @@ -165,6 +193,7 @@ pub enum Block { Name, INode, Links, + GitStatus, } impl TryFrom<&str> for Block { @@ -181,6 +210,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)), } } @@ -391,6 +421,90 @@ mod test_blocks { }); } + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_from_arg_matches_implicit_add_git_block() { + let argv = vec![ + "lsd", + "--blocks", + "permission,name,group,date", + "--git", + "--long", + ]; + let matches = app::build().get_matches_from_safe(argv).unwrap(); + let test_blocks = Blocks(vec![ + Block::Permission, + Block::GitStatus, + Block::Name, + Block::Group, + Block::Date, + ]); + assert!( + match Blocks::configure_from(&matches, &Config::with_none()) { + Ok(blocks) if blocks == test_blocks => true, + _ => false, + } + ); + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_from_arg_matches_no_implicit_add_git_block_if_not_long() { + let argv = vec!["lsd", "--blocks", "permission,name,group,git,date", "--git"]; + let matches = app::build().get_matches_from_safe(argv).unwrap(); + let test_blocks = Blocks(vec![ + Block::Permission, + Block::Name, + Block::Group, + Block::GitStatus, + Block::Date, + ]); + assert!( + match Blocks::configure_from(&matches, &Config::with_none()) { + Ok(blocks) if blocks == test_blocks => true, + _ => false, + } + ); + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_from_arg_matches_no_implicit_add_git_block_if_already_here() { + let argv = vec!["lsd", "--blocks", "permission,name,group,git,date", "--git"]; + let matches = app::build().get_matches_from_safe(argv).unwrap(); + let test_blocks = Blocks(vec![ + Block::Permission, + Block::Name, + Block::Group, + Block::GitStatus, + Block::Date, + ]); + assert!( + match Blocks::configure_from(&matches, &Config::with_none()) { + Ok(blocks) if blocks == test_blocks => true, + _ => false, + } + ); + } + #[test] fn test_from_config_none() { assert_eq!(None, Blocks::from_config(&Config::with_none())); @@ -506,4 +620,9 @@ mod test_block { fn test_links() { assert_eq!(Ok(Block::Links), Block::try_from("links")); } + + #[test] + fn test_git_status() { + assert_eq!(Ok(Block::GitStatus), Block::try_from("git")); + } } diff --git a/src/flags/git_icons.rs b/src/flags/git_icons.rs new file mode 100644 index 000000000..afa56679a --- /dev/null +++ b/src/flags/git_icons.rs @@ -0,0 +1,121 @@ +use crate::git::GitStatus; +use crate::icon::Theme; + +pub struct GitIcons { + theme: Theme, +} + +impl GitIcons { + pub fn new(theme: Theme) -> GitIcons { + GitIcons { theme } + } + + pub fn get(&self, status: &GitStatus) -> String { + match self.theme { + Theme::NoIcon => self.get_text(status), + Theme::Fancy => self.get_icon(status), + Theme::Unicode => self.get_unicode(status), + } + } + + fn get_text(&self, status: &GitStatus) -> String { + match status { + GitStatus::Default => "-", + GitStatus::Unmodified => "-", + GitStatus::NewInIndex => "N", + GitStatus::NewInWorkdir => "?", + GitStatus::Deleted => "D", + GitStatus::Modified => "M", + GitStatus::Renamed => "R", + GitStatus::Ignored => "!", + GitStatus::Typechange => "T", + GitStatus::Conflicted => "C", + } + .to_string() + } + + // On each unicode icon, add its value in a comment like "\ue5fb" (cf https://www.nerdfonts.com/cheat-sheet) + // and then run the command below in vim: + // s#\\u[0-9a-f]\{4}#\=eval('"'.submatch(0).'"')# + fn get_icon(&self, status: &GitStatus) -> String { + match status { + GitStatus::Default => "_", + GitStatus::Unmodified => "_", + GitStatus::NewInIndex => "\u{f067}", // "" + GitStatus::NewInWorkdir => "?", + GitStatus::Deleted => "\u{f014}", // "" + GitStatus::Modified => "\u{f8ea}", // "" + GitStatus::Renamed => "\u{f02b}", // "" + GitStatus::Ignored => "!", + GitStatus::Typechange => "\u{f0ec}", // "" + GitStatus::Conflicted => "\u{f071}", // "" + } + .to_string() + } + + fn get_unicode(&self, status: &GitStatus) -> String { + self.get_text(status) + } +} + +#[cfg(test)] +mod test { + use super::Theme; + use crate::flags::git_icons::GitIcons; + use crate::git::GitStatus; + use std::collections::HashMap; + use strum::IntoEnumIterator; + + fn test_non_duplicated(icons: &GitIcons) { + assert_eq!( + icons.get(&GitStatus::Default), + icons.get(&GitStatus::Unmodified) + ); + let mut m = HashMap::new(); + for status in GitStatus::iter() { + if status == GitStatus::Default { + continue; + } + assert_eq!(m.insert(icons.get(&status), status), None); + } + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_non_duplicated_noicon() { + let icons = GitIcons::new(Theme::NoIcon); + test_non_duplicated(&icons); + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_non_duplicated_unicode() { + let icons = GitIcons::new(Theme::Unicode); + test_non_duplicated(&icons); + } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_non_duplicated_fancy() { + let icons = GitIcons::new(Theme::Fancy); + test_non_duplicated(&icons); + } +} diff --git a/src/flags/sorting.rs b/src/flags/sorting.rs index 5395b9927..7549325b8 100644 --- a/src/flags/sorting.rs +++ b/src/flags/sorting.rs @@ -42,6 +42,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 matches.is_present("versionsort") || sort == Some("version") { Some(Self::Version) + } else if sort == Some("git") { + Some(Self::GitStatus) } else { None } @@ -290,6 +293,23 @@ mod test_sort_column { ); } + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + #[test] + fn test_from_arg_matches_sort_git() { + let argv = vec!["lsd", "--sort", "git"]; + let matches = app::build().get_matches_from_safe(argv).unwrap(); + assert_eq!( + Some(SortColumn::GitStatus), + SortColumn::from_arg_matches(&matches) + ); + } + #[test] fn test_multi_sort() { let argv = vec!["lsd", "--sort", "size", "--sort", "time"]; @@ -381,6 +401,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..2bae4b142 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,464 @@ +use crate::meta::git_file_status::GitFileStatus; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(strum::EnumIter)] // for tests +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum GitStatus { + /// No status info + 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, +} + +impl Default for GitStatus { + fn default() -> Self { + Self::Default + } +} + +pub struct GitCache { + statuses: Vec<(PathBuf, git2::Status)>, + _cached_dir: Option, +} + +impl GitCache { + pub fn new(path: &Path) -> GitCache { + let cachedir = fs::canonicalize(&path).unwrap(); + // log::info!("Trying to retrieve Git statuses for {:?}", cachedir); + + let repo = match git2::Repository::discover(&path) { + Ok(r) => r, + Err(_e) => { + // log::warn!("Git discovery error: {:?}", _e); + return Self::empty(); + } + }; + + if let Some(workdir) = repo.workdir().and_then(|x| std::fs::canonicalize(x).ok()) { + let mut statuses = Vec::new(); + // log::info!("Retrieving Git statuses for workdir {:?}", 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()); + // log::debug!("{:?}", elem); + statuses.push(elem); + } + } + Err(_e) => { + // log::warn!("Git retrieve statuses error: {:?}", _e); + } + } + // log::info!("GitCache path: {:?}", cachedir); + + GitCache { + statuses, + _cached_dir: Some(cachedir), + } + } else { + // log::debug!("No workdir"); + Self::empty() + } + } + + pub fn empty() -> Self { + GitCache { + statuses: Vec::new(), + _cached_dir: None, + } + } + + 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) => { + // log::debug!("error {}", _err); + None + } + } + } + + fn inner_get(&self, filepath: &PathBuf, is_directory: bool) -> GitFileStatus { + // log::debug!("Look for [recurse={}] {:?}", is_directory, filepath); + + if is_directory { + self.statuses + .iter() + .filter(|&x| x.0.starts_with(filepath)) + // .inspect(|&x| log::debug!("\t{:?}", x.0)) + .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(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() { + match std::fs::canonicalize(&root.join(path)) { + Ok(filename) => { + 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 + ); + } + Err(_) => {} + } + } + } + + #[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_stub.rs b/src/git_stub.rs new file mode 100644 index 000000000..c029b4510 --- /dev/null +++ b/src/git_stub.rs @@ -0,0 +1,20 @@ +use crate::meta::GitFileStatus; +use std::path::{Path, PathBuf}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum GitStatus { + /// No status info + Default, +} + +pub struct GitCache; + +impl GitCache { + pub fn new(_: &Path) -> Self { + Self {} + } + + pub fn get(&self, _filepath: &PathBuf, _is_directory: bool) -> Option { + None + } +} diff --git a/src/icon.rs b/src/icon.rs index 5a89dc3e2..d2e1f2cbe 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -8,6 +8,14 @@ pub struct Icons { default_folder_icon: &'static str, default_file_icon: &'static str, icon_separator: String, + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + git_icons: crate::flags::git_icons::GitIcons, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -48,6 +56,14 @@ impl Icons { default_file_icon, default_folder_icon, icon_separator, + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + git_icons: crate::flags::git_icons::GitIcons::new(theme), } } @@ -341,6 +357,17 @@ impl Icons { m } + + #[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) + ))] + pub fn get_status(&self, status: &crate::git::GitStatus) -> String { + self.git_icons.get(status) + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index e9db8d98a..f9e7dc6e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,20 @@ mod config_file; mod core; mod display; mod flags; +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +mod git; +#[cfg(any( + not(feature = "git"), + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") +))] +mod git_stub; 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..28fe2e4c6 --- /dev/null +++ b/src/meta/git_file_status.rs @@ -0,0 +1,84 @@ +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +use crate::git::GitStatus; +#[cfg(any( + not(feature = "git"), + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") +))] +use crate::git_stub::GitStatus; + +#[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, + } + } +} + +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +impl GitFileStatus { + 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: &crate::color::Colors, + icons: &crate::icon::Icons, + ) -> crate::color::ColoredString { + let strings = &[ + colors.colorize( + icons.get_status(&self.index), + &crate::color::Elem::GitStatus { status: self.index }, + ), + crate::color::ColoredString::from(" "), + colors.colorize( + icons.get_status(&self.workdir), + &crate::color::Elem::GitStatus { + status: self.workdir, + }, + ), + ]; + let res = ansi_term::ANSIStrings(strings).to_string(); + crate::color::ColoredString::from(res) + } +} diff --git a/src/meta/mod.rs b/src/meta/mod.rs index 83d311230..09713a66e 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -1,5 +1,6 @@ mod date; mod filetype; +pub mod git_file_status; mod indicator; mod inode; mod links; @@ -14,6 +15,7 @@ mod windows_utils; 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; @@ -27,6 +29,20 @@ pub use crate::icon::Icons; use crate::flags::{Display, Flags, Layout}; use crate::print_error; +#[cfg(all( + feature = "git", + not(any( + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") + )) +))] +use crate::git::GitCache; +#[cfg(any( + not(feature = "git"), + all(target_os = "linux", target_arch = "arm"), + all(windows, target_arch = "x86", target_env = "gnu") +))] +use crate::git_stub::GitCache; use std::fs::read_link; use std::io::{Error, ErrorKind}; use std::path::{Component, Path, PathBuf}; @@ -45,6 +61,7 @@ pub struct Meta { pub inode: INode, pub links: Links, pub content: Option>, + pub git_status: Option, } impl Meta { @@ -52,6 +69,7 @@ impl Meta { &self, depth: usize, flags: &Flags, + cache: Option<&GitCache>, ) -> Result>, std::io::Error> { if depth == 0 { return Ok(None); @@ -91,6 +109,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); } @@ -121,16 +142,18 @@ impl Meta { } }; + let is_directory = entry.file_type()?.is_dir(); + // skip files for --tree -d if flags.layout == Layout::Tree { if let Display::DirectoryOnly = flags.display { - if !entry.file_type()?.is_dir() { + if !is_directory { continue; } } } - match entry_meta.recurse_into(depth - 1, &flags) { + match entry_meta.recurse_into(depth - 1, &flags, cache) { Ok(content) => entry_meta.content = content, Err(err) => { print_error!("{}: {}.", path.display(), err); @@ -138,6 +161,8 @@ impl Meta { } }; + entry_meta.git_status = + cache.and_then(|cache| cache.get(&entry_meta.path, is_directory)); content.push(entry_meta); } @@ -238,6 +263,7 @@ impl Meta { name, file_type, content: None, + git_status: None, }) } } diff --git a/src/sort.rs b/src/sort.rs index 99812e588..03dc720d9 100644 --- a/src/sort.rs +++ b/src/sort.rs @@ -22,6 +22,7 @@ pub fn assemble_sorters(flags: &Flags) -> Vec<(SortOrder, SortFn)> { SortColumn::Time => by_date, SortColumn::Version => by_version, SortColumn::Extension => by_extension, + SortColumn::GitStatus => by_git_status, }; sorters.push((flags.sorting.order, other_sort)); sorters @@ -66,6 +67,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::*;