Skip to main content

specfold_core/workspace/vcs/
history.rs

1//! History: commits in a range, a file's revisions and when governed code last changed.
2
3use super::repo::{git, nearest_repo};
4use std::path::Path;
5
6pub struct CommitText {
7    pub hash: String,
8    pub author: String,
9    pub message: String,
10}
11
12/// Commits in a range with their author and message.
13pub fn commits(root: &Path, range: &str) -> Option<Vec<CommitText>> {
14    let out = git(root, &["log", "--format=%H%x00%an <%ae>%x00%B%x1e", range])?;
15    let text = String::from_utf8_lossy(&out).into_owned();
16    Some(
17        text.split('\u{1e}')
18            .filter_map(|rec| {
19                let rec = rec.trim_start_matches('\n');
20                let mut parts = rec.splitn(3, '\0');
21                let hash = parts.next()?.to_string();
22                if hash.is_empty() {
23                    return None;
24                }
25                Some(CommitText {
26                    hash,
27                    author: parts.next()?.to_string(),
28                    message: parts.next().unwrap_or("").to_string(),
29                })
30            })
31            .collect(),
32    )
33}
34
35/// The date (YYYY-MM-DD) of the last commit touching any of `paths`, which may be globs.
36pub fn last_change(root: &Path, paths: &[String]) -> Option<String> {
37    let mut args: Vec<String> = vec!["log".into(), "-1".into(), "--format=%cs".into(), "--".into()];
38    args.extend(paths.iter().map(|p| if p.contains(['*', '?', '[']) { format!(":(glob){p}") } else { p.clone() }));
39    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
40    let out = git(root, &refs)?;
41    let s = String::from_utf8_lossy(&out).trim().to_string();
42    if s.is_empty() { None } else { Some(s) }
43}
44
45/// Commits after `since` up to HEAD touching any of `paths` (globs allowed) outside `exclude`
46/// (globs), newest first, as short hashes; None when `since` is not a commit git knows.
47pub fn commits_since(root: &Path, since: &str, paths: &[String], exclude: &[String]) -> Option<Vec<String>> {
48    git(root, &["cat-file", "-e", &format!("{since}^{{commit}}")])?;
49    let range = format!("{since}..HEAD");
50    let mut args: Vec<String> = vec!["log".into(), "--format=%h".into(), range, "--".into()];
51    args.extend(paths.iter().map(|p| if p.contains(['*', '?', '[']) { format!(":(glob){p}") } else { p.clone() }));
52    args.extend(exclude.iter().map(|e| format!(":(exclude,glob){e}")));
53    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
54    let out = git(root, &refs)?;
55    Some(String::from_utf8_lossy(&out).lines().map(str::to_string).filter(|l| !l.is_empty()).collect())
56}
57
58pub struct Revision {
59    pub hash: String,
60    pub date: String,
61    pub author: String,
62    /// The file's path in that commit (it may have been renamed since).
63    pub path: String,
64}
65
66/// Every commit that touched `path`, oldest first, following renames.
67pub fn history(root: &Path, path: &str) -> Option<Vec<Revision>> {
68    let (repo, rel, prefix) = nearest_repo(root, path);
69    let out = git(&repo, &["log", "--follow", "--format=%x1e%H%x00%cs%x00%an", "--name-only", "--", &rel])?;
70    let text = String::from_utf8_lossy(&out).into_owned();
71    let mut revs: Vec<Revision> = text
72        .split('\u{1e}')
73        .filter_map(|rec| {
74            let mut lines = rec.lines();
75            let head = lines.next()?;
76            let mut parts = head.split('\0');
77            let hash = parts.next()?.to_string();
78            let date = parts.next()?.to_string();
79            let author = parts.next().unwrap_or("").to_string();
80            let path = format!("{prefix}{}", lines.find(|l| !l.trim().is_empty())?.trim());
81            Some(Revision { hash, date, author, path })
82        })
83        .collect();
84    revs.reverse();
85    Some(revs)
86}