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