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