feat: flag vendored clones and make the repo filter navigable

Two Repos-tab quality-of-life changes:

- External/vendored clones (repos with zero of your own commits — e.g. an
  upstream cloned for reference and never pushed to) are measured on
  refresh, cached in stats_cache (RepoStatsEntry.authored), shown with a
  dim ⧉ marker prefixed to the name, and hidden by the persistent
  hide_external_clones config toggle (key `x`, the `S` menu, or
  `vulp list --external|--no-external`). A fork you do commit to stays
  yours, so only true reference clones are flagged.

- Inside the `/` filter box, ↑↓ now move through the live-filtered
  results and Enter opens the highlighted repo/project directly, instead
  of the filter only "locking" on Enter with no way to scroll or select.
This commit is contained in:
dualfroz committed 2026-07-08 16:15:02 +02:00
1 parent 2ccf69f64c
commit 212c55d138
16 files changed
+120 -10

No files matched your search

+10 -1
View File
@@ -50,7 +50,7 @@ vulp <command> [--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 <repo> # 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
+4
View File
@@ -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 },
+2 -2
View File
@@ -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 } =>
+6 -2
View File
@@ -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);
+6
View File
@@ -10,6 +10,7 @@ pub fn list(
cfg: &AppConfig, json: bool,
dirty: bool, behind: bool, ahead: bool,
lang: Option<String>, project: Option<String>, 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!(
+8
View File
@@ -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(',')
+20 -1
View File
@@ -231,7 +231,26 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option<crate::tui::Tty>) -
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();
+13
View File
@@ -369,6 +369,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
KeyCode::Char('d') => { 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<PhaseRes
format!("[{}] Show behind only", chk(state.filter_behind)),
format!("[{}] Show ahead only", chk(state.filter_ahead)),
format!("[{}] Show archived", chk(state.filter_archived)),
format!("[{}] Hide external clones", chk(state.cfg.hide_external_clones)),
" Reset all".into(),
],
cursor: 0,
+4
View File
@@ -161,6 +161,10 @@ pub fn poll_refresh(state: &mut HubState) {
state.issues = cache.all_issues().into_iter().cloned().collect();
if closed_counts.is_some() { state.closed_counts = closed_counts; }
// The refresh thread just re-measured external-clone classification into
// stats_cache — pick up the fresh set so the marker/hide filter reflect it.
state.external_repos = crate::stats::cache::load().external_repo_names();
state.refresh_rx = None;
state.status_msg = Some(format!(
"Refreshed {} repos: {} PRs, {} issues", fetched_meta, fetched_prs, fetched_iss
+1
View File
@@ -109,6 +109,7 @@ pub async fn run_overview_tui(
selected: HashSet::new(),
filter_dirty: false, filter_behind: false, filter_ahead: false,
filter_archived: false,
external_repos: crate::stats::cache::load().external_repo_names(),
filter_text: String::new(), filtering: false,
sort_mode: SortMode::Default,
bookmarks,
+4
View File
@@ -26,6 +26,10 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
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;
+2
View File
@@ -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);
+9 -2
View File
@@ -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(""),
]
+6
View File
@@ -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<String>,
pub sort_mode: SortMode,
pub bookmarks: HashSet<PathBuf>,
pub pins: HashSet<PathBuf>,
@@ -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; }
+5 -2
View File
@@ -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::<String>()),
Cell::from(proj_name.chars().take(21).collect::<String>()).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) }),
+20
View File
@@ -19,6 +19,12 @@ pub struct SizeSample {
pub struct RepoStatsEntry {
#[serde(default)]
pub size_samples: Vec<SizeSample>,
/// 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<bool>,
}
#[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<bool>) {
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<String> {
self.repos.iter()
.filter(|(_, e)| e.authored == Some(false))
.map(|(n, _)| n.clone())
.collect()
}
}