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 return Ok(None);
284 }
285
286 Ok(parse_blame_reverse_porcelain(&output.stdout))
287}
288
289pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
290 let mut sha = String::new();
291 let mut author_mail = String::new();
292 let mut committer_time: Option<&str> = None;
293
294 for line in output.lines() {
295 if sha.is_empty() && line.len() >= 40 {
296 line.split_whitespace()
297 .next()
298 .unwrap_or("")
299 .clone_into(&mut sha);
300 } else if let Some(val) = line.strip_prefix("author-mail <") {
301 val.trim_end_matches('>').clone_into(&mut author_mail);
302 } else if let Some(val) = line.strip_prefix("committer-time ") {
303 committer_time = Some(val);
304 }
305 }
306
307 if sha.is_empty() {
308 return Ok(None);
309 }
310
311 let date = match committer_time {
312 None => DateTime::UNIX_EPOCH.fixed_offset(),
313 Some(seconds) => seconds
314 .parse::<i64>()
315 .ok()
316 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
317 .context("parsing the committer time of git blame")?
318 .fixed_offset(),
319 };
320
321 Ok(Some(CommitInfo {
322 sha,
323 author_email: author_mail,
324 date,
325 }))
326}
327
328fn read_octal_escape(
329 first: u8,
330 bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
331) -> u8 {
332 let mut code = u32::from(first.saturating_sub(b'0'));
333 for _ in 0..2 {
334 let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
335 break;
336 };
337 bytes.next();
338 code = code
339 .saturating_mul(8)
340 .saturating_add(u32::from(digit.saturating_sub(b'0')));
341 }
342 u8::try_from(code).unwrap_or(b'?')
343}
344
345fn unquote_path(value: &[u8]) -> PathBuf {
346 let Some(inner) = value
347 .strip_prefix(b"\"")
348 .and_then(|rest| rest.strip_suffix(b"\""))
349 else {
350 return path_from_bytes(value);
351 };
352
353 let mut out: Vec<u8> = Vec::with_capacity(inner.len());
354 let mut bytes = inner.iter().copied().peekable();
355 while let Some(byte) = bytes.next() {
356 if byte != b'\\' {
357 out.push(byte);
358 continue;
359 }
360 match bytes.next() {
361 Some(b'n') => out.push(b'\n'),
362 Some(b't') => out.push(b'\t'),
363 Some(b'r') => out.push(b'\r'),
364 Some(b'a') => out.push(0x07),
365 Some(b'b') => out.push(0x08),
366 Some(b'f') => out.push(0x0c),
367 Some(b'v') => out.push(0x0b),
368 Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
369 Some(other) => out.push(other),
370 None => out.push(b'\\'),
371 }
372 }
373
374 path_from_bytes(&out)
375}
376
377pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
378 let mut rev = String::new();
379 let mut line_num: usize = 0;
380 let mut path = PathBuf::new();
381
382 for line in output.split(|byte| *byte == b'\n') {
383 if rev.is_empty() && line.len() >= 40 {
384 let header = String::from_utf8_lossy(line);
385 let parts: Vec<&str> = header.split_whitespace().collect();
386 if let Some(hash) = parts.first() {
387 (*hash).clone_into(&mut rev);
388 }
389 if let Some(ln) = parts.get(1) {
390 line_num = ln.parse().unwrap_or(0);
391 }
392 } else if let Some(val) = line.strip_prefix(b"filename ") {
393 path = unquote_path(val);
394 }
395 }
396
397 if rev.is_empty() {
398 return None;
399 }
400
401 Some(BlameResult {
402 rev,
403 line: line_num,
404 path,
405 })
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn a_branch_checked_out_in_a_worktree_is_still_named() {
414 assert_eq!(branch_name(" main"), "main");
415 assert_eq!(branch_name("* main"), "main");
416 assert_eq!(branch_name("+ main"), "main");
417 assert_eq!(branch_name(" maintenance"), "maintenance");
418 assert_eq!(
419 branch_name("* (HEAD detached at abc1234)"),
420 "(HEAD detached at abc1234)"
421 );
422 }
423
424 #[test]
425 fn test_parse_blame_porcelain_valid() {
426 let output = "\
427abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
428author Test\n\
429author-mail <dev@example.com>\n\
430author-time 1700000000\n\
431author-tz +0000\n\
432committer Test\n\
433committer-mail <dev@example.com>\n\
434committer-time 1700000000\n\
435committer-tz +0000\n\
436summary test commit\n\
437filename file.txt\n\
438\tcode line\n";
439 let result = parse_blame_porcelain(output).unwrap().unwrap();
440 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
441 assert_eq!(result.author_email, "dev@example.com");
442 assert_eq!(result.date.timestamp(), 1_700_000_000);
443 }
444
445 #[test]
446 fn test_parse_blame_porcelain_without_a_committer_time() {
447 let output = "\
448abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
449author-mail <dev@example.com>\n\
450filename file.txt\n\
451\tcode line\n";
452 let result = parse_blame_porcelain(output).unwrap().unwrap();
453 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
454 assert_eq!(result.author_email, "dev@example.com");
455 assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
456 }
457
458 #[test]
459 fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
460 let output = "\
461abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
462author-mail <dev@example.com>\n\
463committer-time 99999999999999\n\
464filename file.txt\n\
465\tcode line\n";
466 assert!(parse_blame_porcelain(output).is_err());
467 }
468
469 #[test]
470 fn test_parse_blame_porcelain_empty() {
471 assert!(parse_blame_porcelain("").unwrap().is_none());
472 }
473
474 #[test]
475 fn test_parse_blame_reverse_porcelain_valid() {
476 let output = b"\
477abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
478author Test\n\
479filename renamed.py\n";
480 let result = parse_blame_reverse_porcelain(output).unwrap();
481 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
482 assert_eq!(result.line, 7);
483 assert_eq!(result.path, Path::new("renamed.py"));
484 }
485
486 #[test]
487 fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
488 let output = b"\
489abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
490filename \"se\\303\\261al.txt\"\n";
491 let result = parse_blame_reverse_porcelain(output).unwrap();
492 assert_eq!(result.path, Path::new("seƱal.txt"));
493 }
494
495 #[test]
496 #[cfg(unix)]
497 fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
498 use std::os::unix::ffi::OsStrExt;
499
500 let output = b"\
501abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
502filename sel\xe1l.txt\n";
503 let result = parse_blame_reverse_porcelain(output).unwrap();
504 assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
505 }
506
507 #[test]
508 fn a_path_git_left_alone_is_taken_as_it_is() {
509 assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
510 assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
511 assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
512 }
513
514 #[test]
515 fn test_parse_blame_reverse_porcelain_empty() {
516 assert!(parse_blame_reverse_porcelain(b"").is_none());
517 }
518}