From 08ade453b4a5d403df81c34b4863393d1f3e113e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 19 Dec 2025 14:42:04 +0100 Subject: [PATCH 1/2] feat(worktree): Enhance Git worktree detection and representation in the CLI --- src/cli.rs | 10 +- src/gitinfo/mod.rs | 7 ++ src/gitinfo/repoinfo.rs | 8 ++ src/printer.rs | 8 +- src/tests/gitinfo_test.rs | 5 +- src/tests/integration_test.rs | 188 ++++++++++++++++++++++++++++++++++ src/tests/printer_test.rs | 17 +++ src/tests/util_test.rs | 2 + src/util.rs | 22 ++++ 9 files changed, 263 insertions(+), 4 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index acf5a14..2c37580 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -97,13 +97,19 @@ impl Args { walker.par_iter().for_each(|entry| { let orig_path = entry.path(); + + // Skip internal .git/worktrees directories - these are metadata, not actual repos + if orig_path.to_string_lossy().contains("/.git/worktrees/") { + return; + } + let repo_name = orig_path.dir_name(); let path_buf = { - if orig_path.is_git_directory() { + if orig_path.is_git_directory() || orig_path.is_git_worktree() { orig_path.to_path_buf() } else if let Some(subdir) = &self.subdir { let subdir_path = orig_path.join(subdir); - if subdir_path.is_git_directory() { + if subdir_path.is_git_directory() || subdir_path.is_git_worktree() { subdir_path } else { // If the subdir does not exist, skip this directory diff --git a/src/gitinfo/mod.rs b/src/gitinfo/mod.rs index 9372811..bf6e020 100644 --- a/src/gitinfo/mod.rs +++ b/src/gitinfo/mod.rs @@ -30,11 +30,18 @@ fn get_remote_name(repo: &Repository) -> Option { /// Gets the path of the repository. /// If the path ends with `.git`, it returns the parent directory. +/// For worktrees, returns the worktree's working directory path. /// # Arguments /// * `repo` - The Git repository to check for the path. /// # Returns /// A `PathBuf` containing the repository path. fn get_repo_path(repo: &Repository) -> path::PathBuf { + // For worktrees, workdir() returns the actual working directory + if let Some(workdir) = repo.workdir() { + return workdir.to_path_buf(); + } + + // Fallback for bare repos or edge cases let path = repo.path(); if path.ends_with(".git") { path.parent().unwrap_or(path).to_path_buf() diff --git a/src/gitinfo/repoinfo.rs b/src/gitinfo/repoinfo.rs index 5586756..0bd2f9e 100644 --- a/src/gitinfo/repoinfo.rs +++ b/src/gitinfo/repoinfo.rs @@ -5,6 +5,10 @@ use git2::Repository; use crate::gitinfo::{self, status::Status}; /// Holds information about a Git repository for status display. +#[expect( + clippy::struct_excessive_bools, + reason = "This structure holds repository state flags that are naturally represented as booleans" +)] #[derive(serde::Serialize, serde::Deserialize, Clone)] pub struct RepoInfo { /// The directory name of the repository. @@ -33,6 +37,8 @@ pub struct RepoInfo { pub fast_forwarded: bool, /// relative path from the starting directory pub repo_path: String, + /// True if this is a Git worktree + pub is_worktree: bool, } impl RepoInfo { @@ -91,6 +97,7 @@ impl RepoInfo { } else { false }; + let is_worktree = repo.is_worktree(); Ok(Self { name, @@ -106,6 +113,7 @@ impl RepoInfo { is_local_only, fast_forwarded, repo_path: repo_path_relative.display().to_string(), + is_worktree, }) } diff --git a/src/printer.rs b/src/printer.rs index b472238..52240e1 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -49,7 +49,12 @@ pub fn repositories_table(repos: &mut [RepoInfo], args: &Args) { table.set_header(header); for repo in repos_iter { - let name_cell = Cell::new(&repo.repo_path).fg(repo.status.comfy_color()); + let display_path = if repo.is_worktree { + format!("āŽ‡ {}", repo.repo_path) + } else { + repo.repo_path.clone() + }; + let name_cell = Cell::new(&display_path).fg(repo.status.comfy_color()); let mut row = vec![ name_cell, @@ -93,6 +98,7 @@ pub fn legend(condensed: bool) { println!("The counts in brackets indicate the number of changed files."); println!("The counts in brackets with an asterisk (*) indicate the number of stashes."); println!("↑↑ indicates that the repository was fast-forwarded"); + println!("āŽ‡ indicates a Git worktree"); } /// Prints a summary of the repository scan (total, clean, dirty, unpushed). diff --git a/src/tests/gitinfo_test.rs b/src/tests/gitinfo_test.rs index 89635be..c3b2de8 100644 --- a/src/tests/gitinfo_test.rs +++ b/src/tests/gitinfo_test.rs @@ -469,7 +469,10 @@ fn test_get_repo_path_functionality() { // When we get the working directory, it should be the parent let workdir = repo.workdir().unwrap(); - assert_eq!(workdir, tmp.path()); + // Canonicalize both paths to handle macOS symlinks (/var vs /private/var) + let workdir_canonical = workdir.canonicalize().unwrap(); + let tmp_canonical = tmp.path().canonicalize().unwrap(); + assert_eq!(workdir_canonical, tmp_canonical); } #[test] diff --git a/src/tests/integration_test.rs b/src/tests/integration_test.rs index 4ae64eb..b82e0ee 100644 --- a/src/tests/integration_test.rs +++ b/src/tests/integration_test.rs @@ -335,3 +335,191 @@ fn test_integration_repository_fast_forward() { assert_eq!(repos[0].behind, 0); assert!(!repos[0].fast_forwarded); } + +#[test] +fn test_integration_worktree_detection() { + let temp_dir = TempDir::new().unwrap(); + + // Create a main repository with an initial commit + let main_repo_path = temp_dir.path().join("main-repo"); + fs::create_dir_all(&main_repo_path).unwrap(); + let repo = Repository::init(&main_repo_path).unwrap(); + + // Configure user for commits + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + drop(config); + + // Create initial commit on main branch + let file_path = main_repo_path.join("README.md"); + fs::write(&file_path, "# Main Repository\n").unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new("README.md")).unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = repo.signature().unwrap(); + + repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[]) + .unwrap(); + + drop(tree); + drop(index); + + // Create a feature branch + let head = repo.head().unwrap(); + let target = head.target().unwrap(); + let commit = repo.find_commit(target).unwrap(); + repo.branch("feature-branch", &commit, false).unwrap(); + drop(commit); + drop(head); + + // Create a worktree using git command (git2-rs doesn't have worktree creation API) + let worktree_path = temp_dir.path().join("feature-worktree"); + let output = std::process::Command::new("git") + .arg("worktree") + .arg("add") + .arg(&worktree_path) + .arg("feature-branch") + .current_dir(&main_repo_path) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Failed to create worktree: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Now scan the temp directory for repositories + let args = Args { + dir: temp_dir.path().to_path_buf(), + depth: 2, + ..Default::default() + }; + + let (repos, failed) = args.find_repositories(); + + // We should find exactly 2 repositories: main repo and worktree + assert_eq!(failed.len(), 0, "Failed repos: {failed:?}"); + assert_eq!( + repos.len(), + 2, + "Expected 2 repos (main + worktree), found {}: {:?}", + repos.len(), + repos.iter().map(|r| &r.repo_path).collect::>() + ); + + // Find the main repo and worktree + let main_repo = repos + .iter() + .find(|r| r.repo_path.contains("main-repo")) + .unwrap(); + let worktree = repos + .iter() + .find(|r| r.repo_path.contains("feature-worktree")) + .unwrap(); + + // Verify main repo is NOT a worktree + assert!( + !main_repo.is_worktree, + "Main repo should not be marked as worktree" + ); + // Git might use "main" or "master" depending on configuration + assert!( + main_repo.branch == "main" || main_repo.branch == "master", + "Main repo branch should be 'main' or 'master', got: {}", + main_repo.branch + ); + + // Verify worktree IS detected as a worktree + assert!( + worktree.is_worktree, + "Worktree should be marked as worktree" + ); + assert_eq!(worktree.branch, "feature-branch"); + + // Verify that the worktree path doesn't contain .git/worktrees + assert!( + !worktree.repo_path.contains(".git/worktrees"), + "Worktree path should not contain .git/worktrees, got: {}", + worktree.repo_path + ); + + // Verify both have the same commit count (since they're from the same repo) + assert_eq!(main_repo.commits, worktree.commits); +} + +#[test] +fn test_integration_worktree_with_changes() { + let temp_dir = TempDir::new().unwrap(); + + // Create a main repository + let main_repo_path = temp_dir.path().join("main-repo"); + fs::create_dir_all(&main_repo_path).unwrap(); + let repo = Repository::init(&main_repo_path).unwrap(); + + // Configure user + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + drop(config); + + // Create initial commit + let file_path = main_repo_path.join("file.txt"); + fs::write(&file_path, "content\n").unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new("file.txt")).unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = repo.signature().unwrap(); + + repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[]) + .unwrap(); + + drop(tree); + drop(index); + + // Create worktree + let worktree_path = temp_dir.path().join("worktree"); + let output = std::process::Command::new("git") + .arg("worktree") + .arg("add") + .arg(&worktree_path) + .arg("-b") + .arg("new-branch") + .current_dir(&main_repo_path) + .output() + .unwrap(); + + assert!(output.status.success()); + + // Make changes in the worktree + let worktree_file = worktree_path.join("new-file.txt"); + fs::write(&worktree_file, "worktree changes\n").unwrap(); + + // Scan repositories + let args = Args { + dir: temp_dir.path().to_path_buf(), + depth: 2, + ..Default::default() + }; + + let (repos, _failed) = args.find_repositories(); + + // Find worktree + let worktree = repos.iter().find(|r| r.is_worktree).unwrap(); + + // Verify worktree has dirty status due to uncommitted changes + assert!( + worktree.status.to_string().contains("Dirty"), + "Worktree should be dirty, got status: {}", + worktree.status + ); +} diff --git a/src/tests/printer_test.rs b/src/tests/printer_test.rs index 501119f..c786f8f 100644 --- a/src/tests/printer_test.rs +++ b/src/tests/printer_test.rs @@ -33,6 +33,7 @@ fn test_repositories_table_with_data() { is_local_only: false, fast_forwarded: false, repo_path: "repo1".to_owned(), + is_worktree: false, }]; let args = Args { dir: ".".into(), @@ -67,6 +68,7 @@ fn test_repositories_table_with_stashes_and_local_only() { is_local_only: true, fast_forwarded: false, repo_path: "repo-with-stash".to_owned(), + is_worktree: false, }, RepoInfo { name: "repo-with-upstream".to_owned(), @@ -82,6 +84,7 @@ fn test_repositories_table_with_stashes_and_local_only() { is_local_only: false, fast_forwarded: false, repo_path: "repo-with-upstream".to_owned(), + is_worktree: false, }, ]; let args = Args { @@ -109,6 +112,7 @@ fn test_repositories_table_with_path_option() { is_local_only: true, fast_forwarded: false, repo_path: "test-repo".to_owned(), + is_worktree: false, }]; let args = Args { dir: ".".into(), @@ -136,6 +140,7 @@ fn test_repositories_table_condensed_layout() { is_local_only: false, fast_forwarded: false, repo_path: "repo".to_owned(), + is_worktree: false, }]; let args = Args { dir: ".".into(), @@ -166,6 +171,7 @@ fn test_repositories_table_non_clean_filter() { is_local_only: false, fast_forwarded: false, repo_path: "clean-repo".to_owned(), + is_worktree: false, }, RepoInfo { name: "dirty-repo".to_owned(), @@ -181,6 +187,7 @@ fn test_repositories_table_non_clean_filter() { is_local_only: false, fast_forwarded: false, repo_path: "dirty-repo".to_owned(), + is_worktree: false, }, ]; let args = Args { @@ -210,6 +217,7 @@ fn test_repositories_table_sorting() { is_local_only: false, fast_forwarded: false, repo_path: "zebra-repo".to_owned(), + is_worktree: false, }, RepoInfo { name: "Alpha-Repo".to_owned(), // Capital letter @@ -225,6 +233,7 @@ fn test_repositories_table_sorting() { is_local_only: false, fast_forwarded: false, repo_path: "Alpha-Repo".to_owned(), + is_worktree: false, }, RepoInfo { name: "beta-repo".to_owned(), @@ -240,6 +249,7 @@ fn test_repositories_table_sorting() { is_local_only: false, fast_forwarded: false, repo_path: "beta-repo".to_owned(), + is_worktree: false, }, ]; let args = Args { @@ -271,6 +281,7 @@ fn test_repositories_table_various_statuses() { is_local_only: false, fast_forwarded: false, repo_path: "rebase-repo".to_owned(), + is_worktree: false, }, RepoInfo { name: "cherry-repo".to_owned(), @@ -286,6 +297,7 @@ fn test_repositories_table_various_statuses() { is_local_only: false, fast_forwarded: false, repo_path: "cherry-repo".to_owned(), + is_worktree: false, }, RepoInfo { name: "bisect-repo".to_owned(), @@ -301,6 +313,7 @@ fn test_repositories_table_various_statuses() { is_local_only: false, fast_forwarded: false, repo_path: "bisect-repo".to_owned(), + is_worktree: false, }, ]; let args = Args { @@ -335,6 +348,7 @@ fn test_summary_comprehensive() { is_local_only: false, fast_forwarded: false, repo_path: "clean1".to_owned(), + is_worktree: false, }, RepoInfo { name: "clean2".to_owned(), @@ -350,6 +364,7 @@ fn test_summary_comprehensive() { is_local_only: true, // local only fast_forwarded: false, repo_path: "clean2".to_owned(), + is_worktree: false, }, RepoInfo { name: "dirty".to_owned(), @@ -365,6 +380,7 @@ fn test_summary_comprehensive() { is_local_only: false, fast_forwarded: false, repo_path: "dirty".to_owned(), + is_worktree: false, }, ]; @@ -422,6 +438,7 @@ fn test_summary_edge_cases() { is_local_only: true, fast_forwarded: false, repo_path: "unknown-status".to_owned(), + is_worktree: false, }]; summary(&edge_repos, 0); } diff --git a/src/tests/util_test.rs b/src/tests/util_test.rs index 91429ac..da47f9e 100644 --- a/src/tests/util_test.rs +++ b/src/tests/util_test.rs @@ -42,6 +42,7 @@ fn test_print_repositories_and_summary() { is_local_only: false, fast_forwarded: false, repo_path: "dummy".to_owned(), + is_worktree: false, }; let args = Args { dir: Path::new(".").to_path_buf(), @@ -85,6 +86,7 @@ fn test_print_repositories_with_remote() { is_local_only: false, fast_forwarded: false, repo_path: "dummy".to_owned(), + is_worktree: false, }; let args = Args { dir: Path::new(".").to_path_buf(), diff --git a/src/util.rs b/src/util.rs index c08ae0d..bf6fc4c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -34,6 +34,13 @@ pub trait GitPathExt { /// `true` if the path is a Git repository, `false` otherwise. fn is_git_directory(&self) -> bool; + /// Checks if the path is a Git worktree. + /// + /// # Returns + /// + /// `true` if the path is a Git worktree, `false` otherwise. + fn is_git_worktree(&self) -> bool; + /// Extracts the repository name from the path. /// /// # Returns @@ -49,6 +56,21 @@ impl GitPathExt for Path { self.is_dir() && self.join(".git").exists() } + /// Checks if the path is a Git worktree. + /// + /// A worktree has a `.git` file (not directory) that points to the main repo. + /// + /// # Returns + /// + /// `true` if the path is a Git worktree, `false` otherwise. + fn is_git_worktree(&self) -> bool { + if !self.is_dir() { + return false; + } + let git_path = self.join(".git"); + git_path.exists() && git_path.is_file() + } + fn dir_name(&self) -> String { self.file_name() .and_then(|n| n.to_str()) From 26a26e62acbd531899f9e3302d416d005c95c759 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 24 Dec 2025 15:03:42 +0100 Subject: [PATCH 2/2] feat: Remove interactive mode --- Cargo.lock | 437 ++-------------- Cargo.toml | 6 +- README.md | 1 - src/cli.rs | 3 - src/gitinfo/status.rs | 23 - src/interactive/helpers.rs | 38 -- src/interactive/mod.rs | 378 -------------- src/interactive/mode.rs | 478 ------------------ src/main.rs | 10 +- ...atuses__tests__cli_test__git-statuses.snap | 4 - 10 files changed, 43 insertions(+), 1335 deletions(-) delete mode 100644 src/interactive/helpers.rs delete mode 100644 src/interactive/mod.rs delete mode 100644 src/interactive/mode.rs diff --git a/Cargo.lock b/Cargo.lock index c484a7d..1ad096f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "anstream" version = "0.6.21" @@ -70,26 +64,11 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" -version = "1.2.49" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -127,9 +106,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.61" +version = "4.5.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" +checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" dependencies = [ "clap", ] @@ -164,23 +143,9 @@ version = "7.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" dependencies = [ - "crossterm 0.29.0", + "crossterm", "unicode-segmentation", - "unicode-width 0.2.0", -] - -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", + "unicode-width", ] [[package]] @@ -195,15 +160,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -229,22 +185,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -253,13 +193,9 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags", "crossterm_winapi", - "derive_more", "document-features", - "mio", "parking_lot", - "rustix 1.1.2", - "signal-hook", - "signal-hook-mio", + "rustix", "winapi", ] @@ -272,41 +208,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "deranged" version = "0.5.5" @@ -316,28 +217,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_more" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -370,12 +249,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -394,21 +267,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "form_urlencoded" @@ -439,18 +300,16 @@ dependencies = [ "clap", "clap_complete", "comfy-table", - "crossterm 0.29.0", "git2", "insta", "log", "parking_lot", - "ratatui", "rayon", "serde", "serde_json", "simplelog", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "tempfile", "walkdir", ] @@ -470,17 +329,6 @@ dependencies = [ "url", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - [[package]] name = "heck" version = "0.5.0" @@ -535,9 +383,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -549,9 +397,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -568,12 +416,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -595,38 +437,17 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "insta" -version = "1.44.3" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" dependencies = [ "console", "once_cell", "serde", "similar", -] - -[[package]] -name = "instability" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", + "tempfile", ] [[package]] @@ -635,20 +456,11 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -706,12 +518,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -745,33 +551,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown", -] - [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -850,12 +635,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -885,9 +664,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -907,27 +686,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm 0.28.1", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum 0.26.3", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "rayon" version = "1.11.0" @@ -957,53 +715,19 @@ dependencies = [ "bitflags", ] -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "same-file" version = "1.0.6" @@ -1019,12 +743,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" version = "1.0.228" @@ -1057,15 +775,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1074,36 +792,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - [[package]] name = "similar" version = "2.7.0" @@ -1133,47 +821,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", + "strum_macros", ] [[package]] @@ -1212,14 +872,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix 1.1.2", + "rustix", "windows-sys 0.61.2", ] @@ -1287,28 +947,11 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "url" @@ -1350,12 +993,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -1572,3 +1209,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06" diff --git a/Cargo.toml b/Cargo.toml index d3492b2..f7efc41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,11 +16,9 @@ anyhow = "1" clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" comfy-table = "7.2.1" -crossterm = "0.29" git2 = { version = "0.20", default-features = false, features = ["https", "ssh", "vendored-openssl"] } log = "0.4.29" parking_lot = "0.12.5" -ratatui = "0.29" rayon = "1.11.0" serde = { version = "1.0", features = ["derive"]} serde_json = "1.0" @@ -30,8 +28,8 @@ strum_macros = "0.27" walkdir = "2.5" [dev-dependencies] -insta = { version = "1.44", features = ["json"] } -tempfile = "3.23" +insta = { version = "1.45", features = ["json"] } +tempfile = "3.24" [lints.rust] diff --git a/README.md b/README.md index 7b3d9bb..d078bfe 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ Options: --completions Generate shell completions [possible values: bash, elvish, fish, powershell, zsh] -p, --path Show the path to the repository -n, --non-clean Only show non clean repositories - -i, --interactive Enable interactive mode to select and interact with repositories -h, --help Print help -V, --version Print version ``` diff --git a/src/cli.rs b/src/cli.rs index 2c37580..89ca678 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -58,9 +58,6 @@ pub struct Args { /// Only show non clean repositories #[arg(short = 'n', long)] pub non_clean: bool, - /// Enable interactive mode to select and interact with repositories - #[arg(short, long)] - pub interactive: bool, /// Output in JSON format #[arg(long)] pub json: bool, diff --git a/src/gitinfo/status.rs b/src/gitinfo/status.rs index aae7d37..e7480c9 100644 --- a/src/gitinfo/status.rs +++ b/src/gitinfo/status.rs @@ -120,29 +120,6 @@ impl Status { } } - pub const fn ratatui_color(&self) -> ratatui::style::Color { - use ratatui::style::Color; - match self { - Self::Clean => Color::Reset, - Self::Dirty(_) | Self::Unpushed | Self::Unpublished => Color::Red, - Self::Merge => Color::Blue, - Self::Revert => Color::Magenta, - Self::Rebase => Color::Cyan, - Self::Bisect => Color::LightYellow, - Self::CherryPick => Color::Yellow, - Self::Detached => - // Purple color for detached HEAD state - { - Color::Rgb(255, 0, 255) - } - Self::Unknown => - // Orange color for unknown status - { - Color::Rgb(255, 165, 0) - } - } - } - /// Converts the status to a `Cell` for use in a table. /// This allows the status to be displayed with its associated color and attributes. pub fn as_cell(&self) -> Cell { diff --git a/src/interactive/helpers.rs b/src/interactive/helpers.rs deleted file mode 100644 index b084c37..0000000 --- a/src/interactive/helpers.rs +++ /dev/null @@ -1,38 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum View { - RepositoryList, - RepositoryActions(usize, usize), // repository index, selected action index - CommandRunning(usize, String), // repository index, command name - CommandOutput(usize, String, String), // repository index, command name, output -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GitAction { - Status, - Push, - Fetch, - Pull, - Back, -} - -impl GitAction { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Status => "šŸ“‹ See status", - Self::Push => "šŸ“¤ Push", - Self::Fetch => "šŸ“„ Fetch", - Self::Pull => "ā¬‡ļø Pull", - Self::Back => "šŸ”™ Back to repository list", - } - } - - pub fn all() -> Vec { - vec![ - Self::Status, - Self::Push, - Self::Fetch, - Self::Pull, - Self::Back, - ] - } -} diff --git a/src/interactive/mod.rs b/src/interactive/mod.rs deleted file mode 100644 index 3eb87e9..0000000 --- a/src/interactive/mod.rs +++ /dev/null @@ -1,378 +0,0 @@ -use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{ - Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap, - }, -}; - -use crate::interactive::helpers::GitAction; -use crate::{cli::Args, gitinfo::repoinfo::RepoInfo}; - -mod helpers; -pub mod mode; - -#[expect( - clippy::too_many_lines, - reason = "This function handles the entire interactive UI drawing logic, which can be complex." -)] -fn draw_repository_list_ui( - f: &mut ratatui::Frame<'_>, - repos: &[RepoInfo], - table_state: &mut TableState, - args: &Args, -) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(1), // Table - Constraint::Length(3), // Help - ]) - .split(f.area()); - - // Title - let title = Paragraph::new("šŸ”§ Interactive Mode - Repository Selection") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(title, chunks[0]); - - // Repository table - let mut headers = vec!["Directory", "Branch", "Local", "Commits", "Status"]; - if args.remote { - headers.push("Remote"); - } - if args.path { - headers.push("Path"); - } - - let header_cells = headers - .iter() - .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))) - .collect::>(); - let header = Row::new(header_cells); - - let rows = repos.iter().enumerate().map(|(i, repo)| { - let repo_color = repo.status.ratatui_color(); - - let name_style = if Some(i) == table_state.selected() { - Style::default() - .fg(repo_color) - .bg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(repo_color) - }; - - let status_style = if Some(i) == table_state.selected() { - Style::default() - .fg(repo_color) - .bg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(repo_color) - }; - - let mut cells = vec![ - Cell::from(repo.name.clone()).style(name_style), - Cell::from(repo.branch.clone()), - Cell::from(repo.format_local_status()), - Cell::from(repo.commits.to_string()), - Cell::from(repo.format_status_with_stash_and_ff()).style(status_style), - ]; - - if args.remote { - cells.push(Cell::from(repo.remote_url.as_deref().unwrap_or("-"))); - } - if args.path { - cells.push(Cell::from(repo.path.display().to_string())); - } - - Row::new(cells) - }); - - let widths = if args.remote && args.path { - vec![ - Constraint::Percentage(15), // Directory - Constraint::Percentage(15), // Branch - Constraint::Percentage(10), // Local - Constraint::Percentage(10), // Commits - Constraint::Percentage(15), // Status - Constraint::Percentage(20), // Remote - Constraint::Percentage(15), // Path - ] - } else if args.path || args.remote { - vec![ - Constraint::Percentage(20), // Directory - Constraint::Percentage(15), // Branch - Constraint::Percentage(15), // Local - Constraint::Percentage(10), // Commits - Constraint::Percentage(15), // Status - Constraint::Percentage(25), // Path - ] - } else { - vec![ - Constraint::Percentage(25), // Directory - Constraint::Percentage(20), // Branch - Constraint::Percentage(20), // Local - Constraint::Percentage(15), // Commits - Constraint::Percentage(20), // Status - ] - }; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .title("šŸ“‚ Repositories"), - ) - .row_highlight_style( - Style::default() - .bg(Color::Blue) - .add_modifier(Modifier::BOLD), - ); - - f.render_stateful_widget(table, chunks[1], table_state); - - // Help text - let help_text = - Paragraph::new("šŸ’” Navigation: ↑/↓ arrows to select, Enter to interact, 'q' to quit") - .style(Style::default().fg(Color::Gray)) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(help_text, chunks[2]); -} - -fn draw_repository_actions_ui( - f: &mut ratatui::Frame<'_>, - repos: &[RepoInfo], - repo_index: usize, - action_list_state: &mut ListState, -) { - if let Some(repo) = repos.get(repo_index) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(8), // Repository info - Constraint::Min(1), // Actions - Constraint::Length(3), // Help - ]) - .split(f.area()); - - // Repository info - let mut info_lines = vec![ - Line::from(vec![ - Span::styled("šŸ”§ Repository: ", Style::default().fg(Color::Cyan)), - Span::from(repo.name.clone()), - ]), - Line::from(vec![ - Span::styled("šŸ“ Path: ", Style::default().fg(Color::Yellow)), - Span::from(repo.path.display().to_string()), - ]), - Line::from(vec![ - Span::styled("🌿 Branch: ", Style::default().fg(Color::Green)), - Span::from(repo.branch.clone()), - ]), - Line::from(vec![ - Span::styled("šŸ“Š Status: ", Style::default().fg(Color::Magenta)), - Span::from(repo.status.to_string()), - ]), - Line::from(vec![ - Span::styled("šŸ”„ Local: ", Style::default().fg(Color::Blue)), - Span::from(repo.format_local_status()), - ]), - ]; - - if let Some(ref remote_url) = repo.remote_url { - info_lines.push(Line::from(vec![ - Span::styled("🌐 Remote: ", Style::default().fg(Color::Cyan)), - Span::from(remote_url.clone()), - ])); - } - - let repo_info = Paragraph::new(Text::from(info_lines)) - .block( - Block::default() - .borders(Borders::ALL) - .title("Repository Details"), - ) - .wrap(Wrap { trim: true }); - f.render_widget(repo_info, chunks[0]); - - // Actions list - let actions = GitAction::all(); - let action_items: Vec> = actions - .iter() - .map(|action| ListItem::new(action.as_str())) - .collect(); - - let actions_list = List::new(action_items) - .block( - Block::default() - .borders(Borders::ALL) - .title("šŸ› ļø Available Actions"), - ) - .highlight_style( - Style::default() - .bg(Color::Blue) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("ā–¶ "); - - f.render_stateful_widget(actions_list, chunks[1], action_list_state); - - // Help text - let help_text = Paragraph::new("šŸ’” Navigation: ↑/↓ arrows to select, Enter to execute, Esc/Backspace to go back, 'q' to quit") - .style(Style::default().fg(Color::Gray)) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(help_text, chunks[2]); - } -} - -fn draw_command_running_ui( - f: &mut ratatui::Frame<'_>, - repos: &[RepoInfo], - repo_index: usize, - command_name: &str, -) { - if let Some(repo) = repos.get(repo_index) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Repository and command info - Constraint::Min(1), // Loading indicator - Constraint::Length(3), // Help - ]) - .split(f.area()); - - // Repository and command info - let info_lines = vec![ - Line::from(vec![ - Span::styled("šŸ”§ Repository: ", Style::default().fg(Color::Cyan)), - Span::from(repo.name.clone()), - ]), - Line::from(vec![ - Span::styled("⚔ Command: ", Style::default().fg(Color::Yellow)), - Span::from(command_name), - ]), - ]; - - let info = Paragraph::new(Text::from(info_lines)) - .block( - Block::default() - .borders(Borders::ALL) - .title("Command Information"), - ) - .wrap(Wrap { trim: true }); - f.render_widget(info, chunks[0]); - - // Loading indicator - let loading_text = vec![ - Line::from(""), - Line::from(vec![ - Span::styled("ā³ ", Style::default().fg(Color::Yellow)), - Span::styled( - "Executing command...", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("šŸ”„ ", Style::default().fg(Color::Blue)), - Span::styled( - "Please wait while the git command is running.", - Style::default().fg(Color::Gray), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("šŸ’” ", Style::default().fg(Color::Green)), - Span::styled( - "This may take a moment depending on your repository size and network connection.", - Style::default().fg(Color::Gray), - ), - ]), - ]; - - let loading_paragraph = Paragraph::new(Text::from(loading_text)) - .block( - Block::default() - .borders(Borders::ALL) - .title("ā³ Running Command"), - ) - .wrap(Wrap { trim: true }); - f.render_widget(loading_paragraph, chunks[1]); - - // Help text - let help_text = - Paragraph::new("šŸ’” Press 'q' to quit (this will not cancel the running command)") - .style(Style::default().fg(Color::Gray)) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(help_text, chunks[2]); - } -} - -fn draw_command_output_ui( - f: &mut ratatui::Frame<'_>, - repos: &[RepoInfo], - repo_index: usize, - command_name: &str, - output: &str, -) { - if let Some(repo) = repos.get(repo_index) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Repository and command info - Constraint::Min(1), // Command output - Constraint::Length(3), // Help - ]) - .split(f.area()); - - // Repository and command info - let info_lines = vec![ - Line::from(vec![ - Span::styled("šŸ”§ Repository: ", Style::default().fg(Color::Cyan)), - Span::from(repo.name.clone()), - ]), - Line::from(vec![ - Span::styled("⚔ Command: ", Style::default().fg(Color::Yellow)), - Span::from(command_name), - ]), - ]; - - let info = Paragraph::new(Text::from(info_lines)) - .block( - Block::default() - .borders(Borders::ALL) - .title("Command Information"), - ) - .wrap(Wrap { trim: true }); - f.render_widget(info, chunks[0]); - - // Command output - let output_paragraph = Paragraph::new(output) - .block(Block::default().borders(Borders::ALL).title("šŸ“„ Output")) - .wrap(Wrap { trim: true }) - .scroll((0, 0)); - f.render_widget(output_paragraph, chunks[1]); - - // Help text - let help_text = - Paragraph::new("šŸ’” Press Enter/Esc/Backspace to go back to actions, 'q' to quit") - .style(Style::default().fg(Color::Gray)) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(help_text, chunks[2]); - } -} diff --git a/src/interactive/mode.rs b/src/interactive/mode.rs deleted file mode 100644 index a763cc6..0000000 --- a/src/interactive/mode.rs +++ /dev/null @@ -1,478 +0,0 @@ -use std::fmt::Write; -use std::io::{self, stdout}; - -use anyhow::Result; -use crossterm::{ - ExecutableCommand, - event::{self, Event, KeyCode, KeyEventKind}, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - Terminal, - backend::CrosstermBackend, - widgets::{ListState, TableState}, -}; - -use crate::interactive::helpers::{GitAction, View}; -use crate::{cli::Args, gitinfo::repoinfo::RepoInfo}; - -/// Interactive mode for selecting and interacting with repositories -pub struct InteractiveMode { - repos: Vec, - table_state: TableState, - action_list_state: ListState, - current_view: View, - terminal: Terminal>, - args: Args, -} - -impl InteractiveMode { - /// Create a new interactive mode instance - pub fn new(repos: &[RepoInfo], args: Args) -> Result { - let mut stdout = stdout(); - stdout.execute(EnterAlternateScreen)?; - enable_raw_mode()?; - - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - - let mut table_state = TableState::default(); - if !repos.is_empty() { - table_state.select(Some(0)); - } - - let mut action_list_state = ListState::default(); - action_list_state.select(Some(0)); // Default to first action - - let mut sorted_repos = repos.to_vec(); - sorted_repos.sort_by_key(|r| r.name.to_ascii_lowercase()); - - // Filter repos based on CLI options - let filtered_repos: Vec = if args.non_clean { - sorted_repos - .into_iter() - .filter(|r| r.status != crate::gitinfo::status::Status::Clean) - .collect() - } else { - sorted_repos - }; - - Ok(Self { - repos: filtered_repos, - table_state, - action_list_state, - current_view: View::RepositoryList, - terminal, - args, - }) - } - - /// Run the interactive mode - pub fn run(&mut self) -> Result<()> { - if self.repos.is_empty() { - return Ok(()); - } - - let result = self.interactive_loop(); - self.cleanup()?; - result - } - - fn cleanup(&mut self) -> Result<()> { - disable_raw_mode()?; - self.terminal.backend_mut().execute(LeaveAlternateScreen)?; - self.terminal.show_cursor()?; - Ok(()) - } - - fn interactive_loop(&mut self) -> Result<()> { - loop { - // Clone data needed for rendering to avoid borrowing issues - let current_view = &self.current_view; - let args = &self.args; - let repos = &self.repos; - - let table_state = &mut self.table_state; - let action_list_state = &mut self.action_list_state; - self.terminal.draw(|f| match ¤t_view { - View::RepositoryList => { - super::draw_repository_list_ui(f, repos, table_state, args); - } - View::RepositoryActions(repo_index, _) => { - super::draw_repository_actions_ui(f, repos, *repo_index, action_list_state); - } - View::CommandRunning(repo_index, command_name) => { - super::draw_command_running_ui(f, repos, *repo_index, command_name); - } - View::CommandOutput(repo_index, command_name, output) => { - super::draw_command_output_ui(f, repos, *repo_index, command_name, output); - } - })?; - - if let Event::Key(key_event) = event::read()? - && key_event.kind == KeyEventKind::Press - { - match self.current_view.clone() { - View::RepositoryList => { - if self.handle_repository_list_input(key_event.code) { - break; - } - } - View::RepositoryActions(repo_index, _) => { - if self.handle_repository_actions_input(key_event.code, repo_index)? { - break; - } - } - View::CommandRunning(_, _) => { - if Self::handle_command_running_input(key_event.code) { - break; - } - } - View::CommandOutput(_, _, _) => { - if self.handle_command_output_input(key_event.code) { - break; - } - } - } - } - } - Ok(()) - } - - fn handle_repository_list_input(&mut self, key_code: KeyCode) -> bool { - match key_code { - KeyCode::Up => { - if let Some(selected) = self.table_state.selected() - && selected > 0 - { - self.table_state.select(Some(selected - 1)); - } - } - KeyCode::Down => { - if let Some(selected) = self.table_state.selected() { - if selected < self.repos.len() - 1 { - self.table_state.select(Some(selected + 1)); - } - } else if !self.repos.is_empty() { - self.table_state.select(Some(0)); - } - } - KeyCode::Enter => { - if let Some(selected) = self.table_state.selected() { - self.current_view = View::RepositoryActions(selected, 0); - self.action_list_state.select(Some(0)); // Reset to first action - } - } - KeyCode::Char('q') | KeyCode::Esc => { - return true; - } - KeyCode::Backspace - | KeyCode::Left - | KeyCode::Right - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Delete - | KeyCode::Insert - | KeyCode::F(_) - | KeyCode::Char(_) - | KeyCode::Null - | KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) => { - // Ignore other keys - } - } - false - } - - fn handle_repository_actions_input( - &mut self, - key_code: KeyCode, - repo_index: usize, - ) -> Result { - let actions = GitAction::all(); - - match key_code { - KeyCode::Up => { - if let Some(selected) = self.action_list_state.selected() - && selected > 0 - { - self.action_list_state.select(Some(selected - 1)); - self.current_view = View::RepositoryActions(repo_index, selected - 1); - } - } - KeyCode::Down => { - if let Some(selected) = self.action_list_state.selected() { - if selected < actions.len() - 1 { - self.action_list_state.select(Some(selected + 1)); - self.current_view = View::RepositoryActions(repo_index, selected + 1); - } - } else if !actions.is_empty() { - self.action_list_state.select(Some(0)); - self.current_view = View::RepositoryActions(repo_index, 0); - } - } - KeyCode::Enter => { - if let Some(selected_action_index) = self.action_list_state.selected() - && let Some(action) = actions.get(selected_action_index) - { - match action { - GitAction::Status => { - // Show loading state first - self.current_view = - View::CommandRunning(repo_index, "Git Status".to_owned()); - self.force_redraw()?; - - let output = Self::execute_git_status(&self.repos[repo_index])?; - self.current_view = - View::CommandOutput(repo_index, "Git Status".to_owned(), output); - } - GitAction::Push => { - // Show loading state first - self.current_view = - View::CommandRunning(repo_index, "Git Push".to_owned()); - self.force_redraw()?; - - let output = Self::execute_git_push(&self.repos[repo_index])?; - self.current_view = - View::CommandOutput(repo_index, "Git Push".to_owned(), output); - } - GitAction::Fetch => { - // Show loading state first - self.current_view = - View::CommandRunning(repo_index, "Git Fetch".to_owned()); - self.force_redraw()?; - - let output = Self::execute_git_fetch(&self.repos[repo_index])?; - self.current_view = - View::CommandOutput(repo_index, "Git Fetch".to_owned(), output); - } - GitAction::Pull => { - // Show loading state first - self.current_view = - View::CommandRunning(repo_index, "Git Pull".to_owned()); - self.force_redraw()?; - - let output = Self::execute_git_pull(&self.repos[repo_index])?; - self.current_view = - View::CommandOutput(repo_index, "Git Pull".to_owned(), output); - } - GitAction::Back => { - self.current_view = View::RepositoryList; - } - } - } - } - KeyCode::Esc | KeyCode::Backspace => { - self.current_view = View::RepositoryList; - } - KeyCode::Char('q') => { - return Ok(true); - } - KeyCode::Left - | KeyCode::Right - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Delete - | KeyCode::Insert - | KeyCode::F(_) - | KeyCode::Char(_) - | KeyCode::Null - | KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) => { - // Ignore other keys - } - } - Ok(false) - } - - fn handle_command_running_input(key_code: KeyCode) -> bool { - key_code == KeyCode::Char('q') - } - - fn force_redraw(&mut self) -> Result<()> { - let current_view = self.current_view.clone(); - let repos = self.repos.clone(); - let args = &self.args; - - let table_state = &mut self.table_state; - let action_list_state = &mut self.action_list_state; - self.terminal.draw(|f| match ¤t_view { - View::RepositoryList => { - super::draw_repository_list_ui(f, &repos, table_state, args); - } - View::RepositoryActions(repo_index, _) => { - super::draw_repository_actions_ui(f, &repos, *repo_index, action_list_state); - } - View::CommandRunning(repo_index, command_name) => { - super::draw_command_running_ui(f, &repos, *repo_index, command_name); - } - View::CommandOutput(repo_index, command_name, output) => { - super::draw_command_output_ui(f, &repos, *repo_index, command_name, output); - } - })?; - Ok(()) - } - - fn handle_command_output_input(&mut self, key_code: KeyCode) -> bool { - match key_code { - KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter => { - // Go back to the repository actions view - if let View::CommandOutput(repo_index, _, _) = &self.current_view { - let repo_index = *repo_index; - self.current_view = View::RepositoryActions( - repo_index, - self.action_list_state.selected().unwrap_or(0), - ); - } - } - KeyCode::Char('q') => { - return true; - } - KeyCode::Left - | KeyCode::Right - | KeyCode::Up - | KeyCode::Down - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Delete - | KeyCode::Insert - | KeyCode::F(_) - | KeyCode::Char(_) - | KeyCode::Null - | KeyCode::CapsLock - | KeyCode::ScrollLock - | KeyCode::NumLock - | KeyCode::PrintScreen - | KeyCode::Pause - | KeyCode::Menu - | KeyCode::KeypadBegin - | KeyCode::Media(_) - | KeyCode::Modifier(_) => { - // Ignore other keys - } - } - false - } - - fn execute_git_status(repo: &RepoInfo) -> Result { - let output = std::process::Command::new("git") - .arg("status") - .current_dir(&repo.path) - .output()?; - - let mut result = format!("šŸ“‹ Git Status for {}\n", repo.name); - write!(result, "šŸ“ Path: {}\n\n", repo.path.display())?; - - if output.status.success() { - write!(result, "{}", String::from_utf8_lossy(&output.stdout))?; - } else { - writeln!(result, "āŒ Error running git status:")?; - write!(result, "{}", String::from_utf8_lossy(&output.stderr))?; - } - - Ok(result) - } - - fn execute_git_push(repo: &RepoInfo) -> Result { - let output = std::process::Command::new("git") - .arg("push") - .current_dir(&repo.path) - .output()?; - - let mut result = format!("šŸ“¤ Git Push for {}\n", repo.name); - write!(result, "šŸ“ Path: {}\n\n", repo.path.display())?; - - if output.status.success() { - writeln!(result, "āœ… Push completed successfully!\n\n")?; - write!(result, "{}", String::from_utf8_lossy(&output.stdout))?; - if !output.stderr.is_empty() { - writeln!(result, "\nšŸ“„ Additional info:\n")?; - write!(result, "{}", String::from_utf8_lossy(&output.stderr))?; - } - } else { - writeln!(result, "āŒ Error during git push:")?; - write!(result, "{}", String::from_utf8_lossy(&output.stderr))?; - } - - Ok(result) - } - - fn execute_git_fetch(repo: &RepoInfo) -> Result { - let output = std::process::Command::new("git") - .arg("fetch") - .current_dir(&repo.path) - .output()?; - - let mut result = format!("šŸ“„ Git Fetch for {}\n", repo.name); - write!(result, "šŸ“ Path: {}\n\n", repo.path.display())?; - - if output.status.success() { - result.push_str("āœ… Fetch completed successfully!\n\n"); - if !output.stdout.is_empty() { - result.push_str(&String::from_utf8_lossy(&output.stdout)); - } - if !output.stderr.is_empty() { - result.push_str("\nšŸ“„ Additional info:\n"); - result.push_str(&String::from_utf8_lossy(&output.stderr)); - } - if output.stdout.is_empty() && output.stderr.is_empty() { - result.push_str("šŸ“” Already up to date with remote."); - } - } else { - result.push_str("āŒ Error during git fetch:\n"); - result.push_str(&String::from_utf8_lossy(&output.stderr)); - } - - Ok(result) - } - - fn execute_git_pull(repo: &RepoInfo) -> Result { - let output = std::process::Command::new("git") - .arg("pull") - .current_dir(&repo.path) - .output()?; - - let mut result = format!("ā¬‡ļø Git Pull for {}\n", repo.name); - write!(result, "šŸ“ Path: {}\n\n", repo.path.display())?; - - if output.status.success() { - result.push_str("āœ… Pull completed successfully!\n\n"); - result.push_str(&String::from_utf8_lossy(&output.stdout)); - if !output.stderr.is_empty() { - result.push_str("\nšŸ“„ Additional info:\n"); - result.push_str(&String::from_utf8_lossy(&output.stderr)); - } - } else { - result.push_str("āŒ Error during git pull:\n"); - result.push_str(&String::from_utf8_lossy(&output.stderr)); - } - - Ok(result) - } -} diff --git a/src/main.rs b/src/main.rs index b1106c4..15fb268 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,10 @@ use std::io; use anyhow::Result; use clap::{CommandFactory as _, Parser as _}; -use crate::{cli::Args, interactive::mode::InteractiveMode}; +use crate::cli::Args; mod cli; mod gitinfo; -mod interactive; mod printer; #[cfg(test)] mod tests; @@ -38,13 +37,6 @@ fn main() -> Result<()> { return Ok(()); } - // Enter interactive mode if requested - if args.interactive { - let mut interactive_mode = InteractiveMode::new(&repos, args)?; - interactive_mode.run()?; - return Ok(()); - } - printer::repositories_table(&mut repos, &args); printer::failed_summary(&failed_repos); if args.summary { diff --git a/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap b/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap index 9f9437e..31be44e 100644 --- a/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap +++ b/src/tests/snapshots/git_statuses__tests__cli_test__git-statuses.snap @@ -1,6 +1,5 @@ --- source: src/tests/cli_test.rs -assertion_line: 34 expression: help_text --- A tool to display git repository statuses in a table format @@ -51,9 +50,6 @@ Options: -n, --non-clean Only show non clean repositories - -i, --interactive - Enable interactive mode to select and interact with repositories - --json Output in JSON format