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 crate::GitRevision::Index | crate::GitRevision::Worktree => repo.head_sha()?,
375 crate::GitRevision::MergeBase(a, b) => repo.merge_base(a, b)?,
376 };
377 let frag = if start_line == end_line {
378 format!("#L{start_line}")
379 } else {
380 format!("#L{start_line}-L{end_line}")
381 };
382 Some(format!(
383 "{}/{}/blob/{}/{}{frag}",
384 remote.base,
385 remote.owner_repo,
386 sha,
387 loc.path.display()
388 ))
389}
390
391fn rel_age(ts: i64) -> String {
393 let now = std::time::SystemTime::now()
394 .duration_since(std::time::UNIX_EPOCH)
395 .map(|d| d.as_secs() as i64)
396 .unwrap_or(0);
397 let age = (now - ts).max(0);
398 match age {
399 a if a < 3600 => format!("{}m", a / 60),
400 a if a < 86400 => format!("{}h", a / 3600),
401 a if a < 86400 * 30 => format!("{}d", a / 86400),
402 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
403 a => format!("{}y", a / (86400 * 365)),
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn ssh_alias_resolves_via_config() {
413 let config = "# comment\nHost bbgithub\n HostName bbgithub.dev.bloomberg.com\n User git\nHost *\n ServerAliveInterval 30\n";
414 assert_eq!(
415 parse_ssh_alias(config, "bbgithub").as_deref(),
416 Some("bbgithub.dev.bloomberg.com")
417 );
418 assert_eq!(parse_ssh_alias(config, "other"), None);
419 assert_eq!(parse_ssh_alias("Host *\n HostName x", "bbgithub"), None);
421 }
422
423 #[test]
424 fn scp_without_user_parses_as_bare_host() {
425 let r = normalize_remote("bbgithub:acme/demo.git");
428 assert!(r.is_some(), "alias form parses");
429 }
430
431 #[test]
432 fn reviewer_table() {
433 for url in [
435 "https://github.com/acme/demo.git",
436 "ssh://git@github.com/acme/demo.git",
437 "git@github.com:acme/demo",
438 "git@bbgithub.dev.bloomberg.com:acme/demo.git",
439 "https://bbgithub.dev.bloomberg.com/acme/demo.git",
440 ] {
441 let r = normalize_remote(url);
442 assert!(r.is_some(), "should parse: {url}");
443 }
444 assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
447 }
448
449 #[test]
450 fn normalizes_ssh_and_https() {
451 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
452 assert_eq!(
453 (r.base.as_str(), r.owner_repo.as_str()),
454 ("https://github.com", "stropdev/strop")
455 );
456 assert_eq!(r.host, Host::GitHub);
457 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
458 assert_eq!(r.host, Host::GitLab);
459 assert_eq!(r.owner_repo, "org/proj");
460 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
461 assert_eq!(r.host, Host::Bitbucket);
462 assert!(normalize_remote("not a url").is_none());
463 }
464
465 #[test]
468 fn blame_file_attributes_lines() {
469 let dir = tempfile::tempdir().unwrap();
470 let root = dir.path();
471 let git = |args: &[&str]| {
472 std::process::Command::new("git")
473 .args(args)
474 .current_dir(root)
475 .output()
476 .unwrap();
477 };
478 git(&["init", "-q"]);
479 git(&["config", "user.email", "t@t.t"]);
480 git(&["config", "user.name", "t"]);
481 std::fs::write(root.join("f.rs"), "one\n").unwrap();
482 git(&["add", "."]);
483 git(&["commit", "-qm", "first"]);
484 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
485 git(&["commit", "-qam", "second"]);
486
487 let clean = blame_file(root, Path::new("f.rs")).unwrap();
488 assert_eq!(clean.len(), 2, "one BlameLine per file line");
489 assert_eq!(clean[0].author, "t");
490 assert_eq!(clean[1].author, "t");
491 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
492 assert!(!clean[0].is_uncommitted());
493
494 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
496 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
497 assert_eq!(dirty.len(), 3);
498 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
499 assert_eq!(dirty[2].age, "now");
500 assert_eq!(dirty[2].author, "you");
501 assert_eq!(dirty[2].ts, 0);
502 }
503
504 #[test]
505 fn blame_file_rejects_missing_file() {
506 let dir = tempfile::tempdir().unwrap();
507 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
508 }
509}