Files
vulp/src/detail/git.rs
T
dualfroz 14ef7fb06c Sprint C: author-filtered stats scan, release cadence + repo size, weekly digest
- 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
2026-07-06 16:47:19 +02:00

459 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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])
}