1use std::path::{Path, PathBuf};
6
7use crate::Repo;
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
225pub struct ChangedFile {
226 #[serde(with = "strop_core::path_serde")]
227 pub path: PathBuf,
228 pub added: usize,
229 pub deleted: usize,
230}
231
232pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
233 let out = std::process::Command::new("git")
234 .args([
235 "-C",
236 &workdir.display().to_string(),
237 "show",
238 "--numstat",
239 "--format=",
240 sha,
241 ])
242 .output()
243 .map_err(|e| format!("spawn git show: {e}"))?;
244 if !out.status.success() {
245 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
246 }
247 Ok(String::from_utf8_lossy(&out.stdout)
248 .lines()
249 .filter_map(|l| {
250 let mut parts = l.split('\t');
251 let added = parts.next()?.parse().ok()?;
252 let deleted = parts.next()?.parse().ok()?;
253 Some(ChangedFile {
254 path: PathBuf::from(parts.next()?),
255 added,
256 deleted,
257 })
258 })
259 .collect())
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum Host {
266 GitHub,
267 GitLab,
268 Bitbucket,
269 Gitea,
270 Other,
272}
273
274pub struct Remote {
275 pub host: Host,
276 pub owner_repo: String, pub base: String, }
279
280pub fn normalize_remote(url: &str) -> Option<Remote> {
283 let url = url.trim().trim_end_matches(".git");
284 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
285 let (host, path) = rest.split_once(':')?;
287 (format!("https://{host}"), path.to_string())
288 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
289 let rest = rest.split('/').collect::<Vec<_>>();
291 let host = rest.first()?;
292 (format!("https://{host}"), rest[1..].join("/"))
293 } else if url.starts_with("https://") || url.starts_with("http://") {
294 let stripped = url
295 .strip_prefix("https://")
296 .or_else(|| url.strip_prefix("http://"))?;
297 let (host, path) = stripped.split_once('/')?;
298 (format!("https://{host}"), path.to_string())
299 } else if let Some((host, path)) = url.split_once(':') {
300 if host.contains('@') || host.contains('/') {
303 return None;
304 }
305 let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
306 (format!("https://{host}"), path.to_string())
307 } else {
308 return None;
309 };
310 let host = match base.as_str() {
311 "https://github.com" => Host::GitHub,
312 "https://gitlab.com" => Host::GitLab,
313 "https://bitbucket.org" => Host::Bitbucket,
314 b if b.contains("gitea") => Host::Gitea,
315 _ => Host::Other,
316 };
317 Some(Remote {
318 host,
319 owner_repo: path,
320 base,
321 })
322}
323
324fn resolve_ssh_alias(alias: &str) -> Option<String> {
328 let home = std::env::var_os("HOME")?;
329 let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
330 parse_ssh_alias(&config, alias)
331}
332
333fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
334 let mut in_block = false;
335 for line in config.lines() {
336 let line = line.trim();
337 if line.is_empty() || line.starts_with('#') {
338 continue;
339 }
340 let mut parts = line.split_whitespace();
341 match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
342 Some("host") => in_block = parts.any(|h| h == alias),
343 Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
344 _ => {}
345 }
346 }
347 None
348}
349
350pub fn pick_remote(repo: &Repo) -> Option<Remote> {
352 pick_remote_from(&repo.remotes())
353}
354
355pub fn pick_remote_from(remotes: &[(String, String)]) -> Option<Remote> {
359 for name in ["upstream", "origin"] {
360 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
361 if let Some(r) = normalize_remote(url) {
362 return Some(r);
363 }
364 }
365 }
366 remotes.iter().find_map(|(_, u)| normalize_remote(u))
367}
368
369pub fn permalink(repo: &Repo, loc: &crate::SourceLocation) -> Option<String> {
374 permalink_with(
375 &repo.remotes(),
376 &|revision| match revision {
377 crate::GitRevision::Head | crate::GitRevision::Index | crate::GitRevision::Worktree => {
378 repo.head_sha()
379 }
380 crate::GitRevision::Commit(sha) => Some(sha.clone()),
381 crate::GitRevision::MergeBase(a, b) => repo.merge_base(a, b),
382 },
383 loc,
384 )
385}
386
387pub fn permalink_with(
391 remotes: &[(String, String)],
392 resolve: &dyn Fn(&crate::GitRevision) -> Option<String>,
393 loc: &crate::SourceLocation,
394) -> Option<String> {
395 let remote = pick_remote_from(remotes)?;
396 let (start_line, end_line) = loc.lines.unwrap_or((1, 1));
397 let sha = resolve(&loc.revision)?;
398 let frag = if start_line == end_line {
399 format!("#L{start_line}")
400 } else {
401 format!("#L{start_line}-L{end_line}")
402 };
403 Some(format!(
404 "{}/{}/blob/{}/{}{frag}",
405 remote.base,
406 remote.owner_repo,
407 sha,
408 loc.path.display()
409 ))
410}
411
412fn rel_age(ts: i64) -> String {
414 let now = std::time::SystemTime::now()
415 .duration_since(std::time::UNIX_EPOCH)
416 .map(|d| d.as_secs() as i64)
417 .unwrap_or(0);
418 let age = (now - ts).max(0);
419 match age {
420 a if a < 3600 => format!("{}m", a / 60),
421 a if a < 86400 => format!("{}h", a / 3600),
422 a if a < 86400 * 30 => format!("{}d", a / 86400),
423 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
424 a => format!("{}y", a / (86400 * 365)),
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn ssh_alias_resolves_via_config() {
434 let config = "# comment\nHost bbgithub\n HostName bbgithub.dev.bloomberg.com\n User git\nHost *\n ServerAliveInterval 30\n";
435 assert_eq!(
436 parse_ssh_alias(config, "bbgithub").as_deref(),
437 Some("bbgithub.dev.bloomberg.com")
438 );
439 assert_eq!(parse_ssh_alias(config, "other"), None);
440 assert_eq!(parse_ssh_alias("Host *\n HostName x", "bbgithub"), None);
442 }
443
444 #[test]
445 fn scp_without_user_parses_as_bare_host() {
446 let r = normalize_remote("bbgithub:acme/demo.git");
449 assert!(r.is_some(), "alias form parses");
450 }
451
452 #[test]
453 fn reviewer_table() {
454 for url in [
456 "https://github.com/acme/demo.git",
457 "ssh://git@github.com/acme/demo.git",
458 "git@github.com:acme/demo",
459 "git@bbgithub.dev.bloomberg.com:acme/demo.git",
460 "https://bbgithub.dev.bloomberg.com/acme/demo.git",
461 ] {
462 let r = normalize_remote(url);
463 assert!(r.is_some(), "should parse: {url}");
464 }
465 assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
468 }
469
470 #[test]
471 fn normalizes_ssh_and_https() {
472 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
473 assert_eq!(
474 (r.base.as_str(), r.owner_repo.as_str()),
475 ("https://github.com", "stropdev/strop")
476 );
477 assert_eq!(r.host, Host::GitHub);
478 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
479 assert_eq!(r.host, Host::GitLab);
480 assert_eq!(r.owner_repo, "org/proj");
481 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
482 assert_eq!(r.host, Host::Bitbucket);
483 assert!(normalize_remote("not a url").is_none());
484 }
485
486 #[test]
489 fn blame_file_attributes_lines() {
490 let dir = tempfile::tempdir().unwrap();
491 let root = dir.path();
492 let git = |args: &[&str]| {
493 std::process::Command::new("git")
494 .args(args)
495 .current_dir(root)
496 .output()
497 .unwrap();
498 };
499 git(&["init", "-q"]);
500 git(&["config", "user.email", "t@t.t"]);
501 git(&["config", "user.name", "t"]);
502 std::fs::write(root.join("f.rs"), "one\n").unwrap();
503 git(&["add", "."]);
504 git(&["commit", "-qm", "first"]);
505 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
506 git(&["commit", "-qam", "second"]);
507
508 let clean = blame_file(root, Path::new("f.rs")).unwrap();
509 assert_eq!(clean.len(), 2, "one BlameLine per file line");
510 assert_eq!(clean[0].author, "t");
511 assert_eq!(clean[1].author, "t");
512 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
513 assert!(!clean[0].is_uncommitted());
514
515 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
517 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
518 assert_eq!(dirty.len(), 3);
519 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
520 assert_eq!(dirty[2].age, "now");
521 assert_eq!(dirty[2].author, "you");
522 assert_eq!(dirty[2].ts, 0);
523 }
524
525 #[test]
526 fn blame_file_rejects_missing_file() {
527 let dir = tempfile::tempdir().unwrap();
528 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
529 }
530}