1use anyhow::{bail, Context, Result};
2use chrono::DateTime;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use tokio::process::Command;
7use tracing::warn;
8
9use super::types::{BlameResult, CommitInfo};
10use crate::call;
11use crate::errors::GitCallError;
12
13const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
15
16pub(crate) fn path_from_bytes(bytes: &[u8]) -> PathBuf {
19 #[cfg(unix)]
20 {
21 use std::os::unix::ffi::OsStrExt;
22 PathBuf::from(OsStr::from_bytes(bytes))
23 }
24 #[cfg(not(unix))]
25 {
26 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
27 }
28}
29
30pub(crate) fn path_to_bytes(path: &Path) -> Vec<u8> {
32 #[cfg(unix)]
33 {
34 use std::os::unix::ffi::OsStrExt;
35 path.as_os_str().as_bytes().to_vec()
36 }
37 #[cfg(not(unix))]
38 {
39 path.to_string_lossy().replace('\\', "/").into_bytes()
40 }
41}
42
43pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String> {
44 let reference = format!("refs/heads/{branch}");
45 let output = Command::new("git")
46 .arg("-C")
47 .arg(repo_path)
48 .args(["rev-parse", &reference])
49 .output()
50 .await
51 .context("running git rev-parse")?;
52
53 if !output.status.success() {
54 bail!(
55 "git rev-parse failed: {}",
56 String::from_utf8_lossy(&output.stderr)
57 );
58 }
59
60 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
61}
62
63pub async fn reset_hard(repo_path: &Path) -> Result<()> {
65 let root = repo_path
66 .canonicalize()
67 .with_context(|| format!("resolving {}", repo_path.display()))?;
68 let mut safe_directory = OsString::from("safe.directory=");
69 safe_directory.push(&root);
70 let output = Command::new("git")
71 .arg("-c")
72 .arg(&safe_directory)
73 .args([
74 "-c",
75 "core.fsmonitor=false",
76 "-c",
77 "core.hooksPath=/dev/null",
78 ])
79 .arg("-C")
80 .arg(&root)
81 .args(["reset", "--hard", "HEAD"])
82 .output()
83 .await
84 .context("running git reset --hard")?;
85
86 if !output.status.success() {
87 bail!(
88 "git reset --hard failed: {}",
89 String::from_utf8_lossy(&output.stderr)
90 );
91 }
92 Ok(())
93}
94
95pub async fn disable_quotepath(repo_path: &Path) -> Result<()> {
96 let mut git_dir = OsString::from("--git-dir=");
97 git_dir.push(repo_path);
98 let output = tokio::time::timeout(
99 Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS),
100 Command::new("git")
101 .arg(&git_dir)
102 .args(["config", "core.quotepath", "off"])
103 .kill_on_drop(true)
104 .output(),
105 )
106 .await
107 .context("git config core.quotepath timed out")?
108 .context("running git config core.quotepath")?;
109
110 if !output.status.success() {
111 bail!(
112 "git config core.quotepath failed: {}",
113 String::from_utf8_lossy(&output.stderr)
114 );
115 }
116 Ok(())
117}
118
119pub async fn get_last_commit_info(repo_path: &Path, filename: &Path) -> Result<Option<CommitInfo>> {
120 let output = Command::new("git")
121 .arg("-C")
122 .arg(repo_path)
123 .args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
124 .arg(filename)
125 .output()
126 .await
127 .context("running git log")?;
128
129 let stderr = String::from_utf8_lossy(&output.stderr);
130 if !output.status.success() {
131 warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git log failed");
132 return Ok(None);
133 }
134
135 let stdout = String::from_utf8_lossy(&output.stdout);
136 let stdout = stdout.trim();
137 if stdout.is_empty() {
138 return Ok(None);
139 }
140
141 let lines: Vec<&str> = stdout.lines().collect();
142 if lines.len() < 3 {
143 return Ok(None);
144 }
145
146 let date = DateTime::parse_from_rfc3339(lines.get(2).copied().unwrap_or(""))
147 .context("parsing the committer date of git log")?;
148
149 Ok(Some(CommitInfo {
150 sha: lines.first().copied().unwrap_or("").to_owned(),
151 author_email: lines.get(1).copied().unwrap_or("").to_owned(),
152 date,
153 }))
154}
155
156pub async fn get_line_author(
157 repo_path: &Path,
158 filename: &Path,
159 line: usize,
160 rev: &str,
161) -> Result<Option<CommitInfo>> {
162 let line_spec = format!("{line},+1");
163 let output = Command::new("git")
164 .arg("-C")
165 .arg(repo_path)
166 .args([
167 "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
168 ])
169 .arg(filename)
170 .output()
171 .await
172 .context("running git blame")?;
173
174 let stderr = String::from_utf8_lossy(&output.stderr);
175 if !output.status.success() {
176 warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame failed");
177 return Ok(None);
178 }
179
180 let stdout = String::from_utf8_lossy(&output.stdout);
181 parse_blame_porcelain(&stdout)
182}
183
184pub async fn get_modified_filenames(
185 repo_path: &Path,
186 commit_sha: &str,
187) -> Result<Vec<PathBuf>, GitCallError> {
188 let mut git = Command::new("git");
189 git.current_dir(repo_path).args([
190 "diff",
191 "--name-only",
192 "--end-of-options",
193 &format!("{commit_sha}..HEAD"),
194 ]);
195 let output = call::run("diff", &mut git).await?;
196
197 Ok(output
198 .stdout
199 .split(|byte| *byte == b'\n')
200 .filter(|line| !line.is_empty())
201 .map(path_from_bytes)
202 .collect())
203}
204
205pub async fn is_commit_in_branch(
206 repo_path: &Path,
207 branch: &str,
208 commit_sha: &str,
209) -> Result<bool, GitCallError> {
210 let mut git = Command::new("git");
211 git.current_dir(repo_path).args([
213 "merge-base",
214 "--is-ancestor",
215 "--end-of-options",
216 commit_sha,
217 &format!("refs/heads/{branch}"),
218 ]);
219 match call::run("merge-base", &mut git).await {
220 Ok(_) => Ok(true),
221 Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
222 Err(error) => Err(error),
223 }
224}
225
226pub async fn show_file_at_rev(
227 repo_path: &Path,
228 rev: &str,
229 file_path: &Path,
230) -> Result<Option<String>> {
231 let mut rev_path = OsString::from(format!("{rev}:"));
232 rev_path.push(file_path);
233 let output = Command::new("git")
234 .arg("-C")
235 .arg(repo_path)
236 .arg("show")
237 .arg(&rev_path)
238 .output()
239 .await
240 .context("running git show")?;
241
242 if !output.status.success() {
243 return Ok(None);
244 }
245
246 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
247}
248
249#[allow(clippy::too_many_arguments)]
250pub async fn blame_reverse(
251 repo_path: &Path,
252 file_path: &Path,
253 line: usize,
254 rev_a: &str,
255 rev_b: &str,
256 enable_copy_detection: bool,
257) -> Result<Option<BlameResult>> {
258 let line_spec = format!("{line},+1");
259 let rev_range = format!("{rev_a}..{rev_b}");
260
261 let mut args: Vec<&OsStr> = vec![
262 OsStr::new("-C"),
263 repo_path.as_os_str(),
264 OsStr::new("-c"),
265 OsStr::new("core.quotepath=off"),
266 OsStr::new("blame"),
267 OsStr::new("-p"),
268 OsStr::new("-M"),
269 ];
270 if enable_copy_detection {
271 args.push(OsStr::new("-C"));
272 }
273 args.extend([
274 OsStr::new("-L"),
275 OsStr::new(&line_spec),
276 OsStr::new("--reverse"),
277 OsStr::new(&rev_range),
278 OsStr::new("--"),
279 file_path.as_os_str(),
280 ]);
281
282 let output = Command::new("git")
283 .args(&args)
284 .output()
285 .await
286 .context("running git blame --reverse")?;
287
288 if !output.status.success() {
289 warn!(
290 repo_path = %repo_path.display(),
291 file_path = %file_path.display(),
292 line,
293 rev_range = %rev_range,
294 stderr = %String::from_utf8_lossy(&output.stderr),
295 "git blame --reverse failed",
296 );
297 return Ok(None);
298 }
299
300 Ok(parse_blame_reverse_porcelain(&output.stdout))
301}
302
303pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
304 let mut sha = String::new();
305 let mut author_mail = String::new();
306 let mut committer_time: Option<&str> = None;
307
308 for line in output.lines() {
309 if sha.is_empty() && line.len() >= 40 {
310 line.split_whitespace()
311 .next()
312 .unwrap_or("")
313 .clone_into(&mut sha);
314 } else if let Some(val) = line.strip_prefix("author-mail <") {
315 val.trim_end_matches('>').clone_into(&mut author_mail);
316 } else if let Some(val) = line.strip_prefix("committer-time ") {
317 committer_time = Some(val);
318 }
319 }
320
321 if sha.is_empty() {
322 return Ok(None);
323 }
324
325 let date = match committer_time {
326 None => DateTime::UNIX_EPOCH.fixed_offset(),
327 Some(seconds) => seconds
328 .parse::<i64>()
329 .ok()
330 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
331 .context("parsing the committer time of git blame")?
332 .fixed_offset(),
333 };
334
335 Ok(Some(CommitInfo {
336 sha,
337 author_email: author_mail,
338 date,
339 }))
340}
341
342fn read_octal_escape(
343 first: u8,
344 bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
345) -> u8 {
346 let mut code = u32::from(first.saturating_sub(b'0'));
347 for _ in 0..2 {
348 let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
349 break;
350 };
351 bytes.next();
352 code = code
353 .saturating_mul(8)
354 .saturating_add(u32::from(digit.saturating_sub(b'0')));
355 }
356 u8::try_from(code).unwrap_or(b'?')
357}
358
359fn unquote_path(value: &[u8]) -> PathBuf {
360 let Some(inner) = value
361 .strip_prefix(b"\"")
362 .and_then(|rest| rest.strip_suffix(b"\""))
363 else {
364 return path_from_bytes(value);
365 };
366
367 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
368 let mut bytes = inner.iter().copied().peekable();
369 while let Some(byte) = bytes.next() {
370 if byte != b'\\' {
371 out.push(byte);
372 continue;
373 }
374 match bytes.next() {
375 Some(b'n') => out.push(b'\n'),
376 Some(b't') => out.push(b'\t'),
377 Some(b'r') => out.push(b'\r'),
378 Some(b'a') => out.push(0x07),
379 Some(b'b') => out.push(0x08),
380 Some(b'f') => out.push(0x0c),
381 Some(b'v') => out.push(0x0b),
382 Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
383 Some(other) => out.push(other),
384 None => out.push(b'\\'),
385 }
386 }
387
388 path_from_bytes(&out)
389}
390
391pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
392 let mut rev = String::new();
393 let mut line_num: usize = 0;
394 let mut path = PathBuf::new();
395
396 for line in output.split(|byte| *byte == b'\n') {
397 if rev.is_empty() && line.len() >= 40 {
398 let header = String::from_utf8_lossy(line);
399 let parts: Vec<&str> = header.split_whitespace().collect();
400 if let Some(hash) = parts.first() {
401 (*hash).clone_into(&mut rev);
402 }
403 if let Some(ln) = parts.get(1) {
404 line_num = ln.parse().unwrap_or(0);
405 }
406 } else if let Some(val) = line.strip_prefix(b"filename ") {
407 path = unquote_path(val);
408 }
409 }
410
411 if rev.is_empty() {
412 return None;
413 }
414
415 Some(BlameResult {
416 rev,
417 line: line_num,
418 path,
419 })
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_parse_blame_porcelain_valid() {
428 let output = "\
429abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
430author Test\n\
431author-mail <dev@example.com>\n\
432author-time 1700000000\n\
433author-tz +0000\n\
434committer Test\n\
435committer-mail <dev@example.com>\n\
436committer-time 1700000000\n\
437committer-tz +0000\n\
438summary test commit\n\
439filename file.txt\n\
440\tcode line\n";
441 let result = parse_blame_porcelain(output).unwrap().unwrap();
442 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
443 assert_eq!(result.author_email, "dev@example.com");
444 assert_eq!(result.date.timestamp(), 1_700_000_000);
445 }
446
447 #[test]
448 fn test_parse_blame_porcelain_without_a_committer_time() {
449 let output = "\
450abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
451author-mail <dev@example.com>\n\
452filename file.txt\n\
453\tcode line\n";
454 let result = parse_blame_porcelain(output).unwrap().unwrap();
455 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
456 assert_eq!(result.author_email, "dev@example.com");
457 assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
458 }
459
460 #[test]
461 fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
462 let output = "\
463abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
464author-mail <dev@example.com>\n\
465committer-time 99999999999999\n\
466filename file.txt\n\
467\tcode line\n";
468 assert!(parse_blame_porcelain(output).is_err());
469 }
470
471 #[test]
472 fn test_parse_blame_porcelain_empty() {
473 assert!(parse_blame_porcelain("").unwrap().is_none());
474 }
475
476 #[test]
477 fn test_parse_blame_reverse_porcelain_valid() {
478 let output = b"\
479abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
480author Test\n\
481filename renamed.py\n";
482 let result = parse_blame_reverse_porcelain(output).unwrap();
483 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
484 assert_eq!(result.line, 7);
485 assert_eq!(result.path, Path::new("renamed.py"));
486 }
487
488 #[test]
489 fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
490 let output = b"\
491abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
492filename \"se\\303\\261al.txt\"\n";
493 let result = parse_blame_reverse_porcelain(output).unwrap();
494 assert_eq!(result.path, Path::new("seƱal.txt"));
495 }
496
497 #[test]
498 #[cfg(unix)]
499 fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
500 use std::os::unix::ffi::OsStrExt;
501
502 let output = b"\
503abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
504filename sel\xe1l.txt\n";
505 let result = parse_blame_reverse_porcelain(output).unwrap();
506 assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
507 }
508
509 #[test]
510 fn a_path_git_left_alone_is_taken_as_it_is() {
511 assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
512 assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
513 assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
514 }
515
516 #[test]
517 fn test_parse_blame_reverse_porcelain_empty() {
518 assert!(parse_blame_reverse_porcelain(b"").is_none());
519 }
520
521 fn git(repo: &Path, args: &[&str]) -> String {
522 let output = std::process::Command::new("git")
523 .arg("-C")
524 .arg(repo)
525 .args([
526 "-c",
527 "user.email=dev@fluidattacks.com",
528 "-c",
529 "user.name=dev",
530 ])
531 .args(args)
532 .output()
533 .unwrap();
534 assert!(output.status.success());
535 String::from_utf8(output.stdout).unwrap().trim().to_owned()
536 }
537
538 #[tokio::test]
539 async fn a_warning_from_git_does_not_hide_its_answer() {
540 let dir = tempfile::tempdir().unwrap();
541 std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
542
543 let repo = dir.path().join("repo");
544 std::fs::create_dir_all(&repo).unwrap();
545 git(&repo, &["init", "-q", "-b", "main"]);
546 std::fs::write(repo.join("f.txt"), b"first").unwrap();
547 git(&repo, &["add", "-A"]);
548 git(&repo, &["commit", "-q", "-m", "first"]);
549 let first = git(&repo, &["rev-parse", "HEAD"]);
550 std::fs::write(repo.join("g.txt"), b"second").unwrap();
551 git(&repo, &["add", "-A"]);
552 git(&repo, &["commit", "-q", "-m", "second"]);
553
554 git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
556 git(
557 &repo,
558 &["update-ref", &format!("refs/heads/{first}"), &first],
559 );
560
561 let last = get_last_commit_info(&repo, Path::new("f.txt"))
562 .await
563 .unwrap();
564 assert_eq!(last.map(|info| info.sha), Some(first.clone()));
565
566 let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
567 .await
568 .unwrap();
569 assert_eq!(author.map(|info| info.sha), Some(first.clone()));
570
571 let modified = get_modified_filenames(&repo, &first).await.unwrap();
572 assert_eq!(modified, vec![PathBuf::from("g.txt")]);
573
574 assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
575 }
576
577 fn two_branches(repo: &Path) -> (String, String) {
578 std::fs::create_dir_all(repo).unwrap();
579 git(repo, &["init", "-q", "-b", "main"]);
580 git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
581 let on_main = git(repo, &["rev-parse", "HEAD"]);
582 git(repo, &["checkout", "-q", "-b", "main-old"]);
583 git(
584 repo,
585 &["commit", "-q", "--allow-empty", "-m", "on main-old"],
586 );
587 let on_main_old = git(repo, &["rev-parse", "HEAD"]);
588 (on_main, on_main_old)
589 }
590
591 #[tokio::test]
592 async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
593 let dir = tempfile::tempdir().unwrap();
594 let repo = dir.path().join("repo");
595 let (on_main, on_main_old) = two_branches(&repo);
596
597 assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
598 assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
599 .await
600 .unwrap());
601 }
602
603 #[tokio::test]
604 async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
605 let dir = tempfile::tempdir().unwrap();
606 let repo = dir.path().join("repo");
607 let (on_main, _) = two_branches(&repo);
608
609 for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
610 let outcome = is_commit_in_branch(&repo, branch, &commit).await;
611
612 assert!(matches!(
613 outcome,
614 Err(GitCallError::Refused { command: "merge-base", status, .. })
615 if status.code() == Some(128)
616 ));
617 }
618 }
619
620 #[tokio::test]
621 async fn a_diff_from_a_commit_the_repository_lacks_is_refused() {
622 let dir = tempfile::tempdir().unwrap();
623 let repo = dir.path().join("repo");
624 two_branches(&repo);
625
626 let outcome = get_modified_filenames(&repo, &"0".repeat(40)).await;
627
628 assert!(matches!(
629 outcome,
630 Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
631 ));
632 }
633
634 #[tokio::test]
635 async fn a_commit_that_looks_like_an_option_is_not_read_as_one() {
636 let dir = tempfile::tempdir().unwrap();
637 let repo = dir.path().join("repo");
638 two_branches(&repo);
639 let leak = dir.path().join("leak");
640
641 let outcome = get_modified_filenames(&repo, &format!("--output={}", leak.display())).await;
642
643 assert!(matches!(
644 outcome,
645 Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
646 ));
647 assert!(!dir.path().join("leak..HEAD").exists());
648 }
649
650 #[tokio::test]
651 async fn a_modified_name_is_cut_only_at_a_newline() {
652 let dir = tempfile::tempdir().unwrap();
653 let repo = dir.path().join("repo");
654 let (on_main, _) = two_branches(&repo);
655 git(&repo, &["config", "core.quotepath", "off"]);
656 std::fs::write(repo.join("a\u{85}b.txt"), b"content").unwrap();
657 git(&repo, &["add", "-A"]);
658 git(&repo, &["commit", "-q", "-m", "next line in a name"]);
659
660 let modified = get_modified_filenames(&repo, &on_main).await.unwrap();
661
662 assert_eq!(modified, vec![PathBuf::from("a\u{85}b.txt")]);
663 }
664
665 #[tokio::test]
666 async fn a_repository_path_that_does_not_exist_does_not_start() {
667 let dir = tempfile::tempdir().unwrap();
668 let missing = dir.path().join("missing");
669
670 let in_branch = is_commit_in_branch(&missing, "main", "HEAD").await;
671 let modified = get_modified_filenames(&missing, "HEAD").await;
672
673 assert!(matches!(
674 in_branch,
675 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
676 ));
677 assert!(matches!(
678 modified,
679 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
680 ));
681 }
682}