Skip to content

Commit a2b6d55

Browse files
committed
Improve worker robustness and repo discovery
- handle worker shutdown/channel failures and git wait status - refactor filtering/parsing, add constants, and expand discovery tests - add CI workflow, MSRV, and move docs into docs/
1 parent 567b288 commit a2b6d55

14 files changed

Lines changed: 404 additions & 215 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: dtolnay/rust-toolchain@master
13+
with:
14+
toolchain: 1.92.0
15+
components: rustfmt, clippy
16+
- run: cargo fmt --check
17+
- run: cargo clippy --all-targets --all-features -- -D warnings
18+
- run: cargo test
19+
- run: cargo build --release

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ Required components:
6363

6464
All code must meet these quality standards before commit:
6565

66+
**When any code changes are made**, always run the full quality gate sequence before handing off:
67+
```sh
68+
cargo fmt
69+
cargo clippy --all-targets --all-features
70+
cargo test
71+
cargo build --release
72+
```
73+
6674
### 1. Formatting
6775
```sh
6876
cargo fmt

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "git-dash"
33
version = "0.2.0"
44
edition = "2021"
5+
rust-version = "1.92.0"
56
authors = ["Jose Mocito"]
67
license = "MIT"
78
description = "A fast TUI dashboard for discovering and managing multiple Git repositories"

docs/CLI.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# CLI Update Mode Specification
2+
3+
This document specifies the proposed CLI update mode for git-dash. The goal is to
4+
use a single tool that behaves as a CLI when update flags are provided and falls
5+
back to the existing TUI when no update flags are present.
6+
7+
## Mode Selection
8+
9+
- TUI mode: no update flags provided (e.g., `git-dash` or `git-dash <path>`).
10+
- CLI mode: any update flag is present (`--pull` or `--push`).
11+
- A scan root path is still accepted in both modes (defaults to CWD).
12+
13+
## CLI Usage
14+
15+
```
16+
git-dash [OPTIONS] [PATH]
17+
18+
Update options (CLI mode):
19+
--pull Pull updates (fast-forward only).
20+
--push Push updates.
21+
--repo <name> Target a single repo by folder name.
22+
--dry-run Show what would run without executing git commands.
23+
--dirty <mode> Handling for dirty repos: skip | allow | stash (default: skip).
24+
```
25+
26+
Defaults:
27+
- If neither `--pull` nor `--push` is provided in CLI mode, perform both in order:
28+
pull then push.
29+
- If `--repo` is not provided, all valid git repos under the scan root are used.
30+
- `--dirty=skip` is the default.
31+
- Implicit confirmation: presence of update flags means no interactive prompt.
32+
33+
## Target Selection
34+
35+
- Only valid git repos are included.
36+
- `--repo <name>` matches a top-level folder name exactly.
37+
- If the target repo is not found, exit with failure and a message.
38+
- Only one target repo can be selected at a time.
39+
40+
## Operation Order
41+
42+
- Combined operations always run in fixed order: pull then push.
43+
44+
## Dirty Repo Handling
45+
46+
- skip (default): do not run any update in that repo; report as skipped.
47+
- allow: run updates even if dirty; failures handled by git.
48+
- stash: stash uncommitted changes (including untracked), run updates, then pop.
49+
If stash pop conflicts, mark as failed and leave stash intact.
50+
51+
TUI behavior should follow the same rules when triggering pull/push for the
52+
selected repo.
53+
54+
## Failure Handling
55+
56+
- In all-repos mode, failures do not stop processing other repos.
57+
- A summary is printed at the end with counts for success, skipped, and failed.
58+
- The exit code is non-zero if any repo failed or was skipped.
59+
60+
## Dry Run
61+
62+
- Print the resolved repo list and the exact git commands that would run per repo.
63+
- Do not execute any git commands.
64+
65+
## Output
66+
67+
- Default: one line per repo with status (OK, SKIP, FAIL) and a short reason.
68+
- `--verbose` and `--quiet` are optional extensions if needed later.
69+
70+
## Shared Logic
71+
72+
- CLI and TUI should use the same internal update runner:
73+
- repo filtering
74+
- dirty handling
75+
- remote validation
76+
- pull/push execution
77+
- result aggregation and summary formatting

SPEC.md renamed to docs/SPEC.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,17 @@ Located in `tests/repo_discovery.rs`:
264264
- ✅ Comprehensive test suite (17 tests)
265265
- ✅ Homebrew tap support (personal tap: jvm/tap)
266266

267-
### v0.2 (Planned)
268-
- Search and filter repositories
269-
- Sorting options (name/status/ahead-behind/last fetch)
270-
- Help screen with keybindings
271-
- Header stats (repo/dirty/ahead/behind counts)
272-
- Empty state handling
273-
- Colorized change summary display
274-
- Scroll hints for long lists
267+
### v0.2 (Completed)
268+
- ✅ Search and filter repositories
269+
- ✅ Sorting options (name/status/ahead-behind/last fetch)
270+
- ✅ Help screen with keybindings
271+
- ✅ Header stats (repo/dirty/ahead/behind counts)
272+
- ✅ Empty state handling
273+
- ✅ Colorized change summary display
274+
- ✅ Scroll hints for long lists
275+
276+
### v0.3 (Planned)
277+
- TBD
275278

276279
---
277280

src/app.rs

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::time::Instant;
55
use ratatui::widgets::TableState;
66

77
use crate::discovery::RepoRef;
8-
use crate::status::RepoState;
8+
use crate::status::{parse_ahead_behind, RepoState, NO_REMOTE};
99
use crate::worker::{Action, WorkerCmd};
1010

1111
#[derive(Clone, Copy, PartialEq)]
@@ -17,11 +17,9 @@ pub enum SortOrder {
1717
}
1818

1919
#[derive(Clone, Copy, PartialEq)]
20-
#[allow(dead_code)]
2120
pub enum StatusType {
2221
Success,
2322
Error,
24-
Warning,
2523
Info,
2624
}
2725

@@ -69,9 +67,12 @@ impl App {
6967
pub fn request_scan(&mut self) {
7068
self.loading = true;
7169
self.scan_progress = 0.0;
72-
let _ = self.cmd_tx.send(WorkerCmd::Scan {
70+
if let Err(err) = self.cmd_tx.send(WorkerCmd::Scan {
7371
root: self.root.clone(),
74-
});
72+
}) {
73+
self.loading = false;
74+
self.set_status(format!("Worker unavailable: {err}"));
75+
}
7576
}
7677

7778
pub fn request_refresh(&mut self) {
@@ -83,7 +84,9 @@ impl App {
8384
git_dir: repo.git_dir.clone(),
8485
})
8586
.collect();
86-
let _ = self.cmd_tx.send(WorkerCmd::Refresh { repos });
87+
if let Err(err) = self.cmd_tx.send(WorkerCmd::Refresh { repos }) {
88+
self.set_status(format!("Worker unavailable: {err}"));
89+
}
8790
}
8891

8992
pub fn request_confirm(&mut self, action: Action) {
@@ -94,7 +97,7 @@ impl App {
9497

9598
// Validate that we have a remote before allowing push/pull
9699
if let Some(repo) = self.selected_repo() {
97-
if repo.remote_url == "-" {
100+
if repo.remote_url == NO_REMOTE {
98101
self.set_status("No remote configured for this repository".to_string());
99102
return;
100103
}
@@ -105,20 +108,25 @@ impl App {
105108

106109
pub fn perform_action(&mut self, action: Action) {
107110
if let Some(repo) = self.selected_repo() {
108-
let _ = self.cmd_tx.send(WorkerCmd::Action {
111+
if let Err(err) = self.cmd_tx.send(WorkerCmd::Action {
109112
path: repo.path.clone(),
110113
action,
111-
});
112-
self.set_status("Running action...".to_string());
114+
}) {
115+
self.set_status(format!("Worker unavailable: {err}"));
116+
} else {
117+
self.set_status("Running action...".to_string());
118+
}
113119
}
114120
}
115121

116122
pub fn request_quit(&mut self) {
117-
let _ = self.cmd_tx.send(WorkerCmd::Quit);
123+
if let Err(err) = self.cmd_tx.send(WorkerCmd::Quit) {
124+
self.set_status(format!("Worker unavailable: {err}"));
125+
}
118126
}
119127

120128
pub fn next(&mut self) {
121-
let len = self.filtered_repos().len();
129+
let len = self.filtered_indices().len();
122130
if len == 0 {
123131
return;
124132
}
@@ -130,7 +138,7 @@ impl App {
130138
}
131139

132140
pub fn previous(&mut self) {
133-
let len = self.filtered_repos().len();
141+
let len = self.filtered_indices().len();
134142
if len == 0 {
135143
return;
136144
}
@@ -148,7 +156,7 @@ impl App {
148156
}
149157

150158
pub fn page_down(&mut self) {
151-
let len = self.filtered_repos().len();
159+
let len = self.filtered_indices().len();
152160
if len == 0 {
153161
return;
154162
}
@@ -160,7 +168,7 @@ impl App {
160168
}
161169

162170
pub fn page_up(&mut self) {
163-
let len = self.filtered_repos().len();
171+
let len = self.filtered_indices().len();
164172
if len == 0 {
165173
return;
166174
}
@@ -172,25 +180,24 @@ impl App {
172180
}
173181

174182
pub fn jump_to_first(&mut self) {
175-
if !self.filtered_repos().is_empty() {
183+
if !self.filtered_indices().is_empty() {
176184
self.table_state.select(Some(0));
177185
}
178186
}
179187

180188
pub fn jump_to_last(&mut self) {
181-
let len = self.filtered_repos().len();
189+
let len = self.filtered_indices().len();
182190
if len > 0 {
183191
self.table_state.select(Some(len - 1));
184192
}
185193
}
186194

187195
pub fn selected_repo(&self) -> Option<&RepoState> {
188-
let filtered = self.filtered_repos();
189-
self.table_state.selected().and_then(|i| {
190-
filtered
191-
.get(i)
192-
.and_then(|r| self.repos.iter().find(|repo| repo.path == r.path))
193-
})
196+
let indices = self.filtered_indices();
197+
self.table_state
198+
.selected()
199+
.and_then(|i| indices.get(i).copied())
200+
.and_then(|repo_idx| self.repos.get(repo_idx))
194201
}
195202

196203
pub fn set_status(&mut self, status: String) {
@@ -261,17 +268,18 @@ impl App {
261268
self.help_visible = !self.help_visible;
262269
}
263270

264-
pub fn filtered_repos(&self) -> Vec<RepoState> {
271+
pub fn filtered_indices(&self) -> Vec<usize> {
265272
if self.search_query.is_empty() {
266-
self.repos.clone()
267-
} else {
268-
let query_lower = self.search_query.to_lowercase();
269-
self.repos
270-
.iter()
271-
.filter(|repo| repo.name.to_lowercase().contains(&query_lower))
272-
.cloned()
273-
.collect()
273+
return (0..self.repos.len()).collect();
274274
}
275+
276+
let query_lower = self.search_query.to_lowercase();
277+
self.repos
278+
.iter()
279+
.enumerate()
280+
.filter(|(_, repo)| repo.name.to_lowercase().contains(&query_lower))
281+
.map(|(idx, _)| idx)
282+
.collect()
275283
}
276284

277285
pub fn enter_search_mode(&mut self) {
@@ -304,20 +312,8 @@ impl App {
304312
}
305313

306314
fn has_ahead_or_behind(value: &str) -> bool {
307-
if value == "-" {
308-
return false;
315+
match parse_ahead_behind(value) {
316+
Some((ahead, behind)) => ahead > 0 || behind > 0,
317+
None => false,
309318
}
310-
311-
let Some((ahead_part, behind_part)) = value.split_once('/') else {
312-
return false;
313-
};
314-
315-
let Ok(ahead) = ahead_part.trim_start_matches('+').parse::<u32>() else {
316-
return false;
317-
};
318-
let Ok(behind) = behind_part.trim_start_matches('-').parse::<u32>() else {
319-
return false;
320-
};
321-
322-
ahead > 0 || behind > 0
323319
}

src/discovery.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ pub struct RepoRef {
99

1010
pub fn discover_repos_with_progress<F>(root: &Path, mut on_progress: F) -> Vec<RepoRef>
1111
where
12-
F: FnMut(usize, usize),
12+
F: FnMut(usize, usize) -> bool,
1313
{
1414
let mut repos = Vec::new();
1515
let mut stack = vec![root.to_path_buf()];
@@ -50,8 +50,8 @@ where
5050
stack.push(subdir);
5151
}
5252

53-
if visited.is_multiple_of(20) || stack.is_empty() {
54-
on_progress(visited, stack.len());
53+
if (visited.is_multiple_of(20) || stack.is_empty()) && !on_progress(visited, stack.len()) {
54+
return repos;
5555
}
5656
}
5757

0 commit comments

Comments
 (0)