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