Skip to main content

fluidattacks_core_git/
cli.rs

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}