This commit is contained in:
dualfroz committed 2026-06-10 12:41:14 +02:00
commit 4cf726709f
36 files changed
+8617

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
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<CommitLine>,
#[allow(dead_code)] pub meta_issues: Option<u32>,
#[allow(dead_code)] pub meta_prs: Option<u32>,
#[allow(dead_code)] pub meta_stars: Option<u32>,
}
// ── 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<RepoSummary> {
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::<String>();
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<RepoDetail> {
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<CommitLine> = 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::<String>();
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<Vec<RepoSummary>> {
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<RepoSummary>, filter: &str) -> Vec<RepoSummary> {
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)
}