Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene

- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
dualfroz committed 2026-07-06 14:42:52 +02:00
1 parent 5787b14880
commit 0468c032c6
100 files changed
+11614 -9840

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
//! Local-workspace CLI commands: list/status/scan, sync, stats, activity —
//! everything that works off the on-disk repos without a provider API.
use super::{die, find_repo, log_activity, out_json, scan_repos};
use crate::config::AppConfig;
use anyhow::Result;
#[allow(clippy::too_many_arguments)]
pub fn list(
cfg: &AppConfig, json: bool,
dirty: bool, behind: bool, ahead: bool,
lang: Option<String>, project: Option<String>,
) -> Result<()> {
let mut repos = scan_repos(cfg);
if dirty { repos.retain(|r| r.is_dirty); }
if behind { repos.retain(|r| r.behind > 0); }
if ahead { repos.retain(|r| r.ahead > 0); }
if let Some(l) = lang {
let l = l.to_lowercase();
repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false));
}
if let Some(p) = &project {
let projects = crate::projects::load();
let Some(proj) = projects.iter().find(|x| x.name.eq_ignore_ascii_case(p)) else {
die(4, &format!("project '{p}' not found"));
};
let members: Vec<std::path::PathBuf> =
proj.repos.iter().map(|rel| cfg.workspace_root.join(rel)).collect();
repos.retain(|r| members.contains(&r.path));
}
if json {
out_json(&repos);
return Ok(());
}
if repos.is_empty() {
println!("no repos match");
return Ok(());
}
let name_w = repos.iter().map(|r| r.name.chars().count()).max().unwrap_or(4).max(4);
for r in &repos {
let mut flags = String::new();
if r.is_dirty { flags.push('⚠'); }
if r.behind > 0 { flags.push('↓'); }
if r.ahead > 0 { flags.push('↑'); }
println!(
"{:<name_w$} {:<12} {:<3} {}",
r.name, r.branch, flags,
r.lang.as_deref().unwrap_or("-"),
);
}
Ok(())
}
pub fn status(cfg: &AppConfig, json: bool, repo: &str) -> Result<()> {
let r = find_repo(cfg, repo);
if json {
out_json(&r);
return Ok(());
}
println!("name: {}", r.name);
println!("path: {}", r.path.display());
println!("branch: {}", r.branch);
println!("dirty: {}", r.is_dirty);
println!("ahead: {}", r.ahead);
println!("behind: {}", r.behind);
println!("owner: {}", r.owner.as_deref().unwrap_or("-"));
println!("language: {}", r.lang.as_deref().unwrap_or("-"));
println!("license: {}", if r.has_license { "yes" } else { "no" });
println!("last commit: {} ({})", r.last_commit_msg, r.last_commit_age);
if let Some(tag) = &r.last_tag { println!("last tag: {tag}"); }
Ok(())
}
pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> {
let repos = scan_repos(cfg);
let dirty = repos.iter().filter(|r| r.is_dirty).count();
let behind = repos.iter().filter(|r| r.behind > 0).count();
let ahead = repos.iter().filter(|r| r.ahead > 0).count();
if json {
#[derive(serde::Serialize)]
struct Summary { total: usize, dirty: usize, behind: usize, ahead: usize }
out_json(&Summary { total: repos.len(), dirty, behind, ahead });
} else {
println!("{} repos · {} dirty · {} behind · {} ahead", repos.len(), dirty, behind, ahead);
}
Ok(())
}
pub fn sync(
cfg: &AppConfig,
repos: Vec<String>, all: bool, project: Option<String>,
action: &str, message: Option<String>,
) -> Result<()> {
use crate::activity::ActivityKind;
use crate::util::git::{git_run, git_run_ok};
let scanned = scan_repos(cfg);
let targets: Vec<&crate::status::RepoSummary> = if all {
scanned.iter().collect()
} else if let Some(p) = &project {
let projects = crate::projects::load();
let Some(proj) = projects.iter().find(|x| x.name.eq_ignore_ascii_case(p)) else {
die(4, &format!("project '{p}' not found"));
};
let members: Vec<std::path::PathBuf> =
proj.repos.iter().map(|rel| cfg.workspace_root.join(rel)).collect();
scanned.iter().filter(|r| members.contains(&r.path)).collect()
} else if !repos.is_empty() {
repos.iter().map(|name| {
let needle = name.to_lowercase();
scanned.iter()
.find(|r| r.name.to_lowercase() == needle
|| r.path.file_name().map(|b| b.to_string_lossy().to_lowercase() == needle).unwrap_or(false))
.unwrap_or_else(|| die(4, &format!("repo '{name}' not found")))
}).collect()
} else {
die(2, "specify repo names, --all, or --project");
};
let action = action.replace('-', "_"); // commit-push → commit_push
if action == "commit_push" && message.is_none() {
die(2, "--message is required for commit-push");
}
let msg = message.unwrap_or_default();
let mut failed = 0usize;
for r in &targets {
eprintln!("── {} [{}]", r.name, action);
let ok = match action.as_str() {
"pull" => {
let ok = git_run_ok(&r.path, &["pull"]);
if ok { log_activity(ActivityKind::Pull, &r.name, "vulp sync (pull)".into()); }
ok
}
"commit_push" => {
git_run(&r.path, &["add", "-A"]);
git_run(&r.path, &["commit", "-m", &msg]);
let ok = git_run_ok(&r.path, &["push"]);
if ok { log_activity(ActivityKind::Push, &r.name, format!("vulp sync (commit+push: {msg})")); }
ok
}
"push" => {
let ok = git_run_ok(&r.path, &["push"]);
if ok { log_activity(ActivityKind::Push, &r.name, "vulp sync (push)".into()); }
ok
}
other => die(2, &format!("unknown action '{other}' (pull | push | commit-push)")),
};
if !ok { eprintln!(" FAILED"); failed += 1; }
}
println!("synced {}/{} repos", targets.len() - failed, targets.len());
if failed > 0 { std::process::exit(1); }
Ok(())
}
pub fn stats(cfg: &AppConfig, json: bool, period: &str) -> Result<()> {
use crate::detail::StatsPeriod;
let p = match period {
"1d" => StatsPeriod::Day1,
"7d" => StatsPeriod::Day7,
"30d" => StatsPeriod::Day30,
"90d" => StatsPeriod::Day90,
"1y" => StatsPeriod::Year1,
"all" => StatsPeriod::AllTime,
other => die(2, &format!("unknown period '{other}' (1d|7d|30d|90d|1y|all)")),
};
let repos = scan_repos(cfg);
let mut per_repo: Vec<(String, usize)> = Vec::new();
let mut total = 0usize;
for r in &repos {
let activity = match p {
StatsPeriod::Day1 => crate::detail::activity_1d(&r.path),
StatsPeriod::Day7 => crate::detail::activity_7d(&r.path),
StatsPeriod::Day30 => crate::detail::activity_30d(&r.path),
StatsPeriod::Day90 => crate::detail::activity_90d(&r.path),
StatsPeriod::Year1 => crate::detail::activity_1y(&r.path),
StatsPeriod::AllTime => crate::detail::activity_all(&r.path),
};
let n: usize = activity.iter().map(|(_, c)| c).sum();
total += n;
if n > 0 { per_repo.push((r.name.clone(), n)); }
}
per_repo.sort_by(|a, b| b.1.cmp(&a.1));
if json {
#[derive(serde::Serialize)]
struct Stats<'a> {
period: &'a str,
total_commits: usize,
repos_active: usize,
per_repo: &'a [(String, usize)],
}
out_json(&Stats { period, total_commits: total, repos_active: per_repo.len(), per_repo: &per_repo });
} else {
println!("period: {period} commits: {total} active repos: {}", per_repo.len());
for (name, n) in per_repo.iter().take(15) {
println!(" {:>5} {}", n, name);
}
}
Ok(())
}
pub fn activity(json: bool, limit: usize, kind: Option<String>) -> Result<()> {
let mut entries = crate::activity::load_logged();
entries.sort_by(|a, b| b.ts.cmp(&a.ts));
if let Some(k) = &kind {
entries.retain(|e| e.kind.label().eq_ignore_ascii_case(k));
}
entries.truncate(limit);
if json {
out_json(&entries);
} else if entries.is_empty() {
println!("no matching activity");
} else {
for e in &entries {
println!("{} {:<8} {:<24} {}", crate::status::format_age_pub(e.ts), e.kind.label(), e.repo, e.detail);
}
}
Ok(())
}