//! 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 { let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; let mut commits: Vec = { 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 = 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 { 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::().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 { 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 { 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::())) .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 { 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::().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, date_fmt: &str) -> HashMap { 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 { 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 { let mut dirs: BTreeSet = 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> = 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>, dirs: &BTreeSet, depth: usize, out: &mut Vec, ) { 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 { 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]) }