- src/stats/: OwnEmailMatcher (config emails + GH no-reply per account) + git-side --author filtering (cheap even on the pterodactyl fork's 4700+ commits); streak (current/longest, midnight-grace rule), 52-week heatmap, day/hour histograms — one scan feeds all of them, data layer only for now (visual Stats-tab redesign stays in Sprint D's mockup-first cycle) - src/stats/cadence.rs: release cadence (median tag gap, local tags, Tier A) - src/stats/size.rs + cache.rs: git-object vs working-tree size, persisted in new ~/.vulp/stats_cache.toml (max 1 sample/day), recorded only during [R]/vulp refresh — confirms vulp's own filter-repo cleanup (539MB->834KB) - src/digest.rs: WeeklyDigest struct + 3 renderers (Discord embed/markdown archive/plaintext); vulp digest [send discord|webhook <url>] CLI, webhook stored via existing auth token store (~/.vulp/.tokens, 0600), never logged; send respects --dry-run - TUI: [d] on Stats previews the digest in a Modal::Info before anything is ever sent - fix: list_contributors used --all (double-counts after fetching remotes), now --branches - config: stats_author_emails field (TUI-editable) - verified: streak/delta/top-repo/release math against a hand-built commit history, webhook validation+permissions, dry-run, real refresh (51 repos, vulp's own entry confirms the filter-repo win), TUI preview via pyte
459 lines
20 KiB
Rust
459 lines
20 KiB
Rust
//! Git data access for the repo Detail view: commit/tag history, branch
|
||
//! listing with ahead/behind counts, contributors, per-period commit
|
||
//! activity (for the bar charts), the file tree, file preview, and the
|
||
//! branch checkout/delete/push actions. Mixes `git2` for structured reads
|
||
//! with shelling out to `git`/`sh` for the handful of queries (shortlog,
|
||
//! line counts, date-bucketed log) that are far simpler as a one-line CLI
|
||
//! pipe than as hand-rolled `git2` traversal.
|
||
|
||
use super::{BranchEntry, ContribEntry, HistEntry, TreeNode};
|
||
use crate::status::format_age_pub;
|
||
use git2::{BranchType, Repository};
|
||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||
|
||
pub use crate::util::git::{git_run, git_run_ok};
|
||
pub use crate::util::url::to_web_url;
|
||
pub use crate::util::clipboard::copy_auto as copy_to_clipboard;
|
||
|
||
// ── commit history ────────────────────────────────────────────────────────────
|
||
|
||
/// Returns up to `limit` recent commits merged with all tags, newest first.
|
||
/// Tags are appended and re-sorted alongside commits (rather than queried
|
||
/// separately by the caller) so the history view can show them inline at
|
||
/// their true chronological position; the `limit + 20` truncation leaves
|
||
/// room for tags that would otherwise push real commits out of the window.
|
||
pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
|
||
let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] };
|
||
|
||
let mut commits: Vec<HistEntry> = {
|
||
let mut rw = match repo.revwalk() { Ok(r) => r, Err(_) => return vec![] };
|
||
let _ = rw.push_head();
|
||
rw.take(limit).filter_map(|id| {
|
||
let id = id.ok()?;
|
||
let c = repo.find_commit(id).ok()?;
|
||
let sha = id.to_string()[..7].to_string();
|
||
let ts = c.time().seconds();
|
||
let age = format_age_pub(ts);
|
||
let author = c.author().name().unwrap_or("").to_string();
|
||
let msg = c.summary().unwrap_or("").chars().take(72).collect();
|
||
Some(HistEntry::Commit { sha, age, ts, author, msg })
|
||
}).collect()
|
||
};
|
||
|
||
let mut tags: Vec<HistEntry> = vec![];
|
||
if let Ok(tag_names) = repo.tag_names(None) {
|
||
for name in tag_names.iter().flatten() {
|
||
let refname = format!("refs/tags/{}", name);
|
||
if let Ok(obj) = repo.revparse_single(&refname) {
|
||
let (ts, tag_msg) = if let Ok(tag) = obj.clone().into_tag() {
|
||
let ts = tag.tagger().map(|s| s.when().seconds()).unwrap_or(0);
|
||
let msg = tag.message().unwrap_or("").chars().take(72).collect();
|
||
(ts, msg)
|
||
} else if let Ok(c) = obj.peel_to_commit() {
|
||
(c.time().seconds(), String::new())
|
||
} else { continue };
|
||
tags.push(HistEntry::Tag {
|
||
name: name.to_string(),
|
||
age: format_age_pub(ts),
|
||
ts,
|
||
msg: tag_msg,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
commits.extend(tags);
|
||
commits.sort_by_key(|c| std::cmp::Reverse(c.ts()));
|
||
commits.truncate(limit + 20);
|
||
commits
|
||
}
|
||
|
||
/// Total commit count on the current branch (`git rev-list --count HEAD`).
|
||
pub fn total_commits(path: &std::path::Path) -> usize {
|
||
std::process::Command::new("git")
|
||
.arg("-C").arg(path).args(["rev-list", "--count", "HEAD"])
|
||
.output()
|
||
.ok()
|
||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||
.and_then(|s| s.trim().parse().ok())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Number of tags in the repo.
|
||
pub fn count_tags(path: &std::path::Path) -> usize {
|
||
std::process::Command::new("git")
|
||
.arg("-C").arg(path).args(["tag", "-l"])
|
||
.output()
|
||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().filter(|l| !l.is_empty()).count())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Number of stash entries.
|
||
pub fn count_stashes(path: &std::path::Path) -> usize {
|
||
std::process::Command::new("git")
|
||
.arg("-C").arg(path).args(["stash", "list"])
|
||
.output()
|
||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().count())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
// ── files ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// All tracked file paths, relative to the repo root (`git ls-files`).
|
||
pub fn list_all_files(path: &std::path::Path) -> Vec<String> {
|
||
let out = std::process::Command::new("git")
|
||
.arg("-C").arg(path).args(["ls-files"])
|
||
.output()
|
||
.unwrap_or_else(|_| std::process::Output {
|
||
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
|
||
});
|
||
String::from_utf8_lossy(&out.stdout)
|
||
.lines().map(|l| l.to_string()).collect()
|
||
}
|
||
|
||
/// Total line count across tracked files (capped at the first 5000 files,
|
||
/// since `wc -l` over an unbounded file list on a huge repo would be slow
|
||
/// for a number that's only used as a rough stat).
|
||
pub fn count_lines_of_code(path: &std::path::Path) -> usize {
|
||
let out = std::process::Command::new("sh")
|
||
.arg("-c")
|
||
.arg("git ls-files | head -5000 | xargs wc -l 2>/dev/null | tail -1")
|
||
.current_dir(path)
|
||
.output();
|
||
match out {
|
||
Ok(o) => {
|
||
let s = String::from_utf8_lossy(&o.stdout);
|
||
s.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0)
|
||
}
|
||
Err(_) => 0,
|
||
}
|
||
}
|
||
|
||
/// The `n` largest tracked files by line count, descending, for the repo
|
||
/// stats view's "largest files" list.
|
||
pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> {
|
||
let out = std::process::Command::new("sh")
|
||
.arg("-c")
|
||
.arg("git ls-files | head -5000 | xargs wc -l 2>/dev/null | sort -rn")
|
||
.current_dir(path)
|
||
.output();
|
||
match out {
|
||
Ok(o) => String::from_utf8_lossy(&o.stdout).lines()
|
||
.filter_map(|line| {
|
||
let line = line.trim();
|
||
let mut parts = line.splitn(2, ' ');
|
||
let count_str = parts.next()?.trim();
|
||
let file = parts.next()?.trim();
|
||
if file == "total" || file.is_empty() { return None; }
|
||
let count = count_str.parse::<usize>().ok()?;
|
||
Some((file.to_string(), count))
|
||
})
|
||
.take(n)
|
||
.collect(),
|
||
Err(_) => vec![],
|
||
}
|
||
}
|
||
|
||
// ── remote URL ────────────────────────────────────────────────────────────────
|
||
|
||
/// The `origin` remote's URL, normalized to a browsable `https://` web URL.
|
||
pub fn remote_url(path: &std::path::Path) -> Option<String> {
|
||
let repo = Repository::open(path).ok()?;
|
||
let remote = repo.find_remote("origin").ok()?;
|
||
let url = remote.url()?;
|
||
Some(to_web_url(url))
|
||
}
|
||
|
||
// ── branches ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Lists local branches with their upstream ahead/behind counts and last
|
||
/// commit info, current branch first then alphabetically.
|
||
pub fn list_branches(path: &std::path::Path) -> Vec<BranchEntry> {
|
||
let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] };
|
||
let mut out = vec![];
|
||
let branches = match repo.branches(Some(BranchType::Local)) {
|
||
Ok(b) => b, Err(_) => return vec![],
|
||
};
|
||
for b in branches.flatten() {
|
||
let (branch, _) = b;
|
||
let name = match branch.name() {
|
||
Ok(Some(n)) => n.to_string(),
|
||
_ => continue,
|
||
};
|
||
let is_current = branch.is_head();
|
||
|
||
let upstream = branch.upstream().ok()
|
||
.and_then(|u| u.name().ok().flatten().map(|n| n.to_string()));
|
||
|
||
let (ahead, behind) = if let Some(ref up) = upstream {
|
||
if let (Ok(local_ref), Ok(up_ref)) = (
|
||
repo.find_branch(&name, BranchType::Local).and_then(|b| b.into_reference().resolve()),
|
||
repo.find_reference(&format!("refs/remotes/{}", up)).and_then(|r| r.resolve()),
|
||
) {
|
||
if let (Some(local_oid), Some(up_oid)) = (local_ref.target(), up_ref.target()) {
|
||
repo.graph_ahead_behind(local_oid, up_oid).unwrap_or((0, 0))
|
||
} else { (0, 0) }
|
||
} else { (0, 0) }
|
||
} else { (0, 0) };
|
||
|
||
let (last_commit_age, last_commit_msg) = branch.get().peel_to_commit().ok()
|
||
.map(|c| (format_age_pub(c.time().seconds()), c.summary().unwrap_or("").chars().take(60).collect::<String>()))
|
||
.unwrap_or_default();
|
||
|
||
out.push(BranchEntry { name, is_current, upstream, ahead, behind, last_commit_age, last_commit_msg });
|
||
}
|
||
out.sort_by(|a, b| b.is_current.cmp(&a.is_current).then(a.name.cmp(&b.name)));
|
||
out
|
||
}
|
||
|
||
// ── contributors ─────────────────────────────────────────────────────────────
|
||
|
||
/// Contributors by commit count across local branches (`git shortlog -sn
|
||
/// --branches`). Deliberately not `--all`: that also counts remote-tracking
|
||
/// refs, so after a fetch every contributor gets double-counted.
|
||
pub fn list_contributors(path: &std::path::Path) -> Vec<ContribEntry> {
|
||
let out = std::process::Command::new("git")
|
||
.arg("-C").arg(path).args(["shortlog", "-sn", "--branches"])
|
||
.output()
|
||
.unwrap_or_else(|_| std::process::Output {
|
||
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
|
||
});
|
||
String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.filter_map(|line| {
|
||
let line = line.trim();
|
||
let (count_str, name) = line.split_once('\t')?;
|
||
let count = count_str.trim().parse::<usize>().ok()?;
|
||
Some(ContribEntry { name: name.to_string(), count })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ── commit activity (per period) ─────────────────────────────────────────────
|
||
|
||
/// Commit counts bucketed by `date_fmt` (a `git log --date=format:` string),
|
||
/// optionally restricted to the last `since_days` days. The shared engine
|
||
/// behind all `activity_*` functions below, which differ only in bucket
|
||
/// width and how many buckets they show.
|
||
fn git_activity_raw(path: &std::path::Path, since_days: Option<u64>, date_fmt: &str) -> HashMap<String, usize> {
|
||
let since_arg = since_days.map(|d| format!("--since={} days ago", d));
|
||
let mut cmd = std::process::Command::new("git");
|
||
cmd.arg("-C").arg(path).arg("log");
|
||
if let Some(ref s) = since_arg { cmd.arg(s); }
|
||
// format-local: buckets by the *local* timezone — plain `format:` uses
|
||
// whatever offset is embedded in each commit, so a commit from CI/UTC
|
||
// (or GitHub web UI) would land in the wrong day.
|
||
cmd.args([&format!("--date=format-local:{}", date_fmt), "--pretty=format:%ad"]);
|
||
let out = cmd.output().unwrap_or_else(|_| std::process::Output {
|
||
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
|
||
});
|
||
let mut counts = HashMap::new();
|
||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||
let label = line.trim().to_string();
|
||
if !label.is_empty() { *counts.entry(label).or_insert(0usize) += 1; }
|
||
}
|
||
counts
|
||
}
|
||
|
||
/// Commits per hour over the last day, as 24 `"00"`–`"23"` buckets.
|
||
pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
let raw = git_activity_raw(path, Some(1), "%H");
|
||
(0u32..24).map(|h| {
|
||
let label = format!("{:02}", h);
|
||
let count = raw.get(&label).copied().unwrap_or(0);
|
||
(label, count)
|
||
}).collect()
|
||
}
|
||
|
||
/// Commits per day over the last 7 days, as `"MM-DD"` buckets.
|
||
pub fn activity_7d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
let raw = git_activity_raw(path, Some(7), "%Y-%m-%d");
|
||
// Local, not Utc — labels must line up with format-local buckets.
|
||
let now = chrono::Local::now();
|
||
(0i64..7).rev().map(|d| {
|
||
let dt = now - chrono::Duration::days(d);
|
||
let label = dt.format("%Y-%m-%d").to_string();
|
||
let short = dt.format("%m-%d").to_string();
|
||
let count = raw.get(&label).copied().unwrap_or(0);
|
||
(short, count)
|
||
}).collect()
|
||
}
|
||
|
||
/// Commits per day over the last 30 days, as `"DD"` buckets.
|
||
pub fn activity_30d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
let raw = git_activity_raw(path, Some(30), "%Y-%m-%d");
|
||
// Local, not Utc — labels must line up with format-local buckets.
|
||
let now = chrono::Local::now();
|
||
(0i64..30).rev().map(|d| {
|
||
let dt = now - chrono::Duration::days(d);
|
||
let label = dt.format("%Y-%m-%d").to_string();
|
||
let short = dt.format("%d").to_string();
|
||
let count = raw.get(&label).copied().unwrap_or(0);
|
||
(short, count)
|
||
}).collect()
|
||
}
|
||
|
||
/// Commits over the last 90 days, grouped into 13 weekly buckets labeled by
|
||
/// each week's start date.
|
||
pub fn activity_90d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
let raw = git_activity_raw(path, Some(90), "%Y-%m-%d");
|
||
// Local, not Utc — labels must line up with format-local buckets.
|
||
let now = chrono::Local::now();
|
||
// Group into 7-day buckets (13 weeks)
|
||
(0usize..13).rev().map(|w| {
|
||
let end_d = (w * 7) as i64;
|
||
let start_d = end_d + 7;
|
||
let dt_end = now - chrono::Duration::days(end_d);
|
||
let dt_start = now - chrono::Duration::days(start_d - 1);
|
||
let label = format!("W{:02}", 13 - w);
|
||
let short = dt_start.format("%m-%d").to_string();
|
||
let count: usize = (0..7).map(|i| {
|
||
let dt = now - chrono::Duration::days(end_d + i);
|
||
let k = dt.format("%Y-%m-%d").to_string();
|
||
raw.get(&k).copied().unwrap_or(0)
|
||
}).sum();
|
||
let _ = (label, dt_end);
|
||
(short, count)
|
||
}).collect()
|
||
}
|
||
|
||
/// Commits per month over the last year, as 3-letter month buckets.
|
||
pub fn activity_1y(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
use chrono::Datelike;
|
||
let raw = git_activity_raw(path, Some(365), "%Y-%m");
|
||
// Real month arithmetic — "now − m·30 days" drifts by a month within a
|
||
// year (months are 28-31 days) and duplicates/skips labels.
|
||
let now = chrono::Local::now();
|
||
let (y0, m0) = (now.year(), now.month() as i32);
|
||
(0i32..12).rev().map(|m| {
|
||
let total = y0 * 12 + (m0 - 1) - m;
|
||
let (y, mon) = (total.div_euclid(12), total.rem_euclid(12) + 1);
|
||
let label = format!("{:04}-{:02}", y, mon);
|
||
const MONTHS: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||
let short = MONTHS[(mon - 1) as usize].to_string();
|
||
let count = raw.get(&label).copied().unwrap_or(0);
|
||
(short, count)
|
||
}).collect()
|
||
}
|
||
|
||
/// Commits per year across the repo's full history, oldest first.
|
||
pub fn activity_all(path: &std::path::Path) -> Vec<(String, usize)> {
|
||
let raw = git_activity_raw(path, None, "%Y");
|
||
let mut result: Vec<(String, usize)> = raw.into_iter().collect();
|
||
result.sort_by(|a, b| a.0.cmp(&b.0));
|
||
result
|
||
}
|
||
|
||
// ── repo age ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Timestamp of the repo's very first commit, found by walking `HEAD`'s
|
||
/// history to its end — used to compute repo age.
|
||
pub fn repo_first_commit_ts(path: &std::path::Path) -> Option<i64> {
|
||
let repo = Repository::open(path).ok()?;
|
||
let mut rw = repo.revwalk().ok()?;
|
||
rw.push_head().ok()?;
|
||
let oids: Vec<_> = rw.filter_map(|r| r.ok()).collect();
|
||
let last_oid = oids.last()?;
|
||
let commit = repo.find_commit(*last_oid).ok()?;
|
||
Some(commit.time().seconds())
|
||
}
|
||
|
||
// ── file tree ────────────────────────────────────────────────────────────────
|
||
|
||
/// Builds a flat, depth-ordered tree (directories before their children,
|
||
/// each subtree alphabetical) from a flat list of repo-relative file paths
|
||
/// — the shape the file-tree view renders directly without needing to walk
|
||
/// a real nested structure at render time.
|
||
pub fn build_tree(all_files: &[String]) -> Vec<TreeNode> {
|
||
let mut dirs: BTreeSet<String> = BTreeSet::new();
|
||
for f in all_files {
|
||
let parts: Vec<&str> = f.split('/').collect();
|
||
for i in 1..parts.len() {
|
||
dirs.insert(parts[..i].join("/"));
|
||
}
|
||
}
|
||
|
||
let mut dir_children: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||
dir_children.entry(String::new()).or_default();
|
||
|
||
for dir in &dirs {
|
||
let parent = dir.rfind('/').map(|i| &dir[..i]).unwrap_or("").to_string();
|
||
dir_children.entry(parent).or_default().push(dir.clone());
|
||
dir_children.entry(dir.clone()).or_default();
|
||
}
|
||
for f in all_files {
|
||
let parent = f.rfind('/').map(|i| &f[..i]).unwrap_or("").to_string();
|
||
dir_children.entry(parent).or_default().push(f.clone());
|
||
}
|
||
|
||
for children in dir_children.values_mut() {
|
||
children.sort_by(|a, b| {
|
||
let a_is_dir = dirs.contains(a.as_str());
|
||
let b_is_dir = dirs.contains(b.as_str());
|
||
b_is_dir.cmp(&a_is_dir).then(a.cmp(b))
|
||
});
|
||
children.dedup();
|
||
}
|
||
|
||
let mut result = Vec::new();
|
||
flatten_tree("", &dir_children, &dirs, 0, &mut result);
|
||
result
|
||
}
|
||
|
||
/// Recursively appends `prefix`'s children to `out` in display order, depth-first.
|
||
fn flatten_tree(
|
||
prefix: &str,
|
||
dir_children: &BTreeMap<String, Vec<String>>,
|
||
dirs: &BTreeSet<String>,
|
||
depth: usize,
|
||
out: &mut Vec<TreeNode>,
|
||
) {
|
||
let children = match dir_children.get(prefix) {
|
||
Some(c) => c,
|
||
None => return,
|
||
};
|
||
for child in children {
|
||
let is_dir = dirs.contains(child.as_str());
|
||
let name = child.rfind('/').map(|i| &child[i+1..]).unwrap_or(child).to_string();
|
||
out.push(TreeNode { name, rel_path: child.clone(), is_dir, depth, expanded: true });
|
||
if is_dir {
|
||
flatten_tree(child, dir_children, dirs, depth + 1, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── file preview ─────────────────────────────────────────────────────────────
|
||
|
||
/// Reads the first `max_lines` lines of `rel` for the file-tree preview
|
||
/// pane, detecting binary content via a null-byte check on the first 512
|
||
/// bytes rather than rendering it as garbled text.
|
||
pub fn read_file_preview(repo_path: &std::path::Path, rel: &str, max_lines: usize) -> Vec<String> {
|
||
let full = repo_path.join(rel);
|
||
let bytes = match std::fs::read(&full) {
|
||
Ok(b) => b,
|
||
Err(_) => return vec!["[cannot read file]".into()],
|
||
};
|
||
if bytes.iter().take(512).any(|&b| b == 0) {
|
||
return vec!["[binary file]".into()];
|
||
}
|
||
let text = String::from_utf8_lossy(&bytes);
|
||
text.lines().take(max_lines).map(|l| l.to_string()).collect()
|
||
}
|
||
|
||
// ── branch actions ───────────────────────────────────────────────────────────
|
||
|
||
/// Checks out `name`, returning whether the command succeeded.
|
||
pub fn checkout_branch(path: &std::path::Path, name: &str) -> bool {
|
||
git_run_ok(path, &["checkout", name])
|
||
}
|
||
|
||
/// Deletes local branch `name` (`git branch -d`, fails if unmerged).
|
||
pub fn delete_branch(path: &std::path::Path, name: &str) -> bool {
|
||
git_run_ok(path, &["branch", "-d", name])
|
||
}
|
||
|
||
/// Pushes `name` to `origin`, setting it as the upstream (`git push -u`).
|
||
pub fn push_branch(path: &std::path::Path, name: &str) -> bool {
|
||
git_run_ok(path, &["push", "-u", "origin", name])
|
||
}
|