specfold_core/workspace/
vcs.rs1use 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
29pub fn staged(root: &Path) -> Option<Vec<String>> {
31 git(root, &["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"]).map(split_nul)
32}
33
34pub 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
45pub 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
50pub 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
56pub 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
68pub 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
91pub 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
98pub 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 pub path: String,
114}
115
116pub 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
137pub 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}