1use anyhow::{bail, Context, Result};
2use chrono::DateTime;
3use tokio::process::Command;
4use tracing::warn;
5
6use super::types::{BlameResult, CommitInfo};
7
8pub async fn get_head_commit(repo_path: &str, branch: &str) -> Result<String> {
9 let reference = format!("refs/heads/{branch}");
10 let output = Command::new("git")
11 .args(["-C", repo_path, "rev-parse", &reference])
12 .output()
13 .await
14 .context("running git rev-parse")?;
15
16 if !output.status.success() {
17 bail!(
18 "git rev-parse failed: {}",
19 String::from_utf8_lossy(&output.stderr)
20 );
21 }
22
23 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
24}
25
26pub async fn reset_hard(repo_path: &str) -> Result<()> {
27 let status = Command::new("git")
28 .args(["-C", repo_path, "reset", "--hard", "HEAD"])
29 .status()
30 .await
31 .context("running git reset --hard")?;
32
33 if !status.success() {
34 bail!("git reset --hard failed");
35 }
36 Ok(())
37}
38
39pub async fn set_safe_directory() -> Result<()> {
40 let status = Command::new("git")
41 .args(["config", "--global", "--add", "safe.directory", "*"])
42 .status()
43 .await
44 .context("running git config safe.directory")?;
45
46 if !status.success() {
47 bail!("git config safe.directory failed");
48 }
49 Ok(())
50}
51
52pub async fn disable_quotepath(repo_path: &str) -> Result<()> {
53 let git_dir = format!("--git-dir={repo_path}");
54 let status = Command::new("git")
55 .args([&git_dir, "config", "core.quotepath", "off"])
56 .status()
57 .await
58 .context("running git config core.quotepath")?;
59
60 if !status.success() {
61 bail!("git config core.quotepath failed");
62 }
63 Ok(())
64}
65
66pub async fn get_last_commit_info(repo_path: &str, filename: &str) -> Result<Option<CommitInfo>> {
67 let output = Command::new("git")
68 .args([
69 "-C",
70 repo_path,
71 "log",
72 "--max-count",
73 "1",
74 "--format=%H%n%ce%n%cI",
75 "--",
76 filename,
77 ])
78 .output()
79 .await
80 .context("running git log")?;
81
82 let stderr = String::from_utf8_lossy(&output.stderr);
83 if !output.status.success() || !stderr.is_empty() {
84 warn!(repo_path, filename, %stderr, "git log did not succeed cleanly");
85 return Ok(None);
86 }
87
88 let stdout = String::from_utf8_lossy(&output.stdout);
89 let stdout = stdout.trim();
90 if stdout.is_empty() {
91 return Ok(None);
92 }
93
94 let lines: Vec<&str> = stdout.lines().collect();
95 if lines.len() < 3 {
96 return Ok(None);
97 }
98
99 let date = DateTime::parse_from_rfc3339(lines.get(2).copied().unwrap_or(""))
100 .context("parsing the committer date of git log")?;
101
102 Ok(Some(CommitInfo {
103 sha: lines.first().copied().unwrap_or("").to_owned(),
104 author_email: lines.get(1).copied().unwrap_or("").to_owned(),
105 date,
106 }))
107}
108
109pub async fn get_line_author(
110 repo_path: &str,
111 filename: &str,
112 line: usize,
113 rev: &str,
114) -> Result<Option<CommitInfo>> {
115 let line_spec = format!("{line},+1");
116 let output = Command::new("git")
117 .args([
118 "-C", repo_path, "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
119 filename,
120 ])
121 .output()
122 .await
123 .context("running git blame")?;
124
125 if !output.status.success() {
126 let stderr = String::from_utf8_lossy(&output.stderr);
127 if stderr.contains("no such path") || stderr.contains("no such ref") {
128 return Ok(None);
129 }
130 bail!("git blame failed: {stderr}");
131 }
132
133 let stdout = String::from_utf8_lossy(&output.stdout);
134 parse_blame_porcelain(&stdout)
135}
136
137pub async fn get_modified_filenames(repo_path: &str, commit_sha: &str) -> Result<Vec<String>> {
138 let output = Command::new("git")
139 .args([
140 "-C",
141 repo_path,
142 "diff",
143 "--name-only",
144 &format!("{commit_sha}..HEAD"),
145 ])
146 .output()
147 .await
148 .context("running git diff --name-only")?;
149
150 if !output.status.success() {
151 bail!(
152 "git diff failed: {}",
153 String::from_utf8_lossy(&output.stderr)
154 );
155 }
156
157 let stdout = String::from_utf8_lossy(&output.stdout);
158 Ok(stdout
159 .lines()
160 .filter(|l| !l.is_empty())
161 .map(str::to_owned)
162 .collect())
163}
164
165pub async fn is_commit_in_branch(repo_path: &str, branch: &str, commit_sha: &str) -> Result<bool> {
166 let output = Command::new("git")
167 .args(["-C", repo_path, "branch", "--contains", commit_sha])
168 .output()
169 .await
170 .context("running git branch --contains")?;
171
172 if !output.status.success() {
173 return Ok(false);
174 }
175
176 let stdout = String::from_utf8_lossy(&output.stdout);
177 Ok(stdout
178 .lines()
179 .any(|l| l.trim().trim_start_matches("* ") == branch))
180}
181
182pub async fn show_file_at_rev(
183 repo_path: &str,
184 rev: &str,
185 file_path: &str,
186) -> Result<Option<String>> {
187 let rev_path = format!("{rev}:{file_path}");
188 let output = Command::new("git")
189 .args(["-C", repo_path, "show", &rev_path])
190 .output()
191 .await
192 .context("running git show")?;
193
194 if !output.status.success() {
195 return Ok(None);
196 }
197
198 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
199}
200
201#[allow(clippy::too_many_arguments)]
202pub async fn blame_reverse(
203 repo_path: &str,
204 file_path: &str,
205 line: usize,
206 rev_a: &str,
207 rev_b: &str,
208 enable_copy_detection: bool,
209) -> Result<Option<BlameResult>> {
210 let line_spec = format!("{line},+1");
211 let rev_range = format!("{rev_a}..{rev_b}");
212
213 let mut args = vec!["-C", repo_path, "blame", "-p", "-M"];
214 if enable_copy_detection {
215 args.push("-C");
216 }
217 args.extend(["-L", &line_spec, "--reverse", &rev_range, "--", file_path]);
218
219 let output = Command::new("git")
220 .args(&args)
221 .output()
222 .await
223 .context("running git blame --reverse")?;
224
225 if !output.status.success() {
226 return Ok(None);
227 }
228
229 let stdout = String::from_utf8_lossy(&output.stdout);
230 Ok(parse_blame_reverse_porcelain(&stdout))
231}
232
233pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
234 let mut sha = String::new();
235 let mut author_mail = String::new();
236 let mut committer_time: Option<&str> = None;
237
238 for line in output.lines() {
239 if sha.is_empty() && line.len() >= 40 {
240 line.split_whitespace()
241 .next()
242 .unwrap_or("")
243 .clone_into(&mut sha);
244 } else if let Some(val) = line.strip_prefix("author-mail <") {
245 val.trim_end_matches('>').clone_into(&mut author_mail);
246 } else if let Some(val) = line.strip_prefix("committer-time ") {
247 committer_time = Some(val);
248 }
249 }
250
251 if sha.is_empty() {
252 return Ok(None);
253 }
254
255 let date = match committer_time {
256 None => DateTime::UNIX_EPOCH.fixed_offset(),
257 Some(seconds) => seconds
258 .parse::<i64>()
259 .ok()
260 .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
261 .context("parsing the committer time of git blame")?
262 .fixed_offset(),
263 };
264
265 Ok(Some(CommitInfo {
266 sha,
267 author_email: author_mail,
268 date,
269 }))
270}
271
272pub(crate) fn parse_blame_reverse_porcelain(output: &str) -> Option<BlameResult> {
273 let mut rev = String::new();
274 let mut line_num: usize = 0;
275 let mut path = String::new();
276
277 for line in output.lines() {
278 if rev.is_empty() && line.len() >= 40 {
279 let parts: Vec<&str> = line.split_whitespace().collect();
280 if let Some(hash) = parts.first() {
281 (*hash).clone_into(&mut rev);
282 }
283 if let Some(ln) = parts.get(1) {
284 line_num = ln.parse().unwrap_or(0);
285 }
286 } else if let Some(val) = line.strip_prefix("filename ") {
287 val.clone_into(&mut path);
288 }
289 }
290
291 if rev.is_empty() {
292 return None;
293 }
294
295 Some(BlameResult {
296 rev,
297 line: line_num,
298 path,
299 })
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_parse_blame_porcelain_valid() {
308 let output = "\
309abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
310author Test\n\
311author-mail <dev@example.com>\n\
312author-time 1700000000\n\
313author-tz +0000\n\
314committer Test\n\
315committer-mail <dev@example.com>\n\
316committer-time 1700000000\n\
317committer-tz +0000\n\
318summary test commit\n\
319filename file.txt\n\
320\tcode line\n";
321 let result = parse_blame_porcelain(output).unwrap().unwrap();
322 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
323 assert_eq!(result.author_email, "dev@example.com");
324 assert_eq!(result.date.timestamp(), 1_700_000_000);
325 }
326
327 #[test]
328 fn test_parse_blame_porcelain_without_a_committer_time() {
329 let output = "\
330abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
331author-mail <dev@example.com>\n\
332filename file.txt\n\
333\tcode line\n";
334 let result = parse_blame_porcelain(output).unwrap().unwrap();
335 assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
336 assert_eq!(result.author_email, "dev@example.com");
337 assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
338 }
339
340 #[test]
341 fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
342 let output = "\
343abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
344author-mail <dev@example.com>\n\
345committer-time 99999999999999\n\
346filename file.txt\n\
347\tcode line\n";
348 assert!(parse_blame_porcelain(output).is_err());
349 }
350
351 #[test]
352 fn test_parse_blame_porcelain_empty() {
353 assert!(parse_blame_porcelain("").unwrap().is_none());
354 }
355
356 #[test]
357 fn test_parse_blame_reverse_porcelain_valid() {
358 let output = "\
359abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
360author Test\n\
361filename renamed.py\n";
362 let result = parse_blame_reverse_porcelain(output).unwrap();
363 assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
364 assert_eq!(result.line, 7);
365 assert_eq!(result.path, "renamed.py");
366 }
367
368 #[test]
369 fn test_parse_blame_reverse_porcelain_empty() {
370 assert!(parse_blame_reverse_porcelain("").is_none());
371 }
372}