diff --git a/README.md b/README.md index b273423..d6aa906 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ overview. Click on a command to see its detailed documentation. | Command | Description | |---|---| | [**`clone`**](./docs/commands/clone.md) | Clones repositories from your config file. | +| [**`ls`**](./docs/commands/ls.md) | Lists repositories with optional filtering. | | [**`run`**](./docs/commands/run.md) | Runs a shell command or a pre-defined recipe in each repository. | | [**`pr`**](./docs/commands/pr.md) | Creates pull requests for repositories with changes. | | [**`rm`**](./docs/commands/rm.md) | Removes cloned repositories from your local disk. | diff --git a/docs/commands/ls.md b/docs/commands/ls.md new file mode 100644 index 0000000..7897d57 --- /dev/null +++ b/docs/commands/ls.md @@ -0,0 +1,134 @@ +# repos ls + +The `ls` command lists repositories specified in your `config.yaml` file with +optional filtering capabilities. + +## Usage + +```bash +repos ls [OPTIONS] [REPOS]... +``` + +## Description + +This command is used to display information about repositories defined in your +configuration. It's particularly useful for reviewing which repositories will be +included when using specific tag filters, helping you preview the scope of +operations before running commands like `clone`, `run`, or `pr`. + +The output includes repository names, URLs, tags, configured paths, and branches +for each repository. + +## Arguments + +- `[REPOS]...`: A space-separated list of specific repository names to list. If +not provided, `repos` will fall back to filtering by tags or listing all +repositories defined in the config. + +## Options + +- `-c, --config `: Specifies the path to the configuration file. +Defaults to `config.yaml`. +- `-t, --tag `: Filters repositories to list only those that have the +specified tag. This option can be used multiple times to include repositories +with *any* of the specified tags (OR logic). +- `-e, --exclude-tag `: Excludes repositories that have the +specified tag. This can be used to filter out repositories from the listing. +This option can be used multiple times. +- `-h, --help`: Prints help information. + +## Output Format + +For each repository, the command displays: + +- **Name**: The repository identifier +- **URL**: The Git remote URL +- **Tags**: Associated tags (if any) +- **Path**: Configured local path (if specified) +- **Branch**: Configured branch (if specified) + +The output also includes a summary showing the total count of repositories found. + +## Examples + +### List all repositories + +```bash +repos ls +``` + +### List specific repositories by name + +```bash +repos ls repo-one repo-two +``` + +### List repositories with a specific tag + +This is particularly useful to see which repositories will be affected when +running commands with the same tag filter. + +```bash +repos ls --tag backend +``` + +### List repositories with multiple tags + +This will list repositories that have *either* the `frontend` or the `rust` +tag. + +```bash +repos ls -t frontend -t rust +``` + +### Exclude repositories with a specific tag + +This will list all repositories *except* those with the `deprecated` tag. + +```bash +repos ls --exclude-tag deprecated +``` + +### Combine inclusion and exclusion + +This will list all repositories with the `backend` tag but exclude those that +also have the `deprecated` tag. + +```bash +repos ls -t backend -e deprecated +``` + +### Preview before cloning + +Before cloning repositories with a specific tag, you can preview which ones will +be affected: + +```bash +# Preview which repositories have the 'flow' tag +repos ls --tag flow + +# Then clone them +repos clone --tag flow +``` + +### Use with custom config + +```bash +repos ls --config path/to/custom-config.yaml +``` + +## Use Cases + +1. **Preview Tag Filters**: Check which repositories will be included in + operations that use the same tag filters. + +2. **Explore Configuration**: Quickly view all repositories defined in your + config without needing to open the file. + +3. **Verify Tags**: Ensure repositories are properly tagged before running bulk + operations. + +4. **Review Paths**: Check configured paths and branches for repositories. + +5. **Filter Testing**: Experiment with different tag combinations to understand + how filters work before applying them to operations like `clone` or `run`. diff --git a/src/commands/ls.rs b/src/commands/ls.rs new file mode 100644 index 0000000..29e03ae --- /dev/null +++ b/src/commands/ls.rs @@ -0,0 +1,251 @@ +//! List command implementation + +use super::{Command, CommandContext}; +use anyhow::Result; +use async_trait::async_trait; +use colored::*; + +/// List command for displaying repositories with optional filtering +pub struct ListCommand; + +#[async_trait] +impl Command for ListCommand { + async fn execute(&self, context: &CommandContext) -> Result<()> { + let repositories = context.config.filter_repositories( + &context.tag, + &context.exclude_tag, + context.repos.as_deref(), + ); + + if repositories.is_empty() { + let mut filter_parts = Vec::new(); + + if !context.tag.is_empty() { + filter_parts.push(format!("tags {:?}", context.tag)); + } + if !context.exclude_tag.is_empty() { + filter_parts.push(format!("excluding tags {:?}", context.exclude_tag)); + } + if let Some(repos) = &context.repos { + filter_parts.push(format!("repositories {:?}", repos)); + } + + let filter_desc = if filter_parts.is_empty() { + "no repositories found".to_string() + } else { + filter_parts.join(" and ") + }; + + println!( + "{}", + format!("No repositories found with {filter_desc}").yellow() + ); + return Ok(()); + } + + // Print summary header + println!( + "{}", + format!("Found {} repositories", repositories.len()).green() + ); + println!(); + + // Print each repository + for repo in &repositories { + println!("{} {}", "•".blue(), repo.name.bold()); + println!(" URL: {}", repo.url); + + if !repo.tags.is_empty() { + println!(" Tags: {}", repo.tags.join(", ").cyan()); + } + + if let Some(path) = &repo.path { + println!(" Path: {}", path); + } + + if let Some(branch) = &repo.branch { + println!(" Branch: {}", branch); + } + + println!(); + } + + // Print summary footer + println!( + "{}", + format!("Total: {} repositories", repositories.len()).green() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, Repository}; + + /// Helper function to create a test config with repositories + fn create_test_config() -> Config { + let mut repo1 = Repository::new( + "test-repo-1".to_string(), + "https://github.com/test/repo1.git".to_string(), + ); + repo1.tags = vec!["frontend".to_string(), "javascript".to_string()]; + + let mut repo2 = Repository::new( + "test-repo-2".to_string(), + "https://github.com/test/repo2.git".to_string(), + ); + repo2.tags = vec!["backend".to_string(), "rust".to_string()]; + + let mut repo3 = Repository::new( + "test-repo-3".to_string(), + "https://github.com/test/repo3.git".to_string(), + ); + repo3.tags = vec!["frontend".to_string(), "typescript".to_string()]; + + Config { + repositories: vec![repo1, repo2, repo3], + recipes: vec![], + } + } + + /// Helper to create CommandContext for testing + fn create_context( + config: Config, + tag: Vec, + exclude_tag: Vec, + repos: Option>, + ) -> CommandContext { + CommandContext { + config, + tag, + exclude_tag, + repos, + parallel: false, + } + } + + #[tokio::test] + async fn test_list_command_all_repositories() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context(config, vec![], vec![], None); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_with_tag_filter() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context(config, vec!["frontend".to_string()], vec![], None); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_with_exclude_tag() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context(config, vec![], vec!["backend".to_string()], None); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_with_both_filters() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context( + config, + vec!["frontend".to_string()], + vec!["javascript".to_string()], + None, + ); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_no_matches() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context(config, vec!["nonexistent".to_string()], vec![], None); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_with_repo_filter() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context( + config, + vec![], + vec![], + Some(vec!["test-repo-1".to_string(), "test-repo-2".to_string()]), + ); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_empty_config() { + let config = Config { + repositories: vec![], + recipes: vec![], + }; + let command = ListCommand; + + let context = create_context(config, vec![], vec![], None); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_multiple_tags() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context( + config, + vec!["frontend".to_string(), "rust".to_string()], + vec![], + None, + ); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_list_command_combined_filters() { + let config = create_test_config(); + let command = ListCommand; + + let context = create_context( + config, + vec!["frontend".to_string()], + vec![], + Some(vec!["test-repo-1".to_string()]), + ); + + let result = command.execute(&context).await; + assert!(result.is_ok()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 32ce8d7..5584482 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod base; pub mod clone; pub mod init; +pub mod ls; pub mod pr; pub mod remove; pub mod run; @@ -12,6 +13,7 @@ pub mod validators; pub use base::{Command, CommandContext}; pub use clone::CloneCommand; pub use init::InitCommand; +pub use ls::ListCommand; pub use pr::PrCommand; pub use remove::RemoveCommand; pub use run::RunCommand; diff --git a/src/main.rs b/src/main.rs index 9053a6b..f849726 100644 --- a/src/main.rs +++ b/src/main.rs @@ -156,6 +156,24 @@ enum Commands { parallel: bool, }, + /// List repositories with optional filtering + Ls { + /// Specific repository names to list (if not provided, uses tag filter or all repos) + repos: Vec, + + /// Configuration file path + #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] + config: String, + + /// Filter repositories by tag (can be specified multiple times) + #[arg(short, long)] + tag: Vec, + + /// Exclude repositories with these tags (can be specified multiple times) + #[arg(short = 'e', long)] + exclude_tag: Vec, + }, + /// Create a config.yaml file from discovered Git repositories Init { /// Output file name @@ -426,6 +444,28 @@ async fn execute_builtin_command(command: Commands) -> Result<()> { }; RemoveCommand.execute(&context).await?; } + Commands::Ls { + repos, + config, + tag, + exclude_tag, + } => { + let config = Config::load_config(&config)?; + + // Validate list command arguments using centralized validators + validators::validate_tag_filters(&tag)?; + validators::validate_tag_filters(&exclude_tag)?; + validators::validate_repository_names(&repos)?; + + let context = CommandContext { + config, + tag, + exclude_tag, + parallel: false, // List command doesn't need parallel execution + repos: if repos.is_empty() { None } else { Some(repos) }, + }; + ListCommand.execute(&context).await?; + } Commands::Init { output, overwrite,