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