Skip to main content

fluidattacks_core_git/
cli.rs

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
9/// Matches the deadline `run_git` applied to the same call in the python.
10const 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}