1use std::path::Path;
9use std::process::Command;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BlameLine {
18 pub path: String,
19 pub line: u32,
20 pub commit: String,
21 pub author: String,
22 pub author_time: u64,
24 pub summary: String,
25 pub content: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Commit {
31 pub commit: String,
32 pub author: String,
33 pub author_time: u64,
34 pub summary: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ChangedFile {
40 pub path: String,
41 pub status: String,
43}
44
45fn git(root: &Path, args: &[&str]) -> Result<String> {
47 let output = Command::new("git")
48 .arg("-C")
49 .arg(root)
50 .args(args)
51 .output()
52 .map_err(|e| Error::other(format!("failed to run git: {e}")))?;
53 if !output.status.success() {
54 let err = String::from_utf8_lossy(&output.stderr);
55 return Err(Error::other(format!(
56 "git {} failed: {}",
57 args.join(" "),
58 err.trim()
59 )));
60 }
61 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
62}
63
64pub fn is_repo(root: &Path) -> bool {
66 git(root, &["rev-parse", "--is-inside-work-tree"])
67 .map(|s| s.trim() == "true")
68 .unwrap_or(false)
69}
70
71pub fn head(root: &Path) -> Option<(String, String)> {
73 let sha = git(root, &["rev-parse", "HEAD"]).ok()?.trim().to_string();
74 let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
75 .ok()
76 .map(|s| s.trim().to_string())
77 .unwrap_or_default();
78 Some((sha, branch))
79}
80
81pub fn blame(root: &Path, rel_path: &str, line: u32) -> Result<BlameLine> {
83 let range = format!("{line},{line}");
84 let out = git(
85 root,
86 &["blame", "-L", &range, "--porcelain", "--", rel_path],
87 )?;
88 parse_blame(&out, rel_path, line)
89 .ok_or_else(|| Error::other(format!("could not blame {rel_path}:{line}")))
90}
91
92fn parse_blame(out: &str, rel_path: &str, line: u32) -> Option<BlameLine> {
93 let mut commit = String::new();
94 let mut author = String::new();
95 let mut author_time = 0u64;
96 let mut summary = String::new();
97 let mut content = String::new();
98 for (i, l) in out.lines().enumerate() {
99 if i == 0 {
100 commit = l.split_whitespace().next().unwrap_or("").to_string();
101 } else if let Some(rest) = l.strip_prefix("author ") {
102 author = rest.to_string();
103 } else if let Some(rest) = l.strip_prefix("author-time ") {
104 author_time = rest.trim().parse().unwrap_or(0);
105 } else if let Some(rest) = l.strip_prefix("summary ") {
106 summary = rest.to_string();
107 } else if let Some(rest) = l.strip_prefix('\t') {
108 content = rest.to_string();
109 }
110 }
111 if commit.is_empty() {
112 return None;
113 }
114 Some(BlameLine {
115 path: rel_path.to_string(),
116 line,
117 commit: short_sha(&commit),
118 author,
119 author_time,
120 summary,
121 content,
122 })
123}
124
125pub fn line_history(
127 root: &Path,
128 rel_path: &str,
129 start: u32,
130 end: u32,
131 limit: usize,
132) -> Result<Vec<Commit>> {
133 let lspec = format!("{start},{end}:{rel_path}");
134 let out = git(
135 root,
136 &[
137 "log",
138 "-L",
139 &lspec,
140 "--no-patch",
141 &format!("--max-count={limit}"),
142 "--format=%H%x09%an%x09%at%x09%s",
143 ],
144 )?;
145 Ok(parse_commits(&out))
146}
147
148pub fn file_history(root: &Path, rel_path: &str, limit: usize) -> Result<Vec<Commit>> {
150 let out = git(
151 root,
152 &[
153 "log",
154 &format!("--max-count={limit}"),
155 "--format=%H%x09%an%x09%at%x09%s",
156 "--",
157 rel_path,
158 ],
159 )?;
160 Ok(parse_commits(&out))
161}
162
163fn parse_commits(out: &str) -> Vec<Commit> {
164 let mut commits = Vec::new();
165 for l in out.lines() {
166 let parts: Vec<&str> = l.splitn(4, '\t').collect();
167 if parts.len() == 4 {
168 commits.push(Commit {
169 commit: short_sha(parts[0]),
170 author: parts[1].to_string(),
171 author_time: parts[2].trim().parse().unwrap_or(0),
172 summary: parts[3].to_string(),
173 });
174 }
175 }
176 commits
177}
178
179pub fn changed_since(root: &Path, rev: &str) -> Result<Vec<ChangedFile>> {
181 let out = git(root, &["diff", "--name-status", rev, "--"])?;
182 let mut files = Vec::new();
183 for l in out.lines() {
184 let mut it = l.split('\t');
185 let status = it.next().unwrap_or("").to_string();
186 let path = it.next_back().unwrap_or("").to_string();
188 if !path.is_empty() {
189 files.push(ChangedFile {
190 path,
191 status: status.chars().next().map(String::from).unwrap_or_default(),
192 });
193 }
194 }
195 Ok(files)
196}
197
198fn short_sha(sha: &str) -> String {
199 sha.chars().take(12).collect()
200}