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