Skip to main content

fluidattacks_core_git/
cli.rs

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}