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