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