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(repo_path: &Path, commit_sha: &str) -> Result<Vec<PathBuf>> {
185 let output = Command::new("git")
186 .arg("-C")
187 .arg(repo_path)
188 .args(["diff", "--name-only", &format!("{commit_sha}..HEAD")])
189 .output()
190 .await
191 .context("running git diff --name-only")?;
192
193 let stderr = String::from_utf8_lossy(&output.stderr);
194 if !output.status.success() {
195 warn!(repo_path = %repo_path.display(), commit_sha, %stderr, "git diff failed");
196 return Ok(Vec::new());
197 }
198
199 Ok(output
200 .stdout
201 .split(|byte| *byte == b'\n')
202 .filter(|line| !line.is_empty())
203 .map(path_from_bytes)
204 .collect())
205}
206
207pub async fn is_commit_in_branch(
208 repo_path: &Path,
209 branch: &str,
210 commit_sha: &str,
211) -> Result<bool, GitCallError> {
212 let mut git = Command::new("git");
213 git.current_dir(repo_path).args([
215 "merge-base",
216 "--is-ancestor",
217 "--end-of-options",
218 commit_sha,
219 &format!("refs/heads/{branch}"),
220 ]);
221 match call::run("merge-base", &mut git).await {
222 Ok(_) => Ok(true),
223 Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
224 Err(error) => Err(error),
225 }
226}
227
228pub async fn show_file_at_rev(
229 repo_path: &Path,
230 rev: &str,
231 file_path: &Path,
232) -> Result<Option<String>> {
233 let mut rev_path = OsString::from(format!("{rev}:"));
234 rev_path.push(file_path);
235 let output = Command::new("git")
236 .arg("-C")
237 .arg(repo_path)
238 .arg("show")
239 .arg(&rev_path)
240 .output()
241 .await
242 .context("running git show")?;
243
244 if !output.status.success() {
245 return Ok(None);
246 }
247
248 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
249}
250
251#[allow(clippy::too_many_arguments)]
252pub async fn blame_reverse(
253 repo_path: &Path,
254 file_path: &Path,
255 line: usize,
256 rev_a: &str,
257 rev_b: &str,
258 enable_copy_detection: bool,
259) -> Result<Option<BlameResult>> {
260 let line_spec = format!("{line},+1");
261 let rev_range = format!("{rev_a}..{rev_b}");
262
263 let mut args: Vec<&OsStr> = vec![
264 OsStr::new("-C"),
265 repo_path.as_os_str(),
266 OsStr::new("-c"),
267 OsStr::new("core.quotepath=off"),
268 OsStr::new("blame"),
269 OsStr::new("-p"),
270 OsStr::new("-M"),
271 ];
272 if enable_copy_detection {
273 args.push(OsStr::new("-C"));
274 }
275 args.extend([
276 OsStr::new("-L"),
277 OsStr::new(&line_spec),
278 OsStr::new("--reverse"),
279 OsStr::new(&rev_range),
280 OsStr::new("--"),
281 file_path.as_os_str(),
282 ]);
283
284 let output = Command::new("git")
285 .args(&args)
286 .output()
287 .await
288 .context("running git blame --reverse")?;
289
290 if !output.status.success() {
291 warn!(
292 repo_path = %repo_path.display(),
293 file_path = %file_path.display(),
294 line,
295 rev_range = %rev_range,
296 stderr = %String::from_utf8_lossy(&output.stderr),
297 "git blame --reverse failed",
298 );
299 return Ok(None);
300 }
301
302 Ok(parse_blame_reverse_porcelain(&output.stdout))
303}
304
305pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
306 let mut sha = String::new();
307 let mut author_mail = String::new();
308 let mut committer_time: Option<&str> = None;
309
310 for line in output.lines() {
311 if sha.is_empty() && line.len() >= 40 {
312 line.split_whitespace()
313 .next()
314 .unwrap_or("")
315 .clone_into(&mut sha);
316 } else if let Some(val) = line.strip_prefix("author-mail <") {
317 val.trim_end_matches('>').clone_into(&mut author_mail);
318 } else if let Some(val) = line.strip_prefix("committer-time ") {
319 committer_time = Some(val);
320 }
321 }
322
323 if sha.is_empty() {
324 return Ok(None);
325 }
326
327 let date = match committer_time {
328 None => DateTime::UNIX_EPOCH.fixed_offset(),
329 Some(seconds) => seconds
330 .parse::<i64>()
331 .ok()
332 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
333 .context("parsing the committer time of git blame")?
334 .fixed_offset(),
335 };
336
337 Ok(Some(CommitInfo {
338 sha,
339 author_email: author_mail,
340 date,
341 }))
342}
343
344fn read_octal_escape(
345 first: u8,
346 bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
347) -> u8 {
348 let mut code = u32::from(first.saturating_sub(b'0'));
349 for _ in 0..2 {
350 let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
351 break;
352 };
353 bytes.next();
354 code = code
355 .saturating_mul(8)
356 .saturating_add(u32::from(digit.saturating_sub(b'0')));
357 }
358 u8::try_from(code).unwrap_or(b'?')
359}
360
361fn unquote_path(value: &[u8]) -> PathBuf {
362 let Some(inner) = value
363 .strip_prefix(b"\"")
364 .and_then(|rest| rest.strip_suffix(b"\""))
365 else {
366 return path_from_bytes(value);
367 };
368
369 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
370 let mut bytes = inner.iter().copied().peekable();
371 while let Some(byte) = bytes.next() {
372 if byte != b'\\' {
373 out.push(byte);
374 continue;
375 }
376 match bytes.next() {
377 Some(b'n') => out.push(b'\n'),
378 Some(b't') => out.push(b'\t'),
379 Some(b'r') => out.push(b'\r'),
380 Some(b'a') => out.push(0x07),
381 Some(b'b') => out.push(0x08),
382 Some(b'f') => out.push(0x0c),
383 Some(b'v') => out.push(0x0b),
384 Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
385 Some(other) => out.push(other),
386 None => out.push(b'\\'),
387 }
388 }
389
390 path_from_bytes(&out)
391}
392
393pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
394 let mut rev = String::new();
395 let mut line_num: usize = 0;
396 let mut path = PathBuf::new();
397
398 for line in output.split(|byte| *byte == b'\n') {
399 if rev.is_empty() && line.len() >= 40 {
400 let header = String::from_utf8_lossy(line);
401 let parts: Vec<&str> = header.split_whitespace().collect();
402 if let Some(hash) = parts.first() {
403 (*hash).clone_into(&mut rev);
404 }
405 if let Some(ln) = parts.get(1) {
406 line_num = ln.parse().unwrap_or(0);
407 }
408 } else if let Some(val) = line.strip_prefix(b"filename ") {
409 path = unquote_path(val);
410 }
411 }
412
413 if rev.is_empty() {
414 return None;
415 }
416
417 Some(BlameResult {
418 rev,
419 line: line_num,
420 path,
421 })
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_parse_blame_porcelain_valid() {
430 let output = "\
431abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
432author Test\n\
433author-mail <dev@example.com>\n\
434author-time 1700000000\n\
435author-tz +0000\n\
436committer Test\n\
437committer-mail <dev@example.com>\n\
438committer-time 1700000000\n\
439committer-tz +0000\n\
440summary test commit\n\
441filename file.txt\n\
442\tcode line\n";
443 let result = parse_blame_porcelain(output).unwrap().unwrap();
444 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
445 assert_eq!(result.author_email, "dev@example.com");
446 assert_eq!(result.date.timestamp(), 1_700_000_000);
447 }
448
449 #[test]
450 fn test_parse_blame_porcelain_without_a_committer_time() {
451 let output = "\
452abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
453author-mail <dev@example.com>\n\
454filename file.txt\n\
455\tcode line\n";
456 let result = parse_blame_porcelain(output).unwrap().unwrap();
457 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
458 assert_eq!(result.author_email, "dev@example.com");
459 assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
460 }
461
462 #[test]
463 fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
464 let output = "\
465abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
466author-mail <dev@example.com>\n\
467committer-time 99999999999999\n\
468filename file.txt\n\
469\tcode line\n";
470 assert!(parse_blame_porcelain(output).is_err());
471 }
472
473 #[test]
474 fn test_parse_blame_porcelain_empty() {
475 assert!(parse_blame_porcelain("").unwrap().is_none());
476 }
477
478 #[test]
479 fn test_parse_blame_reverse_porcelain_valid() {
480 let output = b"\
481abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
482author Test\n\
483filename renamed.py\n";
484 let result = parse_blame_reverse_porcelain(output).unwrap();
485 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
486 assert_eq!(result.line, 7);
487 assert_eq!(result.path, Path::new("renamed.py"));
488 }
489
490 #[test]
491 fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
492 let output = b"\
493abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
494filename \"se\\303\\261al.txt\"\n";
495 let result = parse_blame_reverse_porcelain(output).unwrap();
496 assert_eq!(result.path, Path::new("seƱal.txt"));
497 }
498
499 #[test]
500 #[cfg(unix)]
501 fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
502 use std::os::unix::ffi::OsStrExt;
503
504 let output = b"\
505abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
506filename sel\xe1l.txt\n";
507 let result = parse_blame_reverse_porcelain(output).unwrap();
508 assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
509 }
510
511 #[test]
512 fn a_path_git_left_alone_is_taken_as_it_is() {
513 assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
514 assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
515 assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
516 }
517
518 #[test]
519 fn test_parse_blame_reverse_porcelain_empty() {
520 assert!(parse_blame_reverse_porcelain(b"").is_none());
521 }
522
523 fn git(repo: &Path, args: &[&str]) -> String {
524 let output = std::process::Command::new("git")
525 .arg("-C")
526 .arg(repo)
527 .args([
528 "-c",
529 "user.email=dev@fluidattacks.com",
530 "-c",
531 "user.name=dev",
532 ])
533 .args(args)
534 .output()
535 .unwrap();
536 assert!(output.status.success());
537 String::from_utf8(output.stdout).unwrap().trim().to_owned()
538 }
539
540 #[tokio::test]
541 async fn a_warning_from_git_does_not_hide_its_answer() {
542 let dir = tempfile::tempdir().unwrap();
543 std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
544
545 let repo = dir.path().join("repo");
546 std::fs::create_dir_all(&repo).unwrap();
547 git(&repo, &["init", "-q", "-b", "main"]);
548 std::fs::write(repo.join("f.txt"), b"first").unwrap();
549 git(&repo, &["add", "-A"]);
550 git(&repo, &["commit", "-q", "-m", "first"]);
551 let first = git(&repo, &["rev-parse", "HEAD"]);
552 std::fs::write(repo.join("g.txt"), b"second").unwrap();
553 git(&repo, &["add", "-A"]);
554 git(&repo, &["commit", "-q", "-m", "second"]);
555
556 git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
558 git(
559 &repo,
560 &["update-ref", &format!("refs/heads/{first}"), &first],
561 );
562
563 let last = get_last_commit_info(&repo, Path::new("f.txt"))
564 .await
565 .unwrap();
566 assert_eq!(last.map(|info| info.sha), Some(first.clone()));
567
568 let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
569 .await
570 .unwrap();
571 assert_eq!(author.map(|info| info.sha), Some(first.clone()));
572
573 let modified = get_modified_filenames(&repo, &first).await.unwrap();
574 assert_eq!(modified, vec![PathBuf::from("g.txt")]);
575
576 assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
577 }
578
579 fn two_branches(repo: &Path) -> (String, String) {
580 std::fs::create_dir_all(repo).unwrap();
581 git(repo, &["init", "-q", "-b", "main"]);
582 git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
583 let on_main = git(repo, &["rev-parse", "HEAD"]);
584 git(repo, &["checkout", "-q", "-b", "main-old"]);
585 git(
586 repo,
587 &["commit", "-q", "--allow-empty", "-m", "on main-old"],
588 );
589 let on_main_old = git(repo, &["rev-parse", "HEAD"]);
590 (on_main, on_main_old)
591 }
592
593 #[tokio::test]
594 async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
595 let dir = tempfile::tempdir().unwrap();
596 let repo = dir.path().join("repo");
597 let (on_main, on_main_old) = two_branches(&repo);
598
599 assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
600 assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
601 .await
602 .unwrap());
603 }
604
605 #[tokio::test]
606 async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
607 let dir = tempfile::tempdir().unwrap();
608 let repo = dir.path().join("repo");
609 let (on_main, _) = two_branches(&repo);
610
611 for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
612 let outcome = is_commit_in_branch(&repo, branch, &commit).await;
613
614 assert!(matches!(
615 outcome,
616 Err(GitCallError::Refused { command: "merge-base", status, .. })
617 if status.code() == Some(128)
618 ));
619 }
620 }
621
622 #[tokio::test]
623 async fn a_repository_path_that_does_not_exist_does_not_start() {
624 let dir = tempfile::tempdir().unwrap();
625
626 let outcome = is_commit_in_branch(&dir.path().join("missing"), "main", "HEAD").await;
627
628 assert!(matches!(
629 outcome,
630 Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
631 ));
632 }
633}