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
128pub fn config_identity(root: &Path) -> Option<String> {
131 let get = |key: &str| {
132 run(root, &["config", key])
133 .ok()
134 .map(|s| s.trim().to_string())
135 .filter(|s| !s.is_empty())
136 };
137 match (get("user.name"), get("user.email")) {
138 (Some(name), Some(email)) => Some(format!("{name} <{email}>")),
139 (Some(name), None) => Some(name),
140 (None, Some(email)) => Some(email),
141 (None, None) => None,
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct GraphCommit {
148 pub hash: String,
150 pub short: String,
152 pub parents: Vec<String>,
154 pub refs: Vec<String>,
156 pub author_name: String,
158 pub date: String,
160 pub subject: String,
162 pub board: bool,
164}
165
166pub fn graph(root: &Path, limit: Option<usize>) -> Result<Vec<GraphCommit>> {
170 let board: std::collections::HashSet<String> = run(
172 root,
173 &["--no-pager", "log", "--all", "--format=%H", "--", ".wipe"],
174 )
175 .unwrap_or_default()
176 .lines()
177 .map(|s| s.trim().to_string())
178 .collect();
179
180 let format = format!("--format=%H{FS}%h{FS}%P{FS}%D{FS}%an{FS}%aI{FS}%s{RS}");
181 let mut args: Vec<String> = vec![
182 "--no-pager".into(),
183 "log".into(),
184 "--all".into(),
185 "--date-order".into(),
186 format,
187 "--no-color".into(),
188 ];
189 if let Some(l) = limit {
190 args.push("-n".into());
191 args.push(l.to_string());
192 }
193 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
194 let out = run(root, &refs)?;
195 Ok(out
196 .split(RS)
197 .map(str::trim)
198 .filter(|r| !r.is_empty())
199 .filter_map(|record| {
200 let mut f = record.split(FS);
201 let hash = f.next()?.to_string();
202 let short = f.next()?.to_string();
203 let parents = f
204 .next()?
205 .split_whitespace()
206 .map(|s| s.to_string())
207 .collect();
208 let refs = f
209 .next()
210 .unwrap_or("")
211 .split(',')
212 .map(|s| s.trim().to_string())
213 .filter(|s| !s.is_empty())
214 .collect();
215 let author_name = f.next().unwrap_or("").to_string();
216 let date = f.next().unwrap_or("").to_string();
217 let subject = f.next().unwrap_or("").to_string();
218 let board = board.contains(&hash);
219 Some(GraphCommit {
220 hash,
221 short,
222 parents,
223 refs,
224 author_name,
225 date,
226 subject,
227 board,
228 })
229 })
230 .collect())
231}
232
233fn parse_log(out: &str) -> Vec<CommitInfo> {
234 out.split(RS)
235 .map(str::trim)
236 .filter(|r| !r.is_empty())
237 .filter_map(|record| {
238 let mut f = record.split(FS);
239 Some(CommitInfo {
240 hash: f.next()?.to_string(),
241 short: f.next()?.to_string(),
242 author_name: f.next()?.to_string(),
243 author_email: f.next()?.to_string(),
244 date: f.next()?.to_string(),
245 subject: f.next().unwrap_or("").to_string(),
246 })
247 })
248 .collect()
249}
250
251fn plain(root: &Path) -> std::path::PathBuf {
253 let s = root.to_string_lossy();
254 match s.strip_prefix(r"\\?\") {
255 Some(rest) => std::path::PathBuf::from(rest),
256 None => root.to_path_buf(),
257 }
258}
259
260fn run(root: &Path, args: &[&str]) -> Result<String> {
263 let out = Command::new("git")
264 .arg("-C")
265 .arg(plain(root))
266 .args(args)
267 .output()
268 .map_err(|e| Error::msg(format!("failed to run git: {e}")))?;
269 if out.status.success() {
270 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
271 } else {
272 Err(Error::msg(
273 String::from_utf8_lossy(&out.stderr).trim().to_string(),
274 ))
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::process::Command;
282
283 fn git(root: &Path, args: &[&str]) {
284 let ok = Command::new("git")
285 .arg("-C")
286 .arg(root)
287 .args(args)
288 .output()
289 .unwrap()
290 .status
291 .success();
292 assert!(ok, "git {args:?} failed");
293 }
294
295 #[test]
296 fn log_and_show_roundtrip() {
297 let dir = tempfile::tempdir().unwrap();
298 let root = dir.path();
299 git(root, &["init", "-q"]);
300 git(root, &["config", "user.email", "t@example.com"]);
301 git(root, &["config", "user.name", "Tester"]);
302 std::fs::write(root.join("a.txt"), "v1\n").unwrap();
303 git(root, &["add", "."]);
304 git(root, &["commit", "-q", "-m", "first commit"]);
305
306 assert!(is_repo(root));
307 let history = log(root, None, None).unwrap();
308 assert_eq!(history.len(), 1);
309 assert_eq!(history[0].subject, "first commit");
310 assert_eq!(history[0].author_email, "t@example.com");
311
312 let head = &history[0].hash;
313 let content = file_at_commit(root, head, "a.txt").unwrap();
314 assert_eq!(content.as_deref(), Some("v1\n"));
315
316 assert_eq!(file_at_commit(root, head, "missing.txt").unwrap(), None);
318 }
319
320 #[test]
321 fn non_repo_reports_false() {
322 let dir = tempfile::tempdir().unwrap();
323 assert!(!is_repo(dir.path()));
324 }
325}