Skip to content

Commit b2feadc

Browse files
committed
feat: add ls command
1 parent 322561c commit b2feadc

File tree

5 files changed

+428
-0
lines changed

5 files changed

+428
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ overview. Click on a command to see its detailed documentation.
102102
| Command | Description |
103103
|---|---|
104104
| [**`clone`**](./docs/commands/clone.md) | Clones repositories from your config file. |
105+
| [**`ls`**](./docs/commands/ls.md) | Lists repositories with optional filtering. |
105106
| [**`run`**](./docs/commands/run.md) | Runs a shell command or a pre-defined recipe in each repository. |
106107
| [**`pr`**](./docs/commands/pr.md) | Creates pull requests for repositories with changes. |
107108
| [**`rm`**](./docs/commands/rm.md) | Removes cloned repositories from your local disk. |

docs/commands/ls.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# repos ls
2+
3+
The `ls` command lists repositories specified in your `config.yaml` file with
4+
optional filtering capabilities.
5+
6+
## Usage
7+
8+
```bash
9+
repos ls [OPTIONS] [REPOS]...
10+
```
11+
12+
## Description
13+
14+
This command is used to display information about repositories defined in your
15+
configuration. It's particularly useful for reviewing which repositories will be
16+
included when using specific tag filters, helping you preview the scope of
17+
operations before running commands like `clone`, `run`, or `pr`.
18+
19+
The output includes repository names, URLs, tags, configured paths, and branches
20+
for each repository.
21+
22+
## Arguments
23+
24+
- `[REPOS]...`: A space-separated list of specific repository names to list. If
25+
not provided, `repos` will fall back to filtering by tags or listing all
26+
repositories defined in the config.
27+
28+
## Options
29+
30+
- `-c, --config <CONFIG>`: Specifies the path to the configuration file.
31+
Defaults to `config.yaml`.
32+
- `-t, --tag <TAG>`: Filters repositories to list only those that have the
33+
specified tag. This option can be used multiple times to include repositories
34+
with *any* of the specified tags (OR logic).
35+
- `-e, --exclude-tag <EXCLUDE_TAG>`: Excludes repositories that have the
36+
specified tag. This can be used to filter out repositories from the listing.
37+
This option can be used multiple times.
38+
- `-h, --help`: Prints help information.
39+
40+
## Output Format
41+
42+
For each repository, the command displays:
43+
44+
- **Name**: The repository identifier
45+
- **URL**: The Git remote URL
46+
- **Tags**: Associated tags (if any)
47+
- **Path**: Configured local path (if specified)
48+
- **Branch**: Configured branch (if specified)
49+
50+
The output also includes a summary showing the total count of repositories found.
51+
52+
## Examples
53+
54+
### List all repositories
55+
56+
```bash
57+
repos ls
58+
```
59+
60+
### List specific repositories by name
61+
62+
```bash
63+
repos ls repo-one repo-two
64+
```
65+
66+
### List repositories with a specific tag
67+
68+
This is particularly useful to see which repositories will be affected when
69+
running commands with the same tag filter.
70+
71+
```bash
72+
repos ls --tag backend
73+
```
74+
75+
### List repositories with multiple tags
76+
77+
This will list repositories that have *either* the `frontend` or the `rust`
78+
tag.
79+
80+
```bash
81+
repos ls -t frontend -t rust
82+
```
83+
84+
### Exclude repositories with a specific tag
85+
86+
This will list all repositories *except* those with the `deprecated` tag.
87+
88+
```bash
89+
repos ls --exclude-tag deprecated
90+
```
91+
92+
### Combine inclusion and exclusion
93+
94+
This will list all repositories with the `backend` tag but exclude those that
95+
also have the `deprecated` tag.
96+
97+
```bash
98+
repos ls -t backend -e deprecated
99+
```
100+
101+
### Preview before cloning
102+
103+
Before cloning repositories with a specific tag, you can preview which ones will
104+
be affected:
105+
106+
```bash
107+
# Preview which repositories have the 'flow' tag
108+
repos ls --tag flow
109+
110+
# Then clone them
111+
repos clone --tag flow
112+
```
113+
114+
### Use with custom config
115+
116+
```bash
117+
repos ls --config path/to/custom-config.yaml
118+
```
119+
120+
## Use Cases
121+
122+
1. **Preview Tag Filters**: Check which repositories will be included in
123+
operations that use the same tag filters.
124+
125+
2. **Explore Configuration**: Quickly view all repositories defined in your
126+
config without needing to open the file.
127+
128+
3. **Verify Tags**: Ensure repositories are properly tagged before running bulk
129+
operations.
130+
131+
4. **Review Paths**: Check configured paths and branches for repositories.
132+
133+
5. **Filter Testing**: Experiment with different tag combinations to understand
134+
how filters work before applying them to operations like `clone` or `run`.

src/commands/ls.rs

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
//! List command implementation
2+
3+
use super::{Command, CommandContext};
4+
use anyhow::Result;
5+
use async_trait::async_trait;
6+
use colored::*;
7+
8+
/// List command for displaying repositories with optional filtering
9+
pub struct ListCommand;
10+
11+
#[async_trait]
12+
impl Command for ListCommand {
13+
async fn execute(&self, context: &CommandContext) -> Result<()> {
14+
let repositories = context.config.filter_repositories(
15+
&context.tag,
16+
&context.exclude_tag,
17+
context.repos.as_deref(),
18+
);
19+
20+
if repositories.is_empty() {
21+
let mut filter_parts = Vec::new();
22+
23+
if !context.tag.is_empty() {
24+
filter_parts.push(format!("tags {:?}", context.tag));
25+
}
26+
if !context.exclude_tag.is_empty() {
27+
filter_parts.push(format!("excluding tags {:?}", context.exclude_tag));
28+
}
29+
if let Some(repos) = &context.repos {
30+
filter_parts.push(format!("repositories {:?}", repos));
31+
}
32+
33+
let filter_desc = if filter_parts.is_empty() {
34+
"no repositories found".to_string()
35+
} else {
36+
filter_parts.join(" and ")
37+
};
38+
39+
println!(
40+
"{}",
41+
format!("No repositories found with {filter_desc}").yellow()
42+
);
43+
return Ok(());
44+
}
45+
46+
// Print summary header
47+
println!(
48+
"{}",
49+
format!("Found {} repositories", repositories.len()).green()
50+
);
51+
println!();
52+
53+
// Print each repository
54+
for repo in &repositories {
55+
println!("{} {}", "•".blue(), repo.name.bold());
56+
println!(" URL: {}", repo.url);
57+
58+
if !repo.tags.is_empty() {
59+
println!(" Tags: {}", repo.tags.join(", ").cyan());
60+
}
61+
62+
if let Some(path) = &repo.path {
63+
println!(" Path: {}", path);
64+
}
65+
66+
if let Some(branch) = &repo.branch {
67+
println!(" Branch: {}", branch);
68+
}
69+
70+
println!();
71+
}
72+
73+
// Print summary footer
74+
println!(
75+
"{}",
76+
format!("Total: {} repositories", repositories.len()).green()
77+
);
78+
79+
Ok(())
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use crate::config::{Config, Repository};
87+
88+
/// Helper function to create a test config with repositories
89+
fn create_test_config() -> Config {
90+
let mut repo1 = Repository::new(
91+
"test-repo-1".to_string(),
92+
"https://github.com/test/repo1.git".to_string(),
93+
);
94+
repo1.tags = vec!["frontend".to_string(), "javascript".to_string()];
95+
96+
let mut repo2 = Repository::new(
97+
"test-repo-2".to_string(),
98+
"https://github.com/test/repo2.git".to_string(),
99+
);
100+
repo2.tags = vec!["backend".to_string(), "rust".to_string()];
101+
102+
let mut repo3 = Repository::new(
103+
"test-repo-3".to_string(),
104+
"https://github.com/test/repo3.git".to_string(),
105+
);
106+
repo3.tags = vec!["frontend".to_string(), "typescript".to_string()];
107+
108+
Config {
109+
repositories: vec![repo1, repo2, repo3],
110+
recipes: vec![],
111+
}
112+
}
113+
114+
/// Helper to create CommandContext for testing
115+
fn create_context(
116+
config: Config,
117+
tag: Vec<String>,
118+
exclude_tag: Vec<String>,
119+
repos: Option<Vec<String>>,
120+
) -> CommandContext {
121+
CommandContext {
122+
config,
123+
tag,
124+
exclude_tag,
125+
repos,
126+
parallel: false,
127+
}
128+
}
129+
130+
#[tokio::test]
131+
async fn test_list_command_all_repositories() {
132+
let config = create_test_config();
133+
let command = ListCommand;
134+
135+
let context = create_context(config, vec![], vec![], None);
136+
137+
let result = command.execute(&context).await;
138+
assert!(result.is_ok());
139+
}
140+
141+
#[tokio::test]
142+
async fn test_list_command_with_tag_filter() {
143+
let config = create_test_config();
144+
let command = ListCommand;
145+
146+
let context = create_context(config, vec!["frontend".to_string()], vec![], None);
147+
148+
let result = command.execute(&context).await;
149+
assert!(result.is_ok());
150+
}
151+
152+
#[tokio::test]
153+
async fn test_list_command_with_exclude_tag() {
154+
let config = create_test_config();
155+
let command = ListCommand;
156+
157+
let context = create_context(config, vec![], vec!["backend".to_string()], None);
158+
159+
let result = command.execute(&context).await;
160+
assert!(result.is_ok());
161+
}
162+
163+
#[tokio::test]
164+
async fn test_list_command_with_both_filters() {
165+
let config = create_test_config();
166+
let command = ListCommand;
167+
168+
let context = create_context(
169+
config,
170+
vec!["frontend".to_string()],
171+
vec!["javascript".to_string()],
172+
None,
173+
);
174+
175+
let result = command.execute(&context).await;
176+
assert!(result.is_ok());
177+
}
178+
179+
#[tokio::test]
180+
async fn test_list_command_no_matches() {
181+
let config = create_test_config();
182+
let command = ListCommand;
183+
184+
let context = create_context(config, vec!["nonexistent".to_string()], vec![], None);
185+
186+
let result = command.execute(&context).await;
187+
assert!(result.is_ok());
188+
}
189+
190+
#[tokio::test]
191+
async fn test_list_command_with_repo_filter() {
192+
let config = create_test_config();
193+
let command = ListCommand;
194+
195+
let context = create_context(
196+
config,
197+
vec![],
198+
vec![],
199+
Some(vec!["test-repo-1".to_string(), "test-repo-2".to_string()]),
200+
);
201+
202+
let result = command.execute(&context).await;
203+
assert!(result.is_ok());
204+
}
205+
206+
#[tokio::test]
207+
async fn test_list_command_empty_config() {
208+
let config = Config {
209+
repositories: vec![],
210+
recipes: vec![],
211+
};
212+
let command = ListCommand;
213+
214+
let context = create_context(config, vec![], vec![], None);
215+
216+
let result = command.execute(&context).await;
217+
assert!(result.is_ok());
218+
}
219+
220+
#[tokio::test]
221+
async fn test_list_command_multiple_tags() {
222+
let config = create_test_config();
223+
let command = ListCommand;
224+
225+
let context = create_context(
226+
config,
227+
vec!["frontend".to_string(), "rust".to_string()],
228+
vec![],
229+
None,
230+
);
231+
232+
let result = command.execute(&context).await;
233+
assert!(result.is_ok());
234+
}
235+
236+
#[tokio::test]
237+
async fn test_list_command_combined_filters() {
238+
let config = create_test_config();
239+
let command = ListCommand;
240+
241+
let context = create_context(
242+
config,
243+
vec!["frontend".to_string()],
244+
vec![],
245+
Some(vec!["test-repo-1".to_string()]),
246+
);
247+
248+
let result = command.execute(&context).await;
249+
assert!(result.is_ok());
250+
}
251+
}

0 commit comments

Comments
 (0)