1use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct LogRow {
11 pub text: String,
13 pub sha: Option<String>,
15}
16
17pub fn log_graph(workdir: &Path, max: usize, file: Option<&Path>) -> Result<Vec<LogRow>, String> {
20 log_graph_range(workdir, max, file, None)
21}
22
23pub fn log_graph_range(
27 workdir: &Path,
28 max: usize,
29 file: Option<&Path>,
30 range: Option<(usize, usize)>,
31) -> Result<Vec<LogRow>, String> {
32 let mut cmd = std::process::Command::new("git");
33 let (marker_fmt, ranged) = match range {
34 Some(_) => ("%x01%h %an · %ar · %s%x00%H", true),
35 None => ("%h %an · %ar · %s%x00%H", false),
36 };
37 cmd.arg("-C").arg(workdir).args([
41 "log",
42 &format!("--format={marker_fmt}"),
43 "-n",
44 &max.to_string(),
45 ]);
46 match (file, range) {
47 (Some(f), Some((a, b))) => {
48 let mut spec = std::ffi::OsString::from(format!("-L{a},{b}:"));
51 spec.push(f);
52 cmd.arg(spec);
53 }
54 (Some(f), None) => {
55 cmd.arg("--graph").arg("--").arg(f);
56 }
57 (None, None) => {
58 cmd.arg("--graph");
59 }
60 (None, Some(_)) => return Err("-L needs a file".into()),
61 }
62 let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
63 if !out.status.success() {
64 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
65 }
66 let text = String::from_utf8_lossy(&out.stdout);
67 Ok(text
68 .lines()
69 .filter(|line| !ranged || line.starts_with('\x01'))
71 .map(|line| {
72 let line = line.strip_prefix('\x01').unwrap_or(line);
73 let (vis, sha) = match line.split_once('\0') {
75 Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
76 None => (line.to_string(), None),
77 };
78 LogRow { text: vis, sha }
79 })
80 .collect())
81}
82
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct BlameCard {
86 pub sha: String,
87 pub short_sha: String,
88 pub author: String,
89 pub age: String,
90 pub summary: String,
91 pub line: usize,
92}
93
94pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
96 let out = std::process::Command::new("git")
97 .arg("-C")
98 .arg(workdir)
99 .args(["blame", "--line-porcelain", "-L", &format!("{line},{line}")])
100 .arg("--")
101 .arg(rel)
102 .output()
103 .map_err(|e| format!("spawn git blame: {e}"))?;
104 if !out.status.success() {
105 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
106 }
107 let text = String::from_utf8_lossy(&out.stdout);
108 let mut sha = String::new();
109 let mut author = String::new();
110 let mut summary = String::new();
111 let mut ts = 0i64;
112 for l in text.lines() {
113 if sha.is_empty()
114 && !l.starts_with('\t')
115 && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
116 {
117 sha = l.split_whitespace().next().unwrap_or("").to_string();
118 } else if let Some(a) = l.strip_prefix("author ") {
119 author = a.to_string();
120 } else if let Some(t) = l.strip_prefix("author-time ") {
121 ts = t.parse().unwrap_or(0);
122 } else if let Some(s) = l.strip_prefix("summary ") {
123 summary = s.to_string();
124 }
125 }
126 if sha.is_empty() {
127 return Err("no blame for line".into());
128 }
129 Ok(BlameCard {
130 short_sha: sha.chars().take(8).collect(),
131 sha,
132 author,
133 age: rel_age(ts),
134 summary,
135 line,
136 })
137}
138
139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
143pub struct BlameLine {
144 pub sha: String,
145 pub author: String,
146 pub age: String,
148 pub ts: i64,
150}
151
152impl BlameLine {
153 pub fn is_uncommitted(&self) -> bool {
155 !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
156 }
157}
158
159pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
162 let out = std::process::Command::new("git")
163 .arg("-C")
164 .arg(workdir)
165 .args(["blame", "--line-porcelain"])
166 .arg("--")
167 .arg(rel)
168 .output()
169 .map_err(|e| format!("spawn git blame: {e}"))?;
170 if !out.status.success() {
171 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
172 }
173 let mut lines = Vec::new();
174 let mut sha = String::new();
175 let mut author = String::new();
176 let mut ts = 0i64;
177 for l in String::from_utf8_lossy(&out.stdout).lines() {
178 if let Some(content) = l.strip_prefix('\t') {
179 let _ = content;
182 if !sha.is_empty() {
183 let uncommitted = sha.chars().all(|c| c == '0');
184 lines.push(BlameLine {
185 sha: sha.clone(),
186 age: if uncommitted {
187 "now".into()
188 } else {
189 rel_age(ts)
190 },
191 author: if uncommitted {
192 "you".into()
193 } else {
194 author.clone()
195 },
196 ts: if uncommitted { 0 } else { ts },
197 });
198 }
199 sha.clear();
200 author.clear();
201 ts = 0;
202 } else if sha.is_empty()
203 && !l.is_empty()
204 && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
205 {
206 sha = l.split_whitespace().next().unwrap_or("").to_string();
207 } else if let Some(a) = l.strip_prefix("author ") {
208 author = a.to_string();
209 } else if let Some(t) = l.strip_prefix("author-time ") {
210 ts = t.parse().unwrap_or(0);
211 }
212 }
213 if lines.is_empty() {
214 return Err("no blame for file".into());
215 }
216 Ok(lines)
217}
218
219#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct ChangedFile {
222 #[serde(with = "strop_core::path_serde")]
223 pub path: PathBuf,
224 pub added: usize,
225 pub deleted: usize,
226}
227
228pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
233 let out = std::process::Command::new("git")
234 .arg("-C")
235 .arg(workdir)
236 .args(["show", "--numstat", "-z", "--format=", sha])
237 .output()
238 .map_err(|e| format!("spawn git show: {e}"))?;
239 if !out.status.success() {
240 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
241 }
242 crate::numstat::parse_numstat(&out.stdout)
243}
244
245fn rel_age(ts: i64) -> String {
247 let now = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .map(|d| d.as_secs() as i64)
250 .unwrap_or(0);
251 let age = (now - ts).max(0);
252 match age {
253 a if a < 3600 => format!("{}m", a / 60),
254 a if a < 86400 => format!("{}h", a / 3600),
255 a if a < 86400 * 30 => format!("{}d", a / 86400),
256 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
257 a => format!("{}y", a / (86400 * 365)),
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::Repo;
265
266 #[test]
269 fn blame_file_attributes_lines() {
270 let dir = tempfile::tempdir().unwrap();
271 let root = dir.path();
272 let git = |args: &[&str]| {
273 std::process::Command::new("git")
274 .args(args)
275 .current_dir(root)
276 .output()
277 .unwrap();
278 };
279 git(&["init", "-q"]);
280 git(&["config", "user.email", "t@t.t"]);
281 git(&["config", "user.name", "t"]);
282 std::fs::write(root.join("f.rs"), "one\n").unwrap();
283 git(&["add", "."]);
284 git(&["commit", "-qm", "first"]);
285 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
286 git(&["commit", "-qam", "second"]);
287
288 let clean = blame_file(root, Path::new("f.rs")).unwrap();
289 assert_eq!(clean.len(), 2, "one BlameLine per file line");
290 assert_eq!(clean[0].author, "t");
291 assert_eq!(clean[1].author, "t");
292 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
293 assert!(!clean[0].is_uncommitted());
294
295 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
297 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
298 assert_eq!(dirty.len(), 3);
299 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
300 assert_eq!(dirty[2].age, "now");
301 assert_eq!(dirty[2].author, "you");
302 assert_eq!(dirty[2].ts, 0);
303 }
304
305 #[test]
306 fn blame_file_rejects_missing_file() {
307 let dir = tempfile::tempdir().unwrap();
308 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
309 }
310
311 fn git_here(root: &Path, args: &[&str]) -> String {
314 let out = std::process::Command::new("git")
315 .args(args)
316 .current_dir(root)
317 .env("HOME", root)
318 .env("XDG_CONFIG_HOME", root.join(".xdg"))
319 .env("GIT_CONFIG_NOSYSTEM", "1")
320 .env("GIT_CONFIG_GLOBAL", "/dev/null")
321 .output()
322 .unwrap();
323 assert!(
324 out.status.success(),
325 "git {args:?}: {}",
326 String::from_utf8_lossy(&out.stderr).trim()
327 );
328 String::from_utf8_lossy(&out.stdout).trim().to_string()
329 }
330
331 #[test]
337 fn show_stat_keeps_native_paths() {
338 let dir = tempfile::tempdir().unwrap();
339 let root = dir.path();
340 git_here(root, &["init", "-q"]);
341 git_here(root, &["config", "user.email", "t@t.t"]);
342 git_here(root, &["config", "user.name", "t"]);
343 std::fs::create_dir(root.join("src")).unwrap();
344 std::fs::write(root.join("a.rs"), "one\n").unwrap();
345 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
346 std::fs::write(root.join("ren.txt"), "old\n").unwrap();
347 std::fs::write(root.join("bin.dat"), b"\0\x01binary\0").unwrap();
348 git_here(root, &["add", "."]);
349 git_here(root, &["commit", "-qm", "first"]);
350 git_here(root, &["mv", "ren.txt", "new.txt"]);
351 std::fs::write(root.join("a.rs"), "one\ntwo\nthree\n").unwrap();
352 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\nfn y() {}\n").unwrap();
353 std::fs::write(root.join("bin.dat"), b"\0\x01changed\0").unwrap();
354 git_here(root, &["add", "."]);
355 git_here(root, &["commit", "-qm", "second"]);
356 let sha = git_here(root, &["rev-parse", "HEAD"]);
357
358 let files = show_stat(root, &sha).unwrap();
359 assert_eq!(files.len(), 4, "{files:?}");
360 let row = |p: &str| {
361 files
362 .iter()
363 .find(|f| f.path == Path::new(p))
364 .unwrap_or_else(|| panic!("missing {p} in {files:?}"))
365 };
366 assert_eq!(row("a.rs").added, 2);
367 assert_eq!(row("a.rs").deleted, 0);
368 assert_eq!(row("src/日本語.rs").added, 1);
370 assert_eq!(row("new.txt").added, 0);
372 assert!(!files.iter().any(|f| f.path == Path::new("ren.txt")));
373 assert_eq!((row("bin.dat").added, row("bin.dat").deleted), (0, 0));
375 assert!(files
376 .iter()
377 .all(|f| !f.path.to_string_lossy().starts_with('"')));
378 }
379
380 #[test]
383 fn show_stat_paths_feed_commit_file_diff() {
384 let dir = tempfile::tempdir().unwrap();
385 let root = dir.path();
386 git_here(root, &["init", "-q"]);
387 git_here(root, &["config", "user.email", "t@t.t"]);
388 git_here(root, &["config", "user.name", "t"]);
389 std::fs::create_dir(root.join("src")).unwrap();
390 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
391 git_here(root, &["add", "."]);
392 git_here(root, &["commit", "-qm", "first"]);
393 std::fs::write(
394 root.join("src/日本語.rs"),
395 "fn x() {}\nfn y() {}\nfn z() {}\n",
396 )
397 .unwrap();
398 git_here(root, &["commit", "-qam", "second"]);
399 let sha = git_here(root, &["rev-parse", "HEAD"]);
400
401 let files = show_stat(root, &sha).unwrap();
402 let uni = files
403 .iter()
404 .find(|f| f.path == Path::new("src/日本語.rs"))
405 .expect("native unicode path is a row");
406 let repo = Repo::discover(root).unwrap();
407 let diff = repo.commit_file_diff(&sha, &uni.path).unwrap();
408 assert_eq!(diff.added, 2);
409 assert_eq!(diff.deleted, 0);
410 }
411
412 #[cfg(unix)]
415 #[test]
416 fn show_stat_preserves_non_utf8_paths() {
417 use std::os::unix::ffi::OsStrExt;
418 let dir = tempfile::tempdir().unwrap();
419 let root = dir.path();
420 git_here(root, &["init", "-q"]);
421 git_here(root, &["config", "user.email", "t@t.t"]);
422 git_here(root, &["config", "user.name", "t"]);
423 std::fs::create_dir(root.join("src")).unwrap();
424 let name = std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs");
425 std::fs::write(root.join(name), "fn x() {}\n").unwrap();
426 git_here(root, &["add", "."]);
427 git_here(root, &["commit", "-qm", "first"]);
428 let sha = git_here(root, &["rev-parse", "HEAD"]);
429
430 let files = show_stat(root, &sha).unwrap();
431 assert_eq!(files.len(), 1, "{files:?}");
432 assert_eq!(files[0].path.as_os_str().as_bytes(), b"src/\xff\xfe.rs");
433 assert_eq!(files[0].added, 1);
434 }
435}