1use std::path::Path;
9use std::process::Command;
10
11use serde::Serialize;
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct CommitInfo {
18 pub hash: String,
20 pub short: String,
22 pub author_name: String,
24 pub author_email: String,
26 pub date: String,
28 pub subject: String,
30}
31
32const FS: char = '\u{1f}';
34const RS: char = '\u{1e}';
35
36pub fn is_repo(root: &Path) -> bool {
38 run(root, &["rev-parse", "--is-inside-work-tree"])
39 .map(|o| o.trim() == "true")
40 .unwrap_or(false)
41}
42
43pub fn log(root: &Path, pathspec: Option<&str>, limit: Option<usize>) -> Result<Vec<CommitInfo>> {
46 let format = format!("--format=%H{FS}%h{FS}%an{FS}%ae{FS}%aI{FS}%s{RS}");
47 let mut args: Vec<String> = vec![
48 "--no-pager".into(),
49 "log".into(),
50 format,
51 "--no-color".into(),
52 ];
53 if let Some(l) = limit {
54 args.push("-n".into());
55 args.push(l.to_string());
56 }
57 if let Some(p) = pathspec {
58 args.push("--".into());
59 args.push(p.to_string());
60 }
61 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
62 let out = run(root, &refs)?;
63 Ok(parse_log(&out))
64}
65
66pub fn file_at_commit(root: &Path, rev: &str, relpath: &str) -> Result<Option<String>> {
69 let spec = format!("{rev}:{}", relpath.replace('\\', "/"));
71 match run(root, &["--no-pager", "show", &spec]) {
72 Ok(s) => Ok(Some(s)),
73 Err(Error::Message(_)) => Ok(None),
75 Err(e) => Err(e),
76 }
77}
78
79pub fn last_change(root: &Path, relpath: &str) -> Result<Option<CommitInfo>> {
81 Ok(log(root, Some(relpath), Some(1))?.into_iter().next())
82}
83
84pub fn blob_hash(bytes: &[u8]) -> String {
86 use sha1::{Digest, Sha1};
87 let mut h = Sha1::new();
88 h.update(format!("blob {}\0", bytes.len()).as_bytes());
89 h.update(bytes);
90 format!("{:x}", h.finalize())
91}
92
93pub fn tracked_blobs(root: &Path) -> Result<Vec<(String, String)>> {
95 let out = run(root, &["ls-files", "-s"])?;
96 let mut blobs = Vec::new();
97 for line in out.lines() {
98 if let Some((meta, path)) = line.split_once('\t') {
100 let mut cols = meta.split_whitespace();
101 let _mode = cols.next();
102 if let Some(hash) = cols.next() {
103 blobs.push((hash.to_string(), path.to_string()));
104 }
105 }
106 }
107 Ok(blobs)
108}
109
110pub fn authors(root: &Path) -> Result<Vec<(String, String)>> {
112 let out = run(
113 root,
114 &["--no-pager", "log", &format!("--format=%an{FS}%ae")],
115 )?;
116 let mut seen = std::collections::HashSet::new();
117 let mut authors = Vec::new();
118 for line in out.lines() {
119 if let Some((name, email)) = line.split_once(FS) {
120 if seen.insert(email.to_string()) {
121 authors.push((name.to_string(), email.to_string()));
122 }
123 }
124 }
125 Ok(authors)
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct GraphCommit {
131 pub hash: String,
133 pub short: String,
135 pub parents: Vec<String>,
137 pub refs: Vec<String>,
139 pub author_name: String,
141 pub date: String,
143 pub subject: String,
145 pub board: bool,
147}
148
149pub fn graph(root: &Path, limit: Option<usize>) -> Result<Vec<GraphCommit>> {
153 let board: std::collections::HashSet<String> = run(
155 root,
156 &["--no-pager", "log", "--all", "--format=%H", "--", ".wipe"],
157 )
158 .unwrap_or_default()
159 .lines()
160 .map(|s| s.trim().to_string())
161 .collect();
162
163 let format = format!("--format=%H{FS}%h{FS}%P{FS}%D{FS}%an{FS}%aI{FS}%s{RS}");
164 let mut args: Vec<String> = vec![
165 "--no-pager".into(),
166 "log".into(),
167 "--all".into(),
168 "--date-order".into(),
169 format,
170 "--no-color".into(),
171 ];
172 if let Some(l) = limit {
173 args.push("-n".into());
174 args.push(l.to_string());
175 }
176 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
177 let out = run(root, &refs)?;
178 Ok(out
179 .split(RS)
180 .map(str::trim)
181 .filter(|r| !r.is_empty())
182 .filter_map(|record| {
183 let mut f = record.split(FS);
184 let hash = f.next()?.to_string();
185 let short = f.next()?.to_string();
186 let parents = f
187 .next()?
188 .split_whitespace()
189 .map(|s| s.to_string())
190 .collect();
191 let refs = f
192 .next()
193 .unwrap_or("")
194 .split(',')
195 .map(|s| s.trim().to_string())
196 .filter(|s| !s.is_empty())
197 .collect();
198 let author_name = f.next().unwrap_or("").to_string();
199 let date = f.next().unwrap_or("").to_string();
200 let subject = f.next().unwrap_or("").to_string();
201 let board = board.contains(&hash);
202 Some(GraphCommit {
203 hash,
204 short,
205 parents,
206 refs,
207 author_name,
208 date,
209 subject,
210 board,
211 })
212 })
213 .collect())
214}
215
216fn parse_log(out: &str) -> Vec<CommitInfo> {
217 out.split(RS)
218 .map(str::trim)
219 .filter(|r| !r.is_empty())
220 .filter_map(|record| {
221 let mut f = record.split(FS);
222 Some(CommitInfo {
223 hash: f.next()?.to_string(),
224 short: f.next()?.to_string(),
225 author_name: f.next()?.to_string(),
226 author_email: f.next()?.to_string(),
227 date: f.next()?.to_string(),
228 subject: f.next().unwrap_or("").to_string(),
229 })
230 })
231 .collect()
232}
233
234fn plain(root: &Path) -> std::path::PathBuf {
236 let s = root.to_string_lossy();
237 match s.strip_prefix(r"\\?\") {
238 Some(rest) => std::path::PathBuf::from(rest),
239 None => root.to_path_buf(),
240 }
241}
242
243fn run(root: &Path, args: &[&str]) -> Result<String> {
246 let out = Command::new("git")
247 .arg("-C")
248 .arg(plain(root))
249 .args(args)
250 .output()
251 .map_err(|e| Error::msg(format!("failed to run git: {e}")))?;
252 if out.status.success() {
253 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
254 } else {
255 Err(Error::msg(
256 String::from_utf8_lossy(&out.stderr).trim().to_string(),
257 ))
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::process::Command;
265
266 fn git(root: &Path, args: &[&str]) {
267 let ok = Command::new("git")
268 .arg("-C")
269 .arg(root)
270 .args(args)
271 .output()
272 .unwrap()
273 .status
274 .success();
275 assert!(ok, "git {args:?} failed");
276 }
277
278 #[test]
279 fn log_and_show_roundtrip() {
280 let dir = tempfile::tempdir().unwrap();
281 let root = dir.path();
282 git(root, &["init", "-q"]);
283 git(root, &["config", "user.email", "t@example.com"]);
284 git(root, &["config", "user.name", "Tester"]);
285 std::fs::write(root.join("a.txt"), "v1\n").unwrap();
286 git(root, &["add", "."]);
287 git(root, &["commit", "-q", "-m", "first commit"]);
288
289 assert!(is_repo(root));
290 let history = log(root, None, None).unwrap();
291 assert_eq!(history.len(), 1);
292 assert_eq!(history[0].subject, "first commit");
293 assert_eq!(history[0].author_email, "t@example.com");
294
295 let head = &history[0].hash;
296 let content = file_at_commit(root, head, "a.txt").unwrap();
297 assert_eq!(content.as_deref(), Some("v1\n"));
298
299 assert_eq!(file_at_commit(root, head, "missing.txt").unwrap(), None);
301 }
302
303 #[test]
304 fn non_repo_reports_false() {
305 let dir = tempfile::tempdir().unwrap();
306 assert!(!is_repo(dir.path()));
307 }
308}