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 if let Some((host, path)) = url.split_once(':') {
272 if host.contains('@') || host.contains('/') {
275 return None;
276 }
277 let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
278 (format!("https://{host}"), path.to_string())
279 } else {
280 return None;
281 };
282 let host = match base.as_str() {
283 "https://github.com" => Host::GitHub,
284 "https://gitlab.com" => Host::GitLab,
285 "https://bitbucket.org" => Host::Bitbucket,
286 b if b.contains("gitea") => Host::Gitea,
287 _ => Host::Other,
288 };
289 Some(Remote {
290 host,
291 owner_repo: path,
292 base,
293 })
294}
295
296fn resolve_ssh_alias(alias: &str) -> Option<String> {
300 let home = std::env::var_os("HOME")?;
301 let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
302 parse_ssh_alias(&config, alias)
303}
304
305fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
306 let mut in_block = false;
307 for line in config.lines() {
308 let line = line.trim();
309 if line.is_empty() || line.starts_with('#') {
310 continue;
311 }
312 let mut parts = line.split_whitespace();
313 match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
314 Some("host") => in_block = parts.any(|h| h == alias),
315 Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
316 _ => {}
317 }
318 }
319 None
320}
321
322pub fn pick_remote(repo: &Repo) -> Option<Remote> {
324 let remotes = repo.remotes();
325 for name in ["upstream", "origin"] {
326 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
327 if let Some(r) = normalize_remote(url) {
328 return Some(r);
329 }
330 }
331 }
332 remotes.iter().find_map(|(_, u)| normalize_remote(u))
333}
334
335pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
338 let remote = pick_remote(repo)?;
339 let sha = repo.head_sha()?;
340 let frag = if start_line == end_line {
341 format!("#L{start_line}")
342 } else {
343 format!("#L{start_line}-L{end_line}")
344 };
345 Some(format!(
346 "{}/{}/blob/{}/{}{frag}",
347 remote.base,
348 remote.owner_repo,
349 sha,
350 rel.display()
351 ))
352}
353
354fn rel_age(ts: i64) -> String {
356 let now = std::time::SystemTime::now()
357 .duration_since(std::time::UNIX_EPOCH)
358 .map(|d| d.as_secs() as i64)
359 .unwrap_or(0);
360 let age = (now - ts).max(0);
361 match age {
362 a if a < 3600 => format!("{}m", a / 60),
363 a if a < 86400 => format!("{}h", a / 3600),
364 a if a < 86400 * 30 => format!("{}d", a / 86400),
365 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
366 a => format!("{}y", a / (86400 * 365)),
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn ssh_alias_resolves_via_config() {
376 let config = "# comment\nHost bbgithub\n HostName bbgithub.dev.bloomberg.com\n User git\nHost *\n ServerAliveInterval 30\n";
377 assert_eq!(
378 parse_ssh_alias(config, "bbgithub").as_deref(),
379 Some("bbgithub.dev.bloomberg.com")
380 );
381 assert_eq!(parse_ssh_alias(config, "other"), None);
382 assert_eq!(parse_ssh_alias("Host *\n HostName x", "bbgithub"), None);
384 }
385
386 #[test]
387 fn scp_without_user_parses_as_bare_host() {
388 let r = normalize_remote("bbgithub:acme/demo.git");
391 assert!(r.is_some(), "alias form parses");
392 }
393
394 #[test]
395 fn reviewer_table() {
396 for url in [
398 "https://github.com/acme/demo.git",
399 "ssh://git@github.com/acme/demo.git",
400 "git@github.com:acme/demo",
401 "git@bbgithub.dev.bloomberg.com:acme/demo.git",
402 "https://bbgithub.dev.bloomberg.com/acme/demo.git",
403 ] {
404 let r = normalize_remote(url);
405 assert!(r.is_some(), "should parse: {url}");
406 }
407 assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
410 }
411
412 #[test]
413 fn normalizes_ssh_and_https() {
414 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
415 assert_eq!(
416 (r.base.as_str(), r.owner_repo.as_str()),
417 ("https://github.com", "stropdev/strop")
418 );
419 assert_eq!(r.host, Host::GitHub);
420 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
421 assert_eq!(r.host, Host::GitLab);
422 assert_eq!(r.owner_repo, "org/proj");
423 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
424 assert_eq!(r.host, Host::Bitbucket);
425 assert!(normalize_remote("not a url").is_none());
426 }
427
428 #[test]
431 fn blame_file_attributes_lines() {
432 let dir = tempfile::tempdir().unwrap();
433 let root = dir.path();
434 let git = |args: &[&str]| {
435 std::process::Command::new("git")
436 .args(args)
437 .current_dir(root)
438 .output()
439 .unwrap();
440 };
441 git(&["init", "-q"]);
442 git(&["config", "user.email", "t@t.t"]);
443 git(&["config", "user.name", "t"]);
444 std::fs::write(root.join("f.rs"), "one\n").unwrap();
445 git(&["add", "."]);
446 git(&["commit", "-qm", "first"]);
447 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
448 git(&["commit", "-qam", "second"]);
449
450 let clean = blame_file(root, Path::new("f.rs")).unwrap();
451 assert_eq!(clean.len(), 2, "one BlameLine per file line");
452 assert_eq!(clean[0].author, "t");
453 assert_eq!(clean[1].author, "t");
454 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
455 assert!(!clean[0].is_uncommitted());
456
457 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
459 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
460 assert_eq!(dirty.len(), 3);
461 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
462 assert_eq!(dirty[2].age, "now");
463 assert_eq!(dirty[2].author, "you");
464 assert_eq!(dirty[2].ts, 0);
465 }
466
467 #[test]
468 fn blame_file_rejects_missing_file() {
469 let dir = tempfile::tempdir().unwrap();
470 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
471 }
472}