git_blame_pr/
lib.rs

1//!
2//! git-blame-pr
3//!
4
5use std::collections::HashMap;
6
7//-----------------------------------------------------------------------------
8// git command wrapper
9//-----------------------------------------------------------------------------
10#[derive(Debug, Default)]
11pub struct GitLog {
12    pub commit: String,
13    pub title_line: String,
14}
15
16fn git_log(commit_id: &str) -> Result<GitLog, String> {
17    let output = std::process::Command::new("git")
18        .args(["log", "-1", "--oneline"])
19        .arg(commit_id)
20        .output()
21        .unwrap();
22
23    if !output.status.success() {
24        let err = String::from_utf8_lossy(&output.stderr);
25        return Err(format!("failed to execute `git log {commit_id}`: {err}"));
26    }
27
28    let raw_log = String::from_utf8_lossy(&output.stdout).to_string();
29    if raw_log.is_empty() {
30        return Err(format!("missing log: `git log {commit_id}`"));
31    }
32
33    match raw_log.split_once(' ') {
34        Some((commit, title_line)) => Ok(GitLog {
35            commit: commit.to_string(),
36            title_line: title_line.to_string(),
37        }),
38
39        // --allow-empty-message
40        None => Ok(GitLog {
41            commit: raw_log.to_string(),
42            title_line: String::new(),
43        }),
44    }
45}
46
47fn git_blame(path: &std::path::Path) -> Result<Vec<git_blame_parser::Blame>, String> {
48    let output = std::process::Command::new("git")
49        .args(["blame", "--first-parent", "--line-porcelain"])
50        .arg(path)
51        .output()
52        .unwrap();
53
54    if !output.status.success() {
55        let err = String::from_utf8_lossy(&output.stderr);
56        return Err(format!(
57            "failed to execute `git blame {}`: {err}",
58            path.display()
59        ));
60    }
61
62    let raw_blame = String::from_utf8_lossy(&output.stdout);
63    match git_blame_parser::parse(&raw_blame) {
64        Ok(blames) => Ok(blames),
65        Err(e) => Err(format!("{e}")),
66    }
67}
68
69//-----------------------------------------------------------------------------
70// git-blame-pr
71//-----------------------------------------------------------------------------
72pub struct Config {
73    pub filepath: std::path::PathBuf,
74}
75
76impl Config {
77    pub fn build(args: &[String]) -> Result<Config, String> {
78        if args.len() < 2 {
79            return Err(String::from("missing args: <filepath>"));
80        }
81
82        let filepath = std::path::PathBuf::from(args[1].clone());
83        if !filepath.is_file() {
84            return Err(String::from("invalid file path"));
85        }
86
87        Ok(Config { filepath })
88    }
89}
90
91pub fn lookup_pr(commit: &str) -> Option<String> {
92    let log = match git_log(commit) {
93        Ok(log) => log,
94        Err(_) => return None,
95    };
96
97    let re = regex::Regex::new(r"(?i)Merge\s+(?:pull\s+request|pr)\s+#?(\d+)\s").unwrap();
98
99    if let Some(captures) = re.captures(&log.title_line) {
100        if let Some(pr) = captures.get(1) {
101            return Some(format!("PR#{}", pr.as_str()));
102        }
103    }
104
105    None
106}
107
108pub fn run(config: Config) -> Result<(), Box<dyn std::error::Error>> {
109    let blames = git_blame(&config.filepath)?;
110    let line_digits = if blames.is_empty() {
111        1
112    } else {
113        blames.len().ilog10() as usize + 1
114    };
115
116    let mut cached = HashMap::new();
117    for blame in blames.iter() {
118        let idx =
119            cached
120                .entry(blame.short_commit())
121                .or_insert_with(|| match lookup_pr(&blame.commit) {
122                    Some(pr) => pr,
123                    None => blame.short_commit(),
124                });
125
126        println!(
127            "{:<8} {:0line_digits$}│ {}",
128            idx, blame.final_line_no, blame.content
129        );
130    }
131
132    Ok(())
133}