1use std::collections::HashMap;
6
7#[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 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
69pub 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"));
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}