Skip to main content

specfold_core/workspace/
vcs.rs

1//! The little git specfold needs, through the git command line. Every function degrades to
2//! "unknown" when git is missing or the directory is not a repository.
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7fn git(root: &Path, args: &[&str]) -> Option<Vec<u8>> {
8    let out = Command::new("git").arg("-C").arg(root).args(args).output().ok()?;
9    if !out.status.success() {
10        return None;
11    }
12    Some(out.stdout)
13}
14
15fn split_nul(bytes: Vec<u8>) -> Vec<String> {
16    bytes.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).into_owned()).collect()
17}
18
19pub fn git_root(dir: &Path) -> Option<PathBuf> {
20    let out = git(dir, &["rev-parse", "--show-toplevel"])?;
21    let s = String::from_utf8(out).ok()?;
22    Some(PathBuf::from(s.trim()))
23}
24
25pub fn is_repo(dir: &Path) -> bool {
26    git(dir, &["rev-parse", "--git-dir"]).is_some()
27}
28
29/// Paths added, copied, modified or renamed in the index.
30pub fn staged(root: &Path) -> Option<Vec<String>> {
31    git(root, &["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"]).map(split_nul)
32}
33
34/// Paths changed between `base` and the working tree, plus untracked files.
35pub fn changed_since(root: &Path, base: &str) -> Option<Vec<String>> {
36    let mut files = split_nul(git(root, &["diff", "--name-only", "-z", "--diff-filter=ACMR", base])?);
37    if let Some(untracked) = git(root, &["ls-files", "-z", "--others", "--exclude-standard"]) {
38        files.extend(split_nul(untracked));
39    }
40    files.sort();
41    files.dedup();
42    Some(files)
43}
44
45/// Paths changed in a commit range such as `origin/main..HEAD`.
46pub fn changed_in_range(root: &Path, range: &str) -> Option<Vec<String>> {
47    git(root, &["diff", "--name-only", "-z", "--diff-filter=ACMR", range]).map(split_nul)
48}
49
50/// The content of `path` at `rev`, if it existed there.
51pub fn show(root: &Path, rev: &str, path: &str) -> Option<String> {
52    let spec = format!("{rev}:{path}");
53    git(root, &["show", &spec]).map(|b| String::from_utf8_lossy(&b).into_owned())
54}
55
56/// The staged content of `path`.
57pub fn show_staged(root: &Path, path: &str) -> Option<String> {
58    let spec = format!(":{path}");
59    git(root, &["show", &spec]).map(|b| String::from_utf8_lossy(&b).into_owned())
60}
61
62pub struct CommitText {
63    pub hash: String,
64    pub author: String,
65    pub message: String,
66}
67
68/// Commits in a range with their author and message.
69pub fn commits(root: &Path, range: &str) -> Option<Vec<CommitText>> {
70    let out = git(root, &["log", "--format=%H%x00%an <%ae>%x00%B%x1e", range])?;
71    let text = String::from_utf8_lossy(&out).into_owned();
72    Some(
73        text.split('\u{1e}')
74            .filter_map(|rec| {
75                let rec = rec.trim_start_matches('\n');
76                let mut parts = rec.splitn(3, '\0');
77                let hash = parts.next()?.to_string();
78                if hash.is_empty() {
79                    return None;
80                }
81                Some(CommitText {
82                    hash,
83                    author: parts.next()?.to_string(),
84                    message: parts.next().unwrap_or("").to_string(),
85                })
86            })
87            .collect(),
88    )
89}
90
91/// The upstream of the current branch, used as the default push range base.
92pub fn upstream(root: &Path) -> Option<String> {
93    let out = git(root, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])?;
94    let s = String::from_utf8(out).ok()?.trim().to_string();
95    if s.is_empty() { None } else { Some(s) }
96}
97
98/// The date (YYYY-MM-DD) of the last commit touching any of `paths`, which may be globs.
99pub fn last_change(root: &Path, paths: &[String]) -> Option<String> {
100    let mut args: Vec<String> = vec!["log".into(), "-1".into(), "--format=%cs".into(), "--".into()];
101    args.extend(paths.iter().map(|p| if p.contains(['*', '?', '[']) { format!(":(glob){p}") } else { p.clone() }));
102    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
103    let out = git(root, &refs)?;
104    let s = String::from_utf8_lossy(&out).trim().to_string();
105    if s.is_empty() { None } else { Some(s) }
106}
107
108pub struct Revision {
109    pub hash: String,
110    pub date: String,
111    pub author: String,
112    /// The file's path in that commit (it may have been renamed since).
113    pub path: String,
114}
115
116/// Every commit that touched `path`, oldest first, following renames.
117pub fn history(root: &Path, path: &str) -> Option<Vec<Revision>> {
118    let out = git(root, &["log", "--follow", "--format=%x1e%H%x00%cs%x00%an", "--name-only", "--", path])?;
119    let text = String::from_utf8_lossy(&out).into_owned();
120    let mut revs: Vec<Revision> = text
121        .split('\u{1e}')
122        .filter_map(|rec| {
123            let mut lines = rec.lines();
124            let head = lines.next()?;
125            let mut parts = head.split('\0');
126            let hash = parts.next()?.to_string();
127            let date = parts.next()?.to_string();
128            let author = parts.next().unwrap_or("").to_string();
129            let path = lines.find(|l| !l.trim().is_empty())?.trim().to_string();
130            Some(Revision { hash, date, author, path })
131        })
132        .collect();
133    revs.reverse();
134    Some(revs)
135}
136
137/// The date of the first commit that added `path`, as `YYYYMMDDHHMMSS` UTC.
138pub fn first_commit_stamp(root: &Path, path: &str) -> Option<String> {
139    let out = git(root, &["log", "--diff-filter=A", "--follow", "--format=%at", "--", path])?;
140    let text = String::from_utf8_lossy(&out);
141    let secs: i64 = text.lines().last()?.trim().parse().ok()?;
142    let t = time::OffsetDateTime::from_unix_timestamp(secs).ok()?;
143    let f = time::macros::format_description!("[year][month][day][hour][minute][second]");
144    t.format(f).ok()
145}