diff --git a/docs/cli.md b/docs/cli.md index 25a6df5..4c50bef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,7 +50,7 @@ vulp [--json] # non-interactive (scripts/AI) ``` vulp scan # repo count + dirty/behind/ahead summary vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues -vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] [--stale-deploy] +vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] [--stale-deploy] [--no-external | --external] vulp status # branch, dirty, ahead/behind, last commit... vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license] @@ -78,6 +78,15 @@ open". `--alerts` filters to repos with `dependabot_open > 0`. **Deploy drift** below. `--stale-deploy` filters to repos that are `behind` or `diverged` (excludes `in_sync` and `null`/not-applicable). +`--no-external` hides vendored/reference clones (repos you have **zero of your +own commits** in — e.g. an upstream `pterodactyl` you cloned but never pushed +to); `--external` shows only those. Classification is measured on `vulp refresh` +(a git-side `--author` scan of your identities, cached in `stats_cache.toml`); +before the first refresh nothing is classified and both flags match nothing. A +fork you *do* commit to (even a handful of commits) counts as yours and is not +external. In the TUI this is the `x` key / the `⧉` marker, and the persistent +`hide_external_clones` config toggle. + ## Deploy drift Auto-discovered, not manually configured: if `deploy_root` (config) mirrors diff --git a/src/cli.rs b/src/cli.rs index 5badef4..1ce93e2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -51,6 +51,10 @@ pub enum Commands { #[arg(long)] alerts: bool, /// Only repos whose deploy host is behind or diverged (needs deploy_ssh_cmd/deploy_root configured) #[arg(long)] stale_deploy: bool, + /// Hide vendored/reference clones — repos with zero of your own commits (classified on refresh) + #[arg(long)] no_external: bool, + /// Only vendored/reference clones — repos with zero of your own commits + #[arg(long)] external: bool, }, /// Detailed status of one repo Status { repo: String }, diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index 79245e9..2fcc5f4 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -27,8 +27,8 @@ pub(crate) struct Guard { /// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> { match cmd { - Commands::List { dirty, behind, ahead, lang, project, alerts, stale_deploy } => - workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy), + Commands::List { dirty, behind, ahead, lang, project, alerts, stale_deploy, no_external, external } => + workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy, no_external, external), Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Sync { repos, all, project, action, message, allow_secrets } => diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index d72eb74..daee7bb 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -398,12 +398,16 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { } let _ = crate::cache::save(&cache); - // Tier-B local measurement (repo size trend) — no network, cheap, only - // ever recorded during a refresh (never on a plain read like `vulp status`). + // Tier-B local measurements (repo size trend, external-clone classification) + // — no network, cheap, only ever recorded during a refresh (never on a + // plain read like `vulp status`). + let accounts = crate::auth::load_accounts(cfg).unwrap_or_default(); + let matcher = crate::stats::OwnEmailMatcher::resolve(cfg, &accounts); let mut stats_cache = crate::stats::cache::load(); for r in &repos { let size = crate::stats::size::repo_size(&r.path); stats_cache.record_size(&r.name, size.git_kb, size.tree_kb); + stats_cache.record_authored(&r.name, crate::stats::has_own_commit(&r.path, &matcher)); } let _ = crate::stats::cache::save(&stats_cache); diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 78911d9..0ce0d69 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -10,6 +10,7 @@ pub fn list( cfg: &AppConfig, json: bool, dirty: bool, behind: bool, ahead: bool, lang: Option, project: Option, alerts: bool, stale_deploy: bool, + no_external: bool, external: bool, ) -> Result<()> { let mut repos = scan_repos(cfg); @@ -17,6 +18,11 @@ pub fn list( if behind { repos.retain(|r| r.behind > 0); } if ahead { repos.retain(|r| r.ahead > 0); } if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 0); } + if no_external || external { + let ext = crate::stats::cache::load().external_repo_names(); + if external { repos.retain(|r| ext.contains(&r.name)); } + if no_external { repos.retain(|r| !ext.contains(&r.name)); } + } if stale_deploy { repos.retain(|r| { !matches!( diff --git a/src/config/mod.rs b/src/config/mod.rs index 17e4c86..90ef52c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -62,6 +62,11 @@ pub struct AppConfig { /// paths past these two roots are identical). Empty = feature disabled. #[serde(default)] pub deploy_root: String, + /// Hide vendored/reference clones (repos with zero of your own commits, + /// classified on `[R]`) from the Repos list. A durable preference rather + /// than a transient filter, so it lives here and not in the session file. + #[serde(default)] + pub hide_external_clones: bool, } fn default_license_kind() -> String { "mit".into() } @@ -104,6 +109,7 @@ impl Default for AppConfig { stats_author_emails: vec![], deploy_ssh_cmd: String::new(), deploy_root: String::new(), + hide_external_clones: false, } } } @@ -133,6 +139,7 @@ impl AppConfig { ("stats_author_emails", self.stats_author_emails.join(", ")), ("deploy_ssh_cmd", self.deploy_ssh_cmd.clone()), ("deploy_root", self.deploy_root.clone()), + ("hide_external_clones", self.hide_external_clones.to_string()), ] } @@ -172,6 +179,7 @@ impl AppConfig { } "deploy_ssh_cmd" => self.deploy_ssh_cmd = value, "deploy_root" => self.deploy_root = value, + "hide_external_clones" => self.hide_external_clones = value == "true", "workspace_groups" => { self.workspace_groups = value .split(',') diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 31b6dfa..5799708 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -231,7 +231,26 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option) - state.filter_text.clear(); state.rebuild_display(); } - KeyCode::Enter => { state.filtering = false; } + // Live navigation while the search box is open: arrows move + // the highlight through the filtered results so you can pick + // one without first "locking" the filter (j/k stay literal + // filter input). Enter opens whatever is highlighted. + KeyCode::Down => state.scroll_down(), + KeyCode::Up => state.scroll_up(), + KeyCode::Enter => match state.tab { + Tab::Repos | Tab::Projects + if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() => + { + // Open the highlighted repo/project directly, keeping + // the filter text applied for when we come back. + state.filtering = false; + state.open_detail = true; + return Ok(PhaseResult::OpenDetail); + } + // Nothing openable under the cursor: just lock the filter + // (exit typing, keep the text) so the full keymap works. + _ => state.filtering = false, + }, KeyCode::Backspace => { state.filter_text.pop(); state.rebuild_display(); diff --git a/src/hub/keys_dispatch.rs b/src/hub/keys_dispatch.rs index 997795e..e6d6687 100644 --- a/src/hub/keys_dispatch.rs +++ b/src/hub/keys_dispatch.rs @@ -369,6 +369,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); } KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); } KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); } + KeyCode::Char('x') => { + state.cfg.hide_external_clones = !state.cfg.hide_external_clones; + let _ = crate::config::save_config(&state.cfg); + let n = state.external_repos.len(); + state.status_msg = Some(if state.cfg.hide_external_clones { + if n == 0 { "Hiding external clones (none classified yet — run [R])".into() } + else { format!("Hiding {} external clone(s)", n) } + } else { + "Showing external clones".into() + }); + state.rebuild_display(); + } KeyCode::Char('/') => { state.filtering = true; state.filter_text.clear(); @@ -397,6 +409,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option Option { 7 => state.filter_ahead = !state.filter_ahead, 8 => state.filter_archived = !state.filter_archived, 9 => { + state.cfg.hide_external_clones = !state.cfg.hide_external_clones; + let _ = crate::config::save_config(&state.cfg); + } + 10 => { state.sort_mode = SortMode::Default; state.filter_dirty = false; state.filter_behind = false; diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index 5ca6f18..82ef281 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -859,10 +859,12 @@ pub fn spawn_api_refresh(state: &mut HubState) { // network, cheap (~0.2s/50 repos measured), so a plain sequential // pass is fine. Only run during `[R]`, never on tab entry. let _ = tx.send(RefreshMsg::Progress("Measuring release cadence + repo size...".into())); + let matcher = crate::stats::OwnEmailMatcher::resolve(&cfg, &accounts); let mut stats_cache = crate::stats::cache::load(); for (name, path) in &repo_paths { let size = crate::stats::size::repo_size(path); stats_cache.record_size(name, size.git_kb, size.tree_kb); + stats_cache.record_authored(name, crate::stats::has_own_commit(path, &matcher)); } let _ = crate::stats::cache::save(&stats_cache); diff --git a/src/hub/render.rs b/src/hub/render.rs index e4e83c9..03cfa8f 100644 --- a/src/hub/render.rs +++ b/src/hub/render.rs @@ -28,12 +28,13 @@ pub static HELP_LINES: &[&str] = &[ " q / Esc Quit (or clear active filter)", "", "── Filtering & Sorting ───────────────────────", - " / Fuzzy filter repos (name, branch, owner, lang)", + " / Fuzzy filter repos (↑↓ move · Enter open · Esc clear)", " S Sort & filter menu", " d Toggle: show only dirty repos", " b Toggle: show only behind repos", " f Toggle: show only ahead repos", " a Toggle: show archived repos (no commits >90d)", + " x Toggle: hide external clones (⧉ — zero of your commits)", " * Select repos by condition", " Ctrl+A Select / deselect all visible repos", "", @@ -150,6 +151,12 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { } else if state.filtering { top_spans.push(Span::styled("[/: _] ", active_filter_style)); } + if state.tab == Tab::Repos && state.cfg.hide_external_clones && !state.external_repos.is_empty() { + top_spans.push(Span::styled( + format!("[⧉ {} hidden] ", state.external_repos.len()), + Style::default().fg(Color::DarkGray), + )); + } top_spans.push(Span::styled(health_str, Style::default().fg(health_col))); top_spans.push(Span::styled(pos_str, Style::default().fg(Color::DarkGray))); @@ -225,7 +232,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { ] } else if state.filtering { vec![ - Line::from(vec![Span::styled(" type to filter", dim), sep(), key("Enter"), Span::styled("lock ", dim), key("Esc"), Span::styled("clear", dim)]), + Line::from(vec![Span::styled(" type to filter", dim), sep(), key("↑↓"), Span::styled("move ", dim), key("Enter"), Span::styled("open ", dim), key("Esc"), Span::styled("clear", dim)]), Line::from(""), Line::from(""), ] diff --git a/src/hub/state.rs b/src/hub/state.rs index eac2944..64397c7 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -55,6 +55,11 @@ pub struct HubState { pub filter_text: String, pub filtering: bool, pub filter_archived:bool, + /// Repo names classified as vendored/reference clones (zero of your own + /// commits), loaded from `stats_cache` at startup and refreshed on `[R]`. + /// Hidden from the Repos list when `cfg.hide_external_clones` is set; + /// always shown with a dim `⧉` marker. + pub external_repos: HashSet, pub sort_mode: SortMode, pub bookmarks: HashSet, pub pins: HashSet, @@ -137,6 +142,7 @@ pub struct HubState { impl HubState { pub fn passes_filter(&self, ri: usize) -> bool { let r = &self.repos[ri]; + if self.cfg.hide_external_clones && self.external_repos.contains(&r.name) { return false; } if (self.filter_dirty && !r.is_dirty) || (self.filter_behind && r.behind == 0) || (self.filter_ahead && r.ahead == 0) { return false; } diff --git a/src/hub/tabs_tables.rs b/src/hub/tabs_tables.rs index 16febfc..2a3a364 100644 --- a/src/hub/tabs_tables.rs +++ b/src/hub/tabs_tables.rs @@ -45,7 +45,10 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() }; let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() }; let status_str = format!("{} {}{}", d_sym, a_str, b_str); - let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) { + let is_external = state.external_repos.contains(&r.name); + let name_col = if is_external { + Color::DarkGray + } else if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) { Color::White } else { Color::Yellow @@ -85,7 +88,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { Some(Row::new(vec![ Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty), - Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)), + Cell::from(format!("{}{}{}", prefix, if is_external { "⧉ " } else { "" }, r.name)).style(Style::default().fg(name_col)), Cell::from(r.branch.chars().take(9).collect::()), Cell::from(proj_name.chars().take(21).collect::()).style(Style::default().fg(proj_color)), Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }), diff --git a/src/stats/cache.rs b/src/stats/cache.rs index c5a9f25..2da34a6 100644 --- a/src/stats/cache.rs +++ b/src/stats/cache.rs @@ -19,6 +19,12 @@ pub struct SizeSample { pub struct RepoStatsEntry { #[serde(default)] pub size_samples: Vec, + /// Whether you've ever committed here (`stats::has_own_commit`), measured + /// on `[R]`. `Some(false)` = a vendored/reference clone you've never + /// touched; `Some(true)` = yours; `None` = not yet measured or no identity + /// configured. Drives the external-clone marker and the hide-clones filter. + #[serde(default)] + pub authored: Option, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -61,4 +67,18 @@ impl StatsCache { pub fn latest_size(&self, repo: &str) -> Option<&SizeSample> { self.repos.get(repo).and_then(|e| e.size_samples.last()) } + + /// Records whether `repo` has any of your own commits. + pub fn record_authored(&mut self, repo: &str, authored: Option) { + self.repos.entry(repo.to_string()).or_default().authored = authored; + } + + /// Repo names classified as external/vendored clones — ones measured on a + /// past `[R]` to have zero of your commits. + pub fn external_repo_names(&self) -> std::collections::HashSet { + self.repos.iter() + .filter(|(_, e)| e.authored == Some(false)) + .map(|(n, _)| n.clone()) + .collect() + } }