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 log_graph_range(workdir, max, file, None)
22}
23
24pub fn log_graph_range(
28 workdir: &Path,
29 max: usize,
30 file: Option<&Path>,
31 range: Option<(usize, usize)>,
32) -> Result<Vec<LogRow>, String> {
33 let mut cmd = std::process::Command::new("git");
34 let (marker_fmt, ranged) = match range {
35 Some(_) => ("%x01%h %an · %ar · %s%x00%H", true),
36 None => ("%h %an · %ar · %s%x00%H", false),
37 };
38 cmd.args([
39 "-C",
40 &workdir.display().to_string(),
41 "log",
42 &format!("--format={marker_fmt}"),
43 "-n",
44 &max.to_string(),
45 ]);
46 match (file, range) {
47 (Some(f), Some((a, b))) => {
48 cmd.arg(format!("-L{a},{b}:{}", f.display()));
49 }
50 (Some(f), None) => {
51 cmd.arg("--graph").arg("--").arg(f);
52 }
53 (None, None) => {
54 cmd.arg("--graph");
55 }
56 (None, Some(_)) => return Err("-L needs a file".into()),
57 }
58 let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
59 if !out.status.success() {
60 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
61 }
62 let text = String::from_utf8_lossy(&out.stdout);
63 Ok(text
64 .lines()
65 .filter(|line| !ranged || line.starts_with('\x01'))
67 .map(|line| {
68 let line = line.strip_prefix('\x01').unwrap_or(line);
69 let (vis, sha) = match line.split_once('\0') {
71 Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
72 None => (line.to_string(), None),
73 };
74 LogRow { text: vis, sha }
75 })
76 .collect())
77}
78
79#[derive(Debug, Clone)]
81pub struct BlameCard {
82 pub sha: String,
83 pub short_sha: String,
84 pub author: String,
85 pub age: String,
86 pub summary: String,
87 pub line: usize,
88}
89
90pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
92 let out = std::process::Command::new("git")
93 .args([
94 "-C",
95 &workdir.display().to_string(),
96 "blame",
97 "--line-porcelain",
98 "-L",
99 &format!("{line},{line}"),
100 "--",
101 &rel.display().to_string(),
102 ])
103 .output()
104 .map_err(|e| format!("spawn git blame: {e}"))?;
105 if !out.status.success() {
106 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
107 }
108 let text = String::from_utf8_lossy(&out.stdout);
109 let mut sha = String::new();
110 let mut author = String::new();
111 let mut summary = String::new();
112 let mut ts = 0i64;
113 for l in text.lines() {
114 if sha.is_empty()
115 && !l.starts_with('\t')
116 && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
117 {
118 sha = l.split_whitespace().next().unwrap_or("").to_string();
119 } else if let Some(a) = l.strip_prefix("author ") {
120 author = a.to_string();
121 } else if let Some(t) = l.strip_prefix("author-time ") {
122 ts = t.parse().unwrap_or(0);
123 } else if let Some(s) = l.strip_prefix("summary ") {
124 summary = s.to_string();
125 }
126 }
127 if sha.is_empty() {
128 return Err("no blame for line".into());
129 }
130 Ok(BlameCard {
131 short_sha: sha.chars().take(8).collect(),
132 sha,
133 author,
134 age: rel_age(ts),
135 summary,
136 line,
137 })
138}
139
140#[derive(Debug, Clone)]
144pub struct BlameLine {
145 pub sha: String,
146 pub author: String,
147 pub age: String,
149 pub ts: i64,
151}
152
153impl BlameLine {
154 pub fn is_uncommitted(&self) -> bool {
156 !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
157 }
158}
159
160pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
163 let out = std::process::Command::new("git")
164 .args([
165 "-C",
166 &workdir.display().to_string(),
167 "blame",
168 "--line-porcelain",
169 "--",
170 &rel.display().to_string(),
171 ])
172 .output()
173 .map_err(|e| format!("spawn git blame: {e}"))?;
174 if !out.status.success() {
175 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
176 }
177 let mut lines = Vec::new();
178 let mut sha = String::new();
179 let mut author = String::new();
180 let mut ts = 0i64;
181 for l in String::from_utf8_lossy(&out.stdout).lines() {
182 if let Some(content) = l.strip_prefix('\t') {
183 let _ = content;
186 if !sha.is_empty() {
187 let uncommitted = sha.chars().all(|c| c == '0');
188 lines.push(BlameLine {
189 sha: sha.clone(),
190 age: if uncommitted {
191 "now".into()
192 } else {
193 rel_age(ts)
194 },
195 author: if uncommitted {
196 "you".into()
197 } else {
198 author.clone()
199 },
200 ts: if uncommitted { 0 } else { ts },
201 });
202 }
203 sha.clear();
204 author.clear();
205 ts = 0;
206 } else if sha.is_empty()
207 && !l.is_empty()
208 && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
209 {
210 sha = l.split_whitespace().next().unwrap_or("").to_string();
211 } else if let Some(a) = l.strip_prefix("author ") {
212 author = a.to_string();
213 } else if let Some(t) = l.strip_prefix("author-time ") {
214 ts = t.parse().unwrap_or(0);
215 }
216 }
217 if lines.is_empty() {
218 return Err("no blame for file".into());
219 }
220 Ok(lines)
221}
222
223#[derive(Debug, Clone)]
225pub struct ChangedFile {
226 pub path: PathBuf,
227 pub added: usize,
228 pub deleted: usize,
229}
230
231pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
232 let out = std::process::Command::new("git")
233 .args([
234 "-C",
235 &workdir.display().to_string(),
236 "show",
237 "--numstat",
238 "--format=",
239 sha,
240 ])
241 .output()
242 .map_err(|e| format!("spawn git show: {e}"))?;
243 if !out.status.success() {
244 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
245 }
246 Ok(String::from_utf8_lossy(&out.stdout)
247 .lines()
248 .filter_map(|l| {
249 let mut parts = l.split('\t');
250 let added = parts.next()?.parse().ok()?;
251 let deleted = parts.next()?.parse().ok()?;
252 Some(ChangedFile {
253 path: PathBuf::from(parts.next()?),
254 added,
255 deleted,
256 })
257 })
258 .collect())
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum Host {
265 GitHub,
266 GitLab,
267 Bitbucket,
268 Gitea,
269 Other,
271}
272
273pub struct Remote {
274 pub host: Host,
275 pub owner_repo: String, pub base: String, }
278
279pub fn normalize_remote(url: &str) -> Option<Remote> {
282 let url = url.trim().trim_end_matches(".git");
283 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
284 let (host, path) = rest.split_once(':')?;
286 (format!("https://{host}"), path.to_string())
287 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
288 let rest = rest.split('/').collect::<Vec<_>>();
290 let host = rest.first()?;
291 (format!("https://{host}"), rest[1..].join("/"))
292 } else if url.starts_with("https://") || url.starts_with("http://") {
293 let stripped = url
294 .strip_prefix("https://")
295 .or_else(|| url.strip_prefix("http://"))?;
296 let (host, path) = stripped.split_once('/')?;
297 (format!("https://{host}"), path.to_string())
298 } else if let Some((host, path)) = url.split_once(':') {
299 if host.contains('@') || host.contains('/') {
302 return None;
303 }
304 let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
305 (format!("https://{host}"), path.to_string())
306 } else {
307 return None;
308 };
309 let host = match base.as_str() {
310 "https://github.com" => Host::GitHub,
311 "https://gitlab.com" => Host::GitLab,
312 "https://bitbucket.org" => Host::Bitbucket,
313 b if b.contains("gitea") => Host::Gitea,
314 _ => Host::Other,
315 };
316 Some(Remote {
317 host,
318 owner_repo: path,
319 base,
320 })
321}
322
323fn resolve_ssh_alias(alias: &str) -> Option<String> {
327 let home = std::env::var_os("HOME")?;
328 let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
329 parse_ssh_alias(&config, alias)
330}
331
332fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
333 let mut in_block = false;
334 for line in config.lines() {
335 let line = line.trim();
336 if line.is_empty() || line.starts_with('#') {
337 continue;
338 }
339 let mut parts = line.split_whitespace();
340 match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
341 Some("host") => in_block = parts.any(|h| h == alias),
342 Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
343 _ => {}
344 }
345 }
346 None
347}
348
349pub fn pick_remote(repo: &Repo) -> Option<Remote> {
351 let remotes = repo.remotes();
352 for name in ["upstream", "origin"] {
353 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
354 if let Some(r) = normalize_remote(url) {
355 return Some(r);
356 }
357 }
358 }
359 remotes.iter().find_map(|(_, u)| normalize_remote(u))
360}
361
362pub fn permalink(repo: &Repo, loc: &crate::SourceLocation) -> Option<String> {
367 let remote = pick_remote(repo)?;
368 let (start_line, end_line) = loc.lines.unwrap_or((1, 1));
369 let sha = match &loc.revision {
370 crate::GitRevision::Head => repo.head_sha()?,
371 crate::GitRevision::Commit(sha) => sha.clone(),
372 };
373 let frag = if start_line == end_line {
374 format!("#L{start_line}")
375 } else {
376 format!("#L{start_line}-L{end_line}")
377 };
378 Some(format!(
379 "{}/{}/blob/{}/{}{frag}",
380 remote.base,
381 remote.owner_repo,
382 sha,
383 loc.path.display()
384 ))
385}
386
387fn rel_age(ts: i64) -> String {
389 let now = std::time::SystemTime::now()
390 .duration_since(std::time::UNIX_EPOCH)
391 .map(|d| d.as_secs() as i64)
392 .unwrap_or(0);
393 let age = (now - ts).max(0);
394 match age {
395 a if a < 3600 => format!("{}m", a / 60),
396 a if a < 86400 => format!("{}h", a / 3600),
397 a if a < 86400 * 30 => format!("{}d", a / 86400),
398 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
399 a => format!("{}y", a / (86400 * 365)),
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn ssh_alias_resolves_via_config() {
409 let config = "# comment\nHost bbgithub\n HostName bbgithub.dev.bloomberg.com\n User git\nHost *\n ServerAliveInterval 30\n";
410 assert_eq!(
411 parse_ssh_alias(config, "bbgithub").as_deref(),
412 Some("bbgithub.dev.bloomberg.com")
413 );
414 assert_eq!(parse_ssh_alias(config, "other"), None);
415 assert_eq!(parse_ssh_alias("Host *\n HostName x", "bbgithub"), None);
417 }
418
419 #[test]
420 fn scp_without_user_parses_as_bare_host() {
421 let r = normalize_remote("bbgithub:acme/demo.git");
424 assert!(r.is_some(), "alias form parses");
425 }
426
427 #[test]
428 fn reviewer_table() {
429 for url in [
431 "https://github.com/acme/demo.git",
432 "ssh://git@github.com/acme/demo.git",
433 "git@github.com:acme/demo",
434 "git@bbgithub.dev.bloomberg.com:acme/demo.git",
435 "https://bbgithub.dev.bloomberg.com/acme/demo.git",
436 ] {
437 let r = normalize_remote(url);
438 assert!(r.is_some(), "should parse: {url}");
439 }
440 assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
443 }
444
445 #[test]
446 fn normalizes_ssh_and_https() {
447 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
448 assert_eq!(
449 (r.base.as_str(), r.owner_repo.as_str()),
450 ("https://github.com", "stropdev/strop")
451 );
452 assert_eq!(r.host, Host::GitHub);
453 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
454 assert_eq!(r.host, Host::GitLab);
455 assert_eq!(r.owner_repo, "org/proj");
456 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
457 assert_eq!(r.host, Host::Bitbucket);
458 assert!(normalize_remote("not a url").is_none());
459 }
460
461 #[test]
464 fn blame_file_attributes_lines() {
465 let dir = tempfile::tempdir().unwrap();
466 let root = dir.path();
467 let git = |args: &[&str]| {
468 std::process::Command::new("git")
469 .args(args)
470 .current_dir(root)
471 .output()
472 .unwrap();
473 };
474 git(&["init", "-q"]);
475 git(&["config", "user.email", "t@t.t"]);
476 git(&["config", "user.name", "t"]);
477 std::fs::write(root.join("f.rs"), "one\n").unwrap();
478 git(&["add", "."]);
479 git(&["commit", "-qm", "first"]);
480 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
481 git(&["commit", "-qam", "second"]);
482
483 let clean = blame_file(root, Path::new("f.rs")).unwrap();
484 assert_eq!(clean.len(), 2, "one BlameLine per file line");
485 assert_eq!(clean[0].author, "t");
486 assert_eq!(clean[1].author, "t");
487 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
488 assert!(!clean[0].is_uncommitted());
489
490 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
492 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
493 assert_eq!(dirty.len(), 3);
494 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
495 assert_eq!(dirty[2].age, "now");
496 assert_eq!(dirty[2].author, "you");
497 assert_eq!(dirty[2].ts, 0);
498 }
499
500 #[test]
501 fn blame_file_rejects_missing_file() {
502 let dir = tempfile::tempdir().unwrap();
503 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
504 }
505}