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        warn!(
284            repo_path = %repo_path.display(),
285            file_path = %file_path.display(),
286            line,
287            rev_range = %rev_range,
288            stderr = %String::from_utf8_lossy(&output.stderr),
289            "git blame --reverse failed",
290        );
291        return Ok(None);
292    }
293
294    Ok(parse_blame_reverse_porcelain(&output.stdout))
295}
296
297pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
298    let mut sha = String::new();
299    let mut author_mail = String::new();
300    let mut committer_time: Option<&str> = None;
301
302    for line in output.lines() {
303        if sha.is_empty() && line.len() >= 40 {
304            line.split_whitespace()
305                .next()
306                .unwrap_or("")
307                .clone_into(&mut sha);
308        } else if let Some(val) = line.strip_prefix("author-mail <") {
309            val.trim_end_matches('>').clone_into(&mut author_mail);
310        } else if let Some(val) = line.strip_prefix("committer-time ") {
311            committer_time = Some(val);
312        }
313    }
314
315    if sha.is_empty() {
316        return Ok(None);
317    }
318
319    let date = match committer_time {
320        None => DateTime::UNIX_EPOCH.fixed_offset(),
321        Some(seconds) => seconds
322            .parse::<i64>()
323            .ok()
324            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
325            .context("parsing the committer time of git blame")?
326            .fixed_offset(),
327    };
328
329    Ok(Some(CommitInfo {
330        sha,
331        author_email: author_mail,
332        date,
333    }))
334}
335
336fn read_octal_escape(
337    first: u8,
338    bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
339) -> u8 {
340    let mut code = u32::from(first.saturating_sub(b'0'));
341    for _ in 0..2 {
342        let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
343            break;
344        };
345        bytes.next();
346        code = code
347            .saturating_mul(8)
348            .saturating_add(u32::from(digit.saturating_sub(b'0')));
349    }
350    u8::try_from(code).unwrap_or(b'?')
351}
352
353fn unquote_path(value: &[u8]) -> PathBuf {
354    let Some(inner) = value
355        .strip_prefix(b"\"")
356        .and_then(|rest| rest.strip_suffix(b"\""))
357    else {
358        return path_from_bytes(value);
359    };
360
361    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
362    let mut bytes = inner.iter().copied().peekable();
363    while let Some(byte) = bytes.next() {
364        if byte != b'\\' {
365            out.push(byte);
366            continue;
367        }
368        match bytes.next() {
369            Some(b'n') => out.push(b'\n'),
370            Some(b't') => out.push(b'\t'),
371            Some(b'r') => out.push(b'\r'),
372            Some(b'a') => out.push(0x07),
373            Some(b'b') => out.push(0x08),
374            Some(b'f') => out.push(0x0c),
375            Some(b'v') => out.push(0x0b),
376            Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
377            Some(other) => out.push(other),
378            None => out.push(b'\\'),
379        }
380    }
381
382    path_from_bytes(&out)
383}
384
385pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
386    let mut rev = String::new();
387    let mut line_num: usize = 0;
388    let mut path = PathBuf::new();
389
390    for line in output.split(|byte| *byte == b'\n') {
391        if rev.is_empty() && line.len() >= 40 {
392            let header = String::from_utf8_lossy(line);
393            let parts: Vec<&str> = header.split_whitespace().collect();
394            if let Some(hash) = parts.first() {
395                (*hash).clone_into(&mut rev);
396            }
397            if let Some(ln) = parts.get(1) {
398                line_num = ln.parse().unwrap_or(0);
399            }
400        } else if let Some(val) = line.strip_prefix(b"filename ") {
401            path = unquote_path(val);
402        }
403    }
404
405    if rev.is_empty() {
406        return None;
407    }
408
409    Some(BlameResult {
410        rev,
411        line: line_num,
412        path,
413    })
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn a_branch_checked_out_in_a_worktree_is_still_named() {
422        assert_eq!(branch_name("  main"), "main");
423        assert_eq!(branch_name("* main"), "main");
424        assert_eq!(branch_name("+ main"), "main");
425        assert_eq!(branch_name("  maintenance"), "maintenance");
426        assert_eq!(
427            branch_name("* (HEAD detached at abc1234)"),
428            "(HEAD detached at abc1234)"
429        );
430    }
431
432    #[test]
433    fn test_parse_blame_porcelain_valid() {
434        let output = "\
435abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
436author Test\n\
437author-mail <dev@example.com>\n\
438author-time 1700000000\n\
439author-tz +0000\n\
440committer Test\n\
441committer-mail <dev@example.com>\n\
442committer-time 1700000000\n\
443committer-tz +0000\n\
444summary test commit\n\
445filename file.txt\n\
446\tcode line\n";
447        let result = parse_blame_porcelain(output).unwrap().unwrap();
448        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
449        assert_eq!(result.author_email, "dev@example.com");
450        assert_eq!(result.date.timestamp(), 1_700_000_000);
451    }
452
453    #[test]
454    fn test_parse_blame_porcelain_without_a_committer_time() {
455        let output = "\
456abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
457author-mail <dev@example.com>\n\
458filename file.txt\n\
459\tcode line\n";
460        let result = parse_blame_porcelain(output).unwrap().unwrap();
461        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
462        assert_eq!(result.author_email, "dev@example.com");
463        assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
464    }
465
466    #[test]
467    fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
468        let output = "\
469abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
470author-mail <dev@example.com>\n\
471committer-time 99999999999999\n\
472filename file.txt\n\
473\tcode line\n";
474        assert!(parse_blame_porcelain(output).is_err());
475    }
476
477    #[test]
478    fn test_parse_blame_porcelain_empty() {
479        assert!(parse_blame_porcelain("").unwrap().is_none());
480    }
481
482    #[test]
483    fn test_parse_blame_reverse_porcelain_valid() {
484        let output = b"\
485abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
486author Test\n\
487filename renamed.py\n";
488        let result = parse_blame_reverse_porcelain(output).unwrap();
489        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
490        assert_eq!(result.line, 7);
491        assert_eq!(result.path, Path::new("renamed.py"));
492    }
493
494    #[test]
495    fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
496        let output = b"\
497abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
498filename \"se\\303\\261al.txt\"\n";
499        let result = parse_blame_reverse_porcelain(output).unwrap();
500        assert_eq!(result.path, Path::new("seƱal.txt"));
501    }
502
503    #[test]
504    #[cfg(unix)]
505    fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
506        use std::os::unix::ffi::OsStrExt;
507
508        let output = b"\
509abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
510filename sel\xe1l.txt\n";
511        let result = parse_blame_reverse_porcelain(output).unwrap();
512        assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
513    }
514
515    #[test]
516    fn a_path_git_left_alone_is_taken_as_it_is() {
517        assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
518        assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
519        assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
520    }
521
522    #[test]
523    fn test_parse_blame_reverse_porcelain_empty() {
524        assert!(parse_blame_reverse_porcelain(b"").is_none());
525    }
526}