Skip to main content

fluidattacks_core_git/
cli.rs

1use anyhow::{bail, Context, Result};
2use chrono::DateTime;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use tokio::process::Command;
7use tracing::warn;
8
9use super::types::{BlameResult, CommitInfo};
10
11/// Matches the deadline `run_git` applied to the same call in the python.
12const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
13
14/// Git writes paths as the bytes on disk once `core.quotepath` is off, and those
15/// bytes are not always UTF-8. Decoding them loses the name of the file.
16fn path_from_bytes(bytes: &[u8]) -> PathBuf {
17    #[cfg(unix)]
18    {
19        use std::os::unix::ffi::OsStrExt;
20        PathBuf::from(OsStr::from_bytes(bytes))
21    }
22    #[cfg(not(unix))]
23    {
24        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
25    }
26}
27
28pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String> {
29    let reference = format!("refs/heads/{branch}");
30    let output = Command::new("git")
31        .arg("-C")
32        .arg(repo_path)
33        .args(["rev-parse", &reference])
34        .output()
35        .await
36        .context("running git rev-parse")?;
37
38    if !output.status.success() {
39        bail!(
40            "git rev-parse failed: {}",
41            String::from_utf8_lossy(&output.stderr)
42        );
43    }
44
45    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
46}
47
48pub async fn reset_hard(repo_path: &Path) -> Result<()> {
49    let output = Command::new("git")
50        .arg("-C")
51        .arg(repo_path)
52        .args(["reset", "--hard", "HEAD"])
53        .output()
54        .await
55        .context("running git reset --hard")?;
56
57    if !output.status.success() {
58        bail!(
59            "git reset --hard failed: {}",
60            String::from_utf8_lossy(&output.stderr)
61        );
62    }
63    Ok(())
64}
65
66pub async fn set_safe_directory() -> Result<()> {
67    let output = Command::new("git")
68        .args(["config", "--global", "--add", "safe.directory", "*"])
69        .output()
70        .await
71        .context("running git config safe.directory")?;
72
73    if !output.status.success() {
74        bail!(
75            "git config safe.directory failed: {}",
76            String::from_utf8_lossy(&output.stderr)
77        );
78    }
79    Ok(())
80}
81
82pub async fn disable_quotepath(repo_path: &Path) -> Result<()> {
83    let mut git_dir = OsString::from("--git-dir=");
84    git_dir.push(repo_path);
85    let output = tokio::time::timeout(
86        Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS),
87        Command::new("git")
88            .arg(&git_dir)
89            .args(["config", "core.quotepath", "off"])
90            .kill_on_drop(true)
91            .output(),
92    )
93    .await
94    .context("git config core.quotepath timed out")?
95    .context("running git config core.quotepath")?;
96
97    if !output.status.success() {
98        bail!(
99            "git config core.quotepath failed: {}",
100            String::from_utf8_lossy(&output.stderr)
101        );
102    }
103    Ok(())
104}
105
106pub async fn get_last_commit_info(repo_path: &Path, filename: &Path) -> Result<Option<CommitInfo>> {
107    let output = Command::new("git")
108        .arg("-C")
109        .arg(repo_path)
110        .args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
111        .arg(filename)
112        .output()
113        .await
114        .context("running git log")?;
115
116    let stderr = String::from_utf8_lossy(&output.stderr);
117    if !output.status.success() || !stderr.is_empty() {
118        warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git log did not succeed cleanly");
119        return Ok(None);
120    }
121
122    let stdout = String::from_utf8_lossy(&output.stdout);
123    let stdout = stdout.trim();
124    if stdout.is_empty() {
125        return Ok(None);
126    }
127
128    let lines: Vec<&str> = stdout.lines().collect();
129    if lines.len() < 3 {
130        return Ok(None);
131    }
132
133    let date = DateTime::parse_from_rfc3339(lines.get(2).copied().unwrap_or(""))
134        .context("parsing the committer date of git log")?;
135
136    Ok(Some(CommitInfo {
137        sha: lines.first().copied().unwrap_or("").to_owned(),
138        author_email: lines.get(1).copied().unwrap_or("").to_owned(),
139        date,
140    }))
141}
142
143pub async fn get_line_author(
144    repo_path: &Path,
145    filename: &Path,
146    line: usize,
147    rev: &str,
148) -> Result<Option<CommitInfo>> {
149    let line_spec = format!("{line},+1");
150    let output = Command::new("git")
151        .arg("-C")
152        .arg(repo_path)
153        .args([
154            "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
155        ])
156        .arg(filename)
157        .output()
158        .await
159        .context("running git blame")?;
160
161    let stderr = String::from_utf8_lossy(&output.stderr);
162    if !output.status.success() || !stderr.is_empty() {
163        warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame did not succeed cleanly");
164        return Ok(None);
165    }
166
167    let stdout = String::from_utf8_lossy(&output.stdout);
168    parse_blame_porcelain(&stdout)
169}
170
171pub async fn get_modified_filenames(repo_path: &Path, commit_sha: &str) -> Result<Vec<PathBuf>> {
172    let output = Command::new("git")
173        .arg("-C")
174        .arg(repo_path)
175        .args(["diff", "--name-only", &format!("{commit_sha}..HEAD")])
176        .output()
177        .await
178        .context("running git diff --name-only")?;
179
180    let stderr = String::from_utf8_lossy(&output.stderr);
181    if !output.status.success() || !stderr.is_empty() {
182        warn!(repo_path = %repo_path.display(), commit_sha, %stderr, "git diff did not succeed cleanly");
183        return Ok(Vec::new());
184    }
185
186    Ok(output
187        .stdout
188        .split(|byte| *byte == b'\n')
189        .filter(|line| !line.is_empty())
190        .map(path_from_bytes)
191        .collect())
192}
193
194pub async fn is_commit_in_branch(repo_path: &Path, branch: &str, commit_sha: &str) -> Result<bool> {
195    let output = Command::new("git")
196        .arg("-C")
197        .arg(repo_path)
198        .args(["branch", "--contains", commit_sha])
199        .output()
200        .await
201        .context("running git branch --contains")?;
202
203    let stderr = String::from_utf8_lossy(&output.stderr);
204    if !output.status.success() || !stderr.is_empty() {
205        warn!(repo_path = %repo_path.display(), branch, %stderr, "git branch --contains did not succeed cleanly");
206        return Ok(false);
207    }
208
209    let stdout = String::from_utf8_lossy(&output.stdout);
210    Ok(stdout.lines().any(|line| branch_name(line) == branch))
211}
212
213fn branch_name(line: &str) -> &str {
214    let line = line.trim();
215    line.strip_prefix("* ")
216        .or_else(|| line.strip_prefix("+ "))
217        .unwrap_or(line)
218}
219
220pub async fn show_file_at_rev(
221    repo_path: &Path,
222    rev: &str,
223    file_path: &Path,
224) -> Result<Option<String>> {
225    let mut rev_path = OsString::from(format!("{rev}:"));
226    rev_path.push(file_path);
227    let output = Command::new("git")
228        .arg("-C")
229        .arg(repo_path)
230        .arg("show")
231        .arg(&rev_path)
232        .output()
233        .await
234        .context("running git show")?;
235
236    if !output.status.success() {
237        return Ok(None);
238    }
239
240    Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
241}
242
243#[allow(clippy::too_many_arguments)]
244pub async fn blame_reverse(
245    repo_path: &Path,
246    file_path: &Path,
247    line: usize,
248    rev_a: &str,
249    rev_b: &str,
250    enable_copy_detection: bool,
251) -> Result<Option<BlameResult>> {
252    let line_spec = format!("{line},+1");
253    let rev_range = format!("{rev_a}..{rev_b}");
254
255    let mut args: Vec<&OsStr> = vec![
256        OsStr::new("-C"),
257        repo_path.as_os_str(),
258        OsStr::new("-c"),
259        OsStr::new("core.quotepath=off"),
260        OsStr::new("blame"),
261        OsStr::new("-p"),
262        OsStr::new("-M"),
263    ];
264    if enable_copy_detection {
265        args.push(OsStr::new("-C"));
266    }
267    args.extend([
268        OsStr::new("-L"),
269        OsStr::new(&line_spec),
270        OsStr::new("--reverse"),
271        OsStr::new(&rev_range),
272        OsStr::new("--"),
273        file_path.as_os_str(),
274    ]);
275
276    let output = Command::new("git")
277        .args(&args)
278        .output()
279        .await
280        .context("running git blame --reverse")?;
281
282    if !output.status.success() {
283        return Ok(None);
284    }
285
286    Ok(parse_blame_reverse_porcelain(&output.stdout))
287}
288
289pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
290    let mut sha = String::new();
291    let mut author_mail = String::new();
292    let mut committer_time: Option<&str> = None;
293
294    for line in output.lines() {
295        if sha.is_empty() && line.len() >= 40 {
296            line.split_whitespace()
297                .next()
298                .unwrap_or("")
299                .clone_into(&mut sha);
300        } else if let Some(val) = line.strip_prefix("author-mail <") {
301            val.trim_end_matches('>').clone_into(&mut author_mail);
302        } else if let Some(val) = line.strip_prefix("committer-time ") {
303            committer_time = Some(val);
304        }
305    }
306
307    if sha.is_empty() {
308        return Ok(None);
309    }
310
311    let date = match committer_time {
312        None => DateTime::UNIX_EPOCH.fixed_offset(),
313        Some(seconds) => seconds
314            .parse::<i64>()
315            .ok()
316            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
317            .context("parsing the committer time of git blame")?
318            .fixed_offset(),
319    };
320
321    Ok(Some(CommitInfo {
322        sha,
323        author_email: author_mail,
324        date,
325    }))
326}
327
328fn read_octal_escape(
329    first: u8,
330    bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
331) -> u8 {
332    let mut code = u32::from(first.saturating_sub(b'0'));
333    for _ in 0..2 {
334        let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
335            break;
336        };
337        bytes.next();
338        code = code
339            .saturating_mul(8)
340            .saturating_add(u32::from(digit.saturating_sub(b'0')));
341    }
342    u8::try_from(code).unwrap_or(b'?')
343}
344
345fn unquote_path(value: &[u8]) -> PathBuf {
346    let Some(inner) = value
347        .strip_prefix(b"\"")
348        .and_then(|rest| rest.strip_suffix(b"\""))
349    else {
350        return path_from_bytes(value);
351    };
352
353    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
354    let mut bytes = inner.iter().copied().peekable();
355    while let Some(byte) = bytes.next() {
356        if byte != b'\\' {
357            out.push(byte);
358            continue;
359        }
360        match bytes.next() {
361            Some(b'n') => out.push(b'\n'),
362            Some(b't') => out.push(b'\t'),
363            Some(b'r') => out.push(b'\r'),
364            Some(b'a') => out.push(0x07),
365            Some(b'b') => out.push(0x08),
366            Some(b'f') => out.push(0x0c),
367            Some(b'v') => out.push(0x0b),
368            Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
369            Some(other) => out.push(other),
370            None => out.push(b'\\'),
371        }
372    }
373
374    path_from_bytes(&out)
375}
376
377pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
378    let mut rev = String::new();
379    let mut line_num: usize = 0;
380    let mut path = PathBuf::new();
381
382    for line in output.split(|byte| *byte == b'\n') {
383        if rev.is_empty() && line.len() >= 40 {
384            let header = String::from_utf8_lossy(line);
385            let parts: Vec<&str> = header.split_whitespace().collect();
386            if let Some(hash) = parts.first() {
387                (*hash).clone_into(&mut rev);
388            }
389            if let Some(ln) = parts.get(1) {
390                line_num = ln.parse().unwrap_or(0);
391            }
392        } else if let Some(val) = line.strip_prefix(b"filename ") {
393            path = unquote_path(val);
394        }
395    }
396
397    if rev.is_empty() {
398        return None;
399    }
400
401    Some(BlameResult {
402        rev,
403        line: line_num,
404        path,
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn a_branch_checked_out_in_a_worktree_is_still_named() {
414        assert_eq!(branch_name("  main"), "main");
415        assert_eq!(branch_name("* main"), "main");
416        assert_eq!(branch_name("+ main"), "main");
417        assert_eq!(branch_name("  maintenance"), "maintenance");
418        assert_eq!(
419            branch_name("* (HEAD detached at abc1234)"),
420            "(HEAD detached at abc1234)"
421        );
422    }
423
424    #[test]
425    fn test_parse_blame_porcelain_valid() {
426        let output = "\
427abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
428author Test\n\
429author-mail <dev@example.com>\n\
430author-time 1700000000\n\
431author-tz +0000\n\
432committer Test\n\
433committer-mail <dev@example.com>\n\
434committer-time 1700000000\n\
435committer-tz +0000\n\
436summary test commit\n\
437filename file.txt\n\
438\tcode line\n";
439        let result = parse_blame_porcelain(output).unwrap().unwrap();
440        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
441        assert_eq!(result.author_email, "dev@example.com");
442        assert_eq!(result.date.timestamp(), 1_700_000_000);
443    }
444
445    #[test]
446    fn test_parse_blame_porcelain_without_a_committer_time() {
447        let output = "\
448abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
449author-mail <dev@example.com>\n\
450filename file.txt\n\
451\tcode line\n";
452        let result = parse_blame_porcelain(output).unwrap().unwrap();
453        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
454        assert_eq!(result.author_email, "dev@example.com");
455        assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
456    }
457
458    #[test]
459    fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
460        let output = "\
461abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
462author-mail <dev@example.com>\n\
463committer-time 99999999999999\n\
464filename file.txt\n\
465\tcode line\n";
466        assert!(parse_blame_porcelain(output).is_err());
467    }
468
469    #[test]
470    fn test_parse_blame_porcelain_empty() {
471        assert!(parse_blame_porcelain("").unwrap().is_none());
472    }
473
474    #[test]
475    fn test_parse_blame_reverse_porcelain_valid() {
476        let output = b"\
477abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
478author Test\n\
479filename renamed.py\n";
480        let result = parse_blame_reverse_porcelain(output).unwrap();
481        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
482        assert_eq!(result.line, 7);
483        assert_eq!(result.path, Path::new("renamed.py"));
484    }
485
486    #[test]
487    fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
488        let output = b"\
489abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
490filename \"se\\303\\261al.txt\"\n";
491        let result = parse_blame_reverse_porcelain(output).unwrap();
492        assert_eq!(result.path, Path::new("seƱal.txt"));
493    }
494
495    #[test]
496    #[cfg(unix)]
497    fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
498        use std::os::unix::ffi::OsStrExt;
499
500        let output = b"\
501abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
502filename sel\xe1l.txt\n";
503        let result = parse_blame_reverse_porcelain(output).unwrap();
504        assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
505    }
506
507    #[test]
508    fn a_path_git_left_alone_is_taken_as_it_is() {
509        assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
510        assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
511        assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
512    }
513
514    #[test]
515    fn test_parse_blame_reverse_porcelain_empty() {
516        assert!(parse_blame_reverse_porcelain(b"").is_none());
517    }
518}