1use std::path::{Path, PathBuf};
6
7use crate::Repo;
8
9#[derive(Debug, Clone)]
11pub struct LogRow {
12 pub text: String,
14 pub sha: Option<String>,
16}
17
18pub fn log_graph(workdir: &Path, max: usize, file: Option<&Path>) -> Result<Vec<LogRow>, String> {
21 let mut cmd = std::process::Command::new("git");
22 cmd.args([
23 "-C",
24 &workdir.display().to_string(),
25 "log",
26 "--graph",
27 "--format=%h %an · %ar · %s%x00%H",
28 "-n",
29 &max.to_string(),
30 ]);
31 if let Some(f) = file {
32 cmd.arg("--").arg(f);
33 }
34 let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
35 if !out.status.success() {
36 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
37 }
38 let text = String::from_utf8_lossy(&out.stdout);
39 Ok(text
40 .lines()
41 .map(|line| {
42 let (vis, sha) = match line.split_once('\0') {
44 Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
45 None => (line.to_string(), None),
46 };
47 LogRow { text: vis, sha }
48 })
49 .collect())
50}
51
52#[derive(Debug, Clone)]
54pub struct BlameCard {
55 pub sha: String,
56 pub short_sha: String,
57 pub author: String,
58 pub age: String,
59 pub summary: String,
60 pub line: usize,
61}
62
63pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
65 let out = std::process::Command::new("git")
66 .args([
67 "-C",
68 &workdir.display().to_string(),
69 "blame",
70 "--line-porcelain",
71 "-L",
72 &format!("{line},{line}"),
73 "--",
74 &rel.display().to_string(),
75 ])
76 .output()
77 .map_err(|e| format!("spawn git blame: {e}"))?;
78 if !out.status.success() {
79 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
80 }
81 let text = String::from_utf8_lossy(&out.stdout);
82 let mut sha = String::new();
83 let mut author = String::new();
84 let mut summary = String::new();
85 let mut ts = 0i64;
86 for l in text.lines() {
87 if sha.is_empty()
88 && !l.starts_with('\t')
89 && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
90 {
91 sha = l.split_whitespace().next().unwrap_or("").to_string();
92 } else if let Some(a) = l.strip_prefix("author ") {
93 author = a.to_string();
94 } else if let Some(t) = l.strip_prefix("author-time ") {
95 ts = t.parse().unwrap_or(0);
96 } else if let Some(s) = l.strip_prefix("summary ") {
97 summary = s.to_string();
98 }
99 }
100 if sha.is_empty() {
101 return Err("no blame for line".into());
102 }
103 Ok(BlameCard {
104 short_sha: sha.chars().take(8).collect(),
105 sha,
106 author,
107 age: rel_age(ts),
108 summary,
109 line,
110 })
111}
112
113#[derive(Debug, Clone)]
117pub struct BlameLine {
118 pub sha: String,
119 pub author: String,
120 pub age: String,
122 pub ts: i64,
124}
125
126impl BlameLine {
127 pub fn is_uncommitted(&self) -> bool {
129 !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
130 }
131}
132
133pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
136 let out = std::process::Command::new("git")
137 .args([
138 "-C",
139 &workdir.display().to_string(),
140 "blame",
141 "--line-porcelain",
142 "--",
143 &rel.display().to_string(),
144 ])
145 .output()
146 .map_err(|e| format!("spawn git blame: {e}"))?;
147 if !out.status.success() {
148 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
149 }
150 let mut lines = Vec::new();
151 let mut sha = String::new();
152 let mut author = String::new();
153 let mut ts = 0i64;
154 for l in String::from_utf8_lossy(&out.stdout).lines() {
155 if let Some(content) = l.strip_prefix('\t') {
156 let _ = content;
159 if !sha.is_empty() {
160 let uncommitted = sha.chars().all(|c| c == '0');
161 lines.push(BlameLine {
162 sha: sha.clone(),
163 age: if uncommitted {
164 "now".into()
165 } else {
166 rel_age(ts)
167 },
168 author: if uncommitted {
169 "you".into()
170 } else {
171 author.clone()
172 },
173 ts: if uncommitted { 0 } else { ts },
174 });
175 }
176 sha.clear();
177 author.clear();
178 ts = 0;
179 } else if sha.is_empty()
180 && !l.is_empty()
181 && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
182 {
183 sha = l.split_whitespace().next().unwrap_or("").to_string();
184 } else if let Some(a) = l.strip_prefix("author ") {
185 author = a.to_string();
186 } else if let Some(t) = l.strip_prefix("author-time ") {
187 ts = t.parse().unwrap_or(0);
188 }
189 }
190 if lines.is_empty() {
191 return Err("no blame for file".into());
192 }
193 Ok(lines)
194}
195
196#[derive(Debug, Clone)]
198pub struct ChangedFile {
199 pub path: PathBuf,
200 pub added: usize,
201 pub deleted: usize,
202}
203
204pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
205 let out = std::process::Command::new("git")
206 .args([
207 "-C",
208 &workdir.display().to_string(),
209 "show",
210 "--numstat",
211 "--format=",
212 sha,
213 ])
214 .output()
215 .map_err(|e| format!("spawn git show: {e}"))?;
216 if !out.status.success() {
217 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
218 }
219 Ok(String::from_utf8_lossy(&out.stdout)
220 .lines()
221 .filter_map(|l| {
222 let mut parts = l.split('\t');
223 let added = parts.next()?.parse().ok()?;
224 let deleted = parts.next()?.parse().ok()?;
225 Some(ChangedFile {
226 path: PathBuf::from(parts.next()?),
227 added,
228 deleted,
229 })
230 })
231 .collect())
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum Host {
238 GitHub,
239 GitLab,
240 Bitbucket,
241 Gitea,
242 Other,
244}
245
246pub struct Remote {
247 pub host: Host,
248 pub owner_repo: String, pub base: String, }
251
252pub fn normalize_remote(url: &str) -> Option<Remote> {
255 let url = url.trim().trim_end_matches(".git");
256 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
257 let (host, path) = rest.split_once(':')?;
259 (format!("https://{host}"), path.to_string())
260 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
261 let rest = rest.split('/').collect::<Vec<_>>();
263 let host = rest.first()?;
264 (format!("https://{host}"), rest[1..].join("/"))
265 } else if url.starts_with("https://") || url.starts_with("http://") {
266 let stripped = url
267 .strip_prefix("https://")
268 .or_else(|| url.strip_prefix("http://"))?;
269 let (host, path) = stripped.split_once('/')?;
270 (format!("https://{host}"), path.to_string())
271 } else {
272 return None;
273 };
274 let host = match base.as_str() {
275 "https://github.com" => Host::GitHub,
276 "https://gitlab.com" => Host::GitLab,
277 "https://bitbucket.org" => Host::Bitbucket,
278 b if b.contains("gitea") => Host::Gitea,
279 _ => Host::Other,
280 };
281 Some(Remote {
282 host,
283 owner_repo: path,
284 base,
285 })
286}
287
288pub fn pick_remote(repo: &Repo) -> Option<Remote> {
290 let remotes = repo.remotes();
291 for name in ["upstream", "origin"] {
292 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
293 if let Some(r) = normalize_remote(url) {
294 return Some(r);
295 }
296 }
297 }
298 remotes.iter().find_map(|(_, u)| normalize_remote(u))
299}
300
301pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
304 let remote = pick_remote(repo)?;
305 let sha = repo.head_sha()?;
306 let frag = if start_line == end_line {
307 format!("#L{start_line}")
308 } else {
309 format!("#L{start_line}-L{end_line}")
310 };
311 Some(format!(
312 "{}/{}/blob/{}/{}{frag}",
313 remote.base,
314 remote.owner_repo,
315 sha,
316 rel.display()
317 ))
318}
319
320fn rel_age(ts: i64) -> String {
322 let now = std::time::SystemTime::now()
323 .duration_since(std::time::UNIX_EPOCH)
324 .map(|d| d.as_secs() as i64)
325 .unwrap_or(0);
326 let age = (now - ts).max(0);
327 match age {
328 a if a < 3600 => format!("{}m", a / 60),
329 a if a < 86400 => format!("{}h", a / 3600),
330 a if a < 86400 * 30 => format!("{}d", a / 86400),
331 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
332 a => format!("{}y", a / (86400 * 365)),
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn normalizes_ssh_and_https() {
342 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
343 assert_eq!(
344 (r.base.as_str(), r.owner_repo.as_str()),
345 ("https://github.com", "stropdev/strop")
346 );
347 assert_eq!(r.host, Host::GitHub);
348 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
349 assert_eq!(r.host, Host::GitLab);
350 assert_eq!(r.owner_repo, "org/proj");
351 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
352 assert_eq!(r.host, Host::Bitbucket);
353 assert!(normalize_remote("not a url").is_none());
354 }
355
356 #[test]
359 fn blame_file_attributes_lines() {
360 let dir = tempfile::tempdir().unwrap();
361 let root = dir.path();
362 let git = |args: &[&str]| {
363 std::process::Command::new("git")
364 .args(args)
365 .current_dir(root)
366 .output()
367 .unwrap();
368 };
369 git(&["init", "-q"]);
370 git(&["config", "user.email", "t@t.t"]);
371 git(&["config", "user.name", "t"]);
372 std::fs::write(root.join("f.rs"), "one\n").unwrap();
373 git(&["add", "."]);
374 git(&["commit", "-qm", "first"]);
375 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
376 git(&["commit", "-qam", "second"]);
377
378 let clean = blame_file(root, Path::new("f.rs")).unwrap();
379 assert_eq!(clean.len(), 2, "one BlameLine per file line");
380 assert_eq!(clean[0].author, "t");
381 assert_eq!(clean[1].author, "t");
382 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
383 assert!(!clean[0].is_uncommitted());
384
385 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
387 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
388 assert_eq!(dirty.len(), 3);
389 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
390 assert_eq!(dirty[2].age, "now");
391 assert_eq!(dirty[2].author, "you");
392 assert_eq!(dirty[2].ts, 0);
393 }
394
395 #[test]
396 fn blame_file_rejects_missing_file() {
397 let dir = tempfile::tempdir().unwrap();
398 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
399 }
400}