pub mod ui; use crate::cli::StatusArgs; use crate::config::AppConfig; use crate::workspace::load_workspaces; use anyhow::Result; use chrono::Utc; use git2::{Repository, StatusOptions}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct RepoSummary { pub name: String, pub path: PathBuf, pub branch: String, pub ahead: usize, pub behind: usize, pub is_dirty: bool, #[allow(dead_code)] pub last_commit_msg: String, pub last_commit_age: String, } #[derive(Debug, Clone)] pub struct CommitLine { pub sha: String, pub msg: String, pub age: String, pub author: String, } #[derive(Debug, Clone)] pub struct RepoDetail { pub summary: RepoSummary, pub modified_files: Vec<(char, String)>, pub recent_commits: Vec, #[allow(dead_code)] pub meta_issues: Option, #[allow(dead_code)] pub meta_prs: Option, #[allow(dead_code)] pub meta_stars: Option, } // ── git helpers ─────────────────────────────────────────────────────────────── fn format_age(secs: i64) -> String { let diff = Utc::now().timestamp() - secs; match diff { d if d < 3600 => format!("{}m ago", d / 60), d if d < 86400 => format!("{}h ago", d / 3600), d if d < 86400 * 2 => "yesterday".into(), d => format!("{}d ago", d / 86400), } } fn ahead_behind(repo: &Repository, branch: &str) -> (usize, usize) { let local = match repo.head().and_then(|h| h.peel_to_commit()) { Ok(c) => c.id(), Err(_) => return (0, 0), }; let upstream_ref = format!("refs/remotes/origin/{}", branch); match repo.refname_to_id(&upstream_ref) { Ok(upstream) => repo.graph_ahead_behind(local, upstream).unwrap_or((0, 0)), Err(_) => (0, 0), } } pub fn repo_summary(path: &Path) -> Result { let repo = Repository::open(path)?; let head = repo.head()?; let branch = head.shorthand().unwrap_or("HEAD").to_string(); let commit = head.peel_to_commit()?; let msg = commit.summary().unwrap_or("").chars().take(60).collect::(); let age = format_age(commit.time().seconds()); let mut opts = StatusOptions::new(); opts.include_untracked(true); let is_dirty = repo.statuses(Some(&mut opts)).map(|s| !s.is_empty()).unwrap_or(false); let (ahead, behind) = ahead_behind(&repo, &branch); let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); Ok(RepoSummary { name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, last_commit_msg: msg, last_commit_age: age }) } pub fn repo_detail(path: &Path) -> Result { let repo = Repository::open(path)?; let summary = repo_summary(path)?; // Modified files let mut sopts = StatusOptions::new(); sopts.include_untracked(true); let statuses = repo.statuses(Some(&mut sopts))?; let modified_files = statuses.iter().map(|e| { let st = e.status(); let ch = if st.contains(git2::Status::INDEX_NEW) || st.contains(git2::Status::WT_NEW) { '?' } else if st.contains(git2::Status::INDEX_DELETED) || st.contains(git2::Status::WT_DELETED) { 'D' } else { 'M' }; (ch, e.path().unwrap_or("").to_string()) }).collect(); // Recent commits let mut revwalk = repo.revwalk()?; revwalk.push_head()?; let recent_commits: Vec = revwalk.take(5).filter_map(|id| { let id = id.ok()?; let commit = repo.find_commit(id).ok()?; let sha = id.to_string()[..7].to_string(); let msg = commit.summary().unwrap_or("").chars().take(50).collect::(); let age = format_age(commit.time().seconds()); let author = commit.author().name().unwrap_or("").to_string(); Some(CommitLine { sha, msg, age, author }) }).collect(); Ok(RepoDetail { summary, modified_files, recent_commits, meta_issues: None, meta_prs: None, meta_stars: None }) } pub fn scan_workspace(root: &Path) -> Result> { let entries = match std::fs::read_dir(root) { Ok(e) => e, Err(_) => return Ok(vec![]), }; let mut repos = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.join(".git").exists() { if let Ok(s) = repo_summary(&path) { repos.push(s); } } } repos.sort_by(|a, b| a.name.cmp(&b.name)); Ok(repos) } pub fn apply_filter(mut repos: Vec, filter: &str) -> Vec { repos.retain(|r| match filter { "dirty" => r.is_dirty, "behind" => r.behind > 0, "ahead" => r.ahead > 0, "clean" => !r.is_dirty && r.behind == 0, _ => true, }); repos } pub async fn run(args: StatusArgs, cfg: &AppConfig) -> Result<()> { if let Some(repo_name) = &args.repo { let path = cfg.workspace_root.join(repo_name); let detail = repo_detail(&path)?; return ui::run_detail_tui(detail); } if let Some(ws_name) = &args.workspace { let wss = load_workspaces(cfg)?; let ws = wss.iter().find(|w| &w.name == ws_name) .ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", ws_name))?; let mut repos = Vec::new(); for path in crate::workspace::resolve_paths(ws, cfg) { if let Ok(s) = repo_summary(&path) { repos.push(s); } } if let Some(f) = &args.filter { repos = apply_filter(repos, f); } return ui::run_overview_tui(repos); } let mut repos = scan_workspace(&cfg.workspace_root)?; if let Some(f) = &args.filter { repos = apply_filter(repos, f); } ui::run_overview_tui(repos) }