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