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.arg("-C").arg(workdir).args([
42 "log",
43 &format!("--format={marker_fmt}"),
44 "-n",
45 &max.to_string(),
46 ]);
47 match (file, range) {
48 (Some(f), Some((a, b))) => {
49 let mut spec = std::ffi::OsString::from(format!("-L{a},{b}:"));
52 spec.push(f);
53 cmd.arg(spec);
54 }
55 (Some(f), None) => {
56 cmd.arg("--graph").arg("--").arg(f);
57 }
58 (None, None) => {
59 cmd.arg("--graph");
60 }
61 (None, Some(_)) => return Err("-L needs a file".into()),
62 }
63 let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
64 if !out.status.success() {
65 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
66 }
67 let text = String::from_utf8_lossy(&out.stdout);
68 Ok(text
69 .lines()
70 .filter(|line| !ranged || line.starts_with('\x01'))
72 .map(|line| {
73 let line = line.strip_prefix('\x01').unwrap_or(line);
74 let (vis, sha) = match line.split_once('\0') {
76 Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
77 None => (line.to_string(), None),
78 };
79 LogRow { text: vis, sha }
80 })
81 .collect())
82}
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86pub struct BlameCard {
87 pub sha: String,
88 pub short_sha: String,
89 pub author: String,
90 pub age: String,
91 pub summary: String,
92 pub line: usize,
93}
94
95pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
97 let out = std::process::Command::new("git")
98 .arg("-C")
99 .arg(workdir)
100 .args(["blame", "--line-porcelain", "-L", &format!("{line},{line}")])
101 .arg("--")
102 .arg(rel)
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 .arg("-C")
165 .arg(workdir)
166 .args(["blame", "--line-porcelain"])
167 .arg("--")
168 .arg(rel)
169 .output()
170 .map_err(|e| format!("spawn git blame: {e}"))?;
171 if !out.status.success() {
172 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
173 }
174 let mut lines = Vec::new();
175 let mut sha = String::new();
176 let mut author = String::new();
177 let mut ts = 0i64;
178 for l in String::from_utf8_lossy(&out.stdout).lines() {
179 if let Some(content) = l.strip_prefix('\t') {
180 let _ = content;
183 if !sha.is_empty() {
184 let uncommitted = sha.chars().all(|c| c == '0');
185 lines.push(BlameLine {
186 sha: sha.clone(),
187 age: if uncommitted {
188 "now".into()
189 } else {
190 rel_age(ts)
191 },
192 author: if uncommitted {
193 "you".into()
194 } else {
195 author.clone()
196 },
197 ts: if uncommitted { 0 } else { ts },
198 });
199 }
200 sha.clear();
201 author.clear();
202 ts = 0;
203 } else if sha.is_empty()
204 && !l.is_empty()
205 && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
206 {
207 sha = l.split_whitespace().next().unwrap_or("").to_string();
208 } else if let Some(a) = l.strip_prefix("author ") {
209 author = a.to_string();
210 } else if let Some(t) = l.strip_prefix("author-time ") {
211 ts = t.parse().unwrap_or(0);
212 }
213 }
214 if lines.is_empty() {
215 return Err("no blame for file".into());
216 }
217 Ok(lines)
218}
219
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222pub struct ChangedFile {
223 #[serde(with = "strop_core::path_serde")]
224 pub path: PathBuf,
225 pub added: usize,
226 pub deleted: usize,
227}
228
229pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
234 let out = std::process::Command::new("git")
235 .arg("-C")
236 .arg(workdir)
237 .args(["show", "--numstat", "-z", "--format=", sha])
238 .output()
239 .map_err(|e| format!("spawn git show: {e}"))?;
240 if !out.status.success() {
241 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
242 }
243 crate::numstat::parse_numstat(&out.stdout)
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum Host {
250 GitHub,
251 GitLab,
252 Bitbucket,
253 Gitea,
254 Other,
256}
257
258pub struct Remote {
259 pub host: Host,
260 pub owner_repo: String, pub base: String, }
263
264pub fn normalize_remote(url: &str) -> Option<Remote> {
267 let url = url.trim().trim_end_matches(".git");
268 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
269 let (host, path) = rest.split_once(':')?;
271 (format!("https://{host}"), path.to_string())
272 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
273 let rest = rest.split('/').collect::<Vec<_>>();
275 let host = rest.first()?;
276 (format!("https://{host}"), rest[1..].join("/"))
277 } else if url.starts_with("https://") || url.starts_with("http://") {
278 let stripped = url
279 .strip_prefix("https://")
280 .or_else(|| url.strip_prefix("http://"))?;
281 let (host, path) = stripped.split_once('/')?;
282 (format!("https://{host}"), path.to_string())
283 } else if let Some((host, path)) = url.split_once(':') {
284 if host.contains('@') || host.contains('/') {
287 return None;
288 }
289 let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
290 (format!("https://{host}"), path.to_string())
291 } else {
292 return None;
293 };
294 let host = match base.as_str() {
295 "https://github.com" => Host::GitHub,
296 "https://gitlab.com" => Host::GitLab,
297 "https://bitbucket.org" => Host::Bitbucket,
298 b if b.contains("gitea") => Host::Gitea,
299 _ => Host::Other,
300 };
301 Some(Remote {
302 host,
303 owner_repo: path,
304 base,
305 })
306}
307
308fn resolve_ssh_alias(alias: &str) -> Option<String> {
312 let home = std::env::var_os("HOME")?;
313 let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
314 parse_ssh_alias(&config, alias)
315}
316
317fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
318 let mut in_block = false;
319 for line in config.lines() {
320 let line = line.trim();
321 if line.is_empty() || line.starts_with('#') {
322 continue;
323 }
324 let mut parts = line.split_whitespace();
325 match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
326 Some("host") => in_block = parts.any(|h| h == alias),
327 Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
328 _ => {}
329 }
330 }
331 None
332}
333
334pub fn pick_remote(repo: &Repo) -> Option<Remote> {
336 pick_remote_from(&repo.remotes())
337}
338
339pub fn pick_remote_from(remotes: &[(String, String)]) -> Option<Remote> {
343 for name in ["upstream", "origin"] {
344 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
345 if let Some(r) = normalize_remote(url) {
346 return Some(r);
347 }
348 }
349 }
350 remotes.iter().find_map(|(_, u)| normalize_remote(u))
351}
352
353pub fn permalink(repo: &Repo, loc: &crate::SourceLocation) -> Option<String> {
358 permalink_with(
359 &repo.remotes(),
360 &|revision| match revision {
361 crate::GitRevision::Head | crate::GitRevision::Index | crate::GitRevision::Worktree => {
362 repo.head_sha()
363 }
364 crate::GitRevision::Commit(sha) => Some(sha.clone()),
365 crate::GitRevision::MergeBase(a, b) => repo.merge_base(a, b),
366 },
367 loc,
368 )
369}
370
371pub fn permalink_with(
375 remotes: &[(String, String)],
376 resolve: &dyn Fn(&crate::GitRevision) -> Option<String>,
377 loc: &crate::SourceLocation,
378) -> Option<String> {
379 let remote = pick_remote_from(remotes)?;
380 let (start_line, end_line) = loc.lines.unwrap_or((1, 1));
381 let sha = resolve(&loc.revision)?;
382 let frag = if start_line == end_line {
383 format!("#L{start_line}")
384 } else {
385 format!("#L{start_line}-L{end_line}")
386 };
387 Some(format!(
388 "{}/{}/blob/{}/{}{frag}",
389 remote.base,
390 remote.owner_repo,
391 sha,
392 loc.path.display()
393 ))
394}
395
396fn rel_age(ts: i64) -> String {
398 let now = std::time::SystemTime::now()
399 .duration_since(std::time::UNIX_EPOCH)
400 .map(|d| d.as_secs() as i64)
401 .unwrap_or(0);
402 let age = (now - ts).max(0);
403 match age {
404 a if a < 3600 => format!("{}m", a / 60),
405 a if a < 86400 => format!("{}h", a / 3600),
406 a if a < 86400 * 30 => format!("{}d", a / 86400),
407 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
408 a => format!("{}y", a / (86400 * 365)),
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn ssh_alias_resolves_via_config() {
418 let config = "# comment\nHost bbgithub\n HostName bbgithub.dev.bloomberg.com\n User git\nHost *\n ServerAliveInterval 30\n";
419 assert_eq!(
420 parse_ssh_alias(config, "bbgithub").as_deref(),
421 Some("bbgithub.dev.bloomberg.com")
422 );
423 assert_eq!(parse_ssh_alias(config, "other"), None);
424 assert_eq!(parse_ssh_alias("Host *\n HostName x", "bbgithub"), None);
426 }
427
428 #[test]
429 fn scp_without_user_parses_as_bare_host() {
430 let r = normalize_remote("bbgithub:acme/demo.git");
433 assert!(r.is_some(), "alias form parses");
434 }
435
436 #[test]
437 fn reviewer_table() {
438 for url in [
440 "https://github.com/acme/demo.git",
441 "ssh://git@github.com/acme/demo.git",
442 "git@github.com:acme/demo",
443 "git@bbgithub.dev.bloomberg.com:acme/demo.git",
444 "https://bbgithub.dev.bloomberg.com/acme/demo.git",
445 ] {
446 let r = normalize_remote(url);
447 assert!(r.is_some(), "should parse: {url}");
448 }
449 assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
452 }
453
454 #[test]
455 fn normalizes_ssh_and_https() {
456 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
457 assert_eq!(
458 (r.base.as_str(), r.owner_repo.as_str()),
459 ("https://github.com", "stropdev/strop")
460 );
461 assert_eq!(r.host, Host::GitHub);
462 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
463 assert_eq!(r.host, Host::GitLab);
464 assert_eq!(r.owner_repo, "org/proj");
465 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
466 assert_eq!(r.host, Host::Bitbucket);
467 assert!(normalize_remote("not a url").is_none());
468 }
469
470 #[test]
473 fn blame_file_attributes_lines() {
474 let dir = tempfile::tempdir().unwrap();
475 let root = dir.path();
476 let git = |args: &[&str]| {
477 std::process::Command::new("git")
478 .args(args)
479 .current_dir(root)
480 .output()
481 .unwrap();
482 };
483 git(&["init", "-q"]);
484 git(&["config", "user.email", "t@t.t"]);
485 git(&["config", "user.name", "t"]);
486 std::fs::write(root.join("f.rs"), "one\n").unwrap();
487 git(&["add", "."]);
488 git(&["commit", "-qm", "first"]);
489 std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
490 git(&["commit", "-qam", "second"]);
491
492 let clean = blame_file(root, Path::new("f.rs")).unwrap();
493 assert_eq!(clean.len(), 2, "one BlameLine per file line");
494 assert_eq!(clean[0].author, "t");
495 assert_eq!(clean[1].author, "t");
496 assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
497 assert!(!clean[0].is_uncommitted());
498
499 std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
501 let dirty = blame_file(root, Path::new("f.rs")).unwrap();
502 assert_eq!(dirty.len(), 3);
503 assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
504 assert_eq!(dirty[2].age, "now");
505 assert_eq!(dirty[2].author, "you");
506 assert_eq!(dirty[2].ts, 0);
507 }
508
509 #[test]
510 fn blame_file_rejects_missing_file() {
511 let dir = tempfile::tempdir().unwrap();
512 assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
513 }
514
515 fn git_here(root: &Path, args: &[&str]) -> String {
518 let out = std::process::Command::new("git")
519 .args(args)
520 .current_dir(root)
521 .env("HOME", root)
522 .env("XDG_CONFIG_HOME", root.join(".xdg"))
523 .env("GIT_CONFIG_NOSYSTEM", "1")
524 .env("GIT_CONFIG_GLOBAL", "/dev/null")
525 .output()
526 .unwrap();
527 assert!(
528 out.status.success(),
529 "git {args:?}: {}",
530 String::from_utf8_lossy(&out.stderr).trim()
531 );
532 String::from_utf8_lossy(&out.stdout).trim().to_string()
533 }
534
535 #[test]
541 fn show_stat_keeps_native_paths() {
542 let dir = tempfile::tempdir().unwrap();
543 let root = dir.path();
544 git_here(root, &["init", "-q"]);
545 git_here(root, &["config", "user.email", "t@t.t"]);
546 git_here(root, &["config", "user.name", "t"]);
547 std::fs::create_dir(root.join("src")).unwrap();
548 std::fs::write(root.join("a.rs"), "one\n").unwrap();
549 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
550 std::fs::write(root.join("ren.txt"), "old\n").unwrap();
551 std::fs::write(root.join("bin.dat"), b"\0\x01binary\0").unwrap();
552 git_here(root, &["add", "."]);
553 git_here(root, &["commit", "-qm", "first"]);
554 git_here(root, &["mv", "ren.txt", "new.txt"]);
555 std::fs::write(root.join("a.rs"), "one\ntwo\nthree\n").unwrap();
556 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\nfn y() {}\n").unwrap();
557 std::fs::write(root.join("bin.dat"), b"\0\x01changed\0").unwrap();
558 git_here(root, &["add", "."]);
559 git_here(root, &["commit", "-qm", "second"]);
560 let sha = git_here(root, &["rev-parse", "HEAD"]);
561
562 let files = show_stat(root, &sha).unwrap();
563 assert_eq!(files.len(), 4, "{files:?}");
564 let row = |p: &str| {
565 files
566 .iter()
567 .find(|f| f.path == Path::new(p))
568 .unwrap_or_else(|| panic!("missing {p} in {files:?}"))
569 };
570 assert_eq!(row("a.rs").added, 2);
571 assert_eq!(row("a.rs").deleted, 0);
572 assert_eq!(row("src/日本語.rs").added, 1);
574 assert_eq!(row("new.txt").added, 0);
576 assert!(!files.iter().any(|f| f.path == Path::new("ren.txt")));
577 assert_eq!((row("bin.dat").added, row("bin.dat").deleted), (0, 0));
579 assert!(files
580 .iter()
581 .all(|f| !f.path.to_string_lossy().starts_with('"')));
582 }
583
584 #[test]
587 fn show_stat_paths_feed_commit_file_diff() {
588 let dir = tempfile::tempdir().unwrap();
589 let root = dir.path();
590 git_here(root, &["init", "-q"]);
591 git_here(root, &["config", "user.email", "t@t.t"]);
592 git_here(root, &["config", "user.name", "t"]);
593 std::fs::create_dir(root.join("src")).unwrap();
594 std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
595 git_here(root, &["add", "."]);
596 git_here(root, &["commit", "-qm", "first"]);
597 std::fs::write(
598 root.join("src/日本語.rs"),
599 "fn x() {}\nfn y() {}\nfn z() {}\n",
600 )
601 .unwrap();
602 git_here(root, &["commit", "-qam", "second"]);
603 let sha = git_here(root, &["rev-parse", "HEAD"]);
604
605 let files = show_stat(root, &sha).unwrap();
606 let uni = files
607 .iter()
608 .find(|f| f.path == Path::new("src/日本語.rs"))
609 .expect("native unicode path is a row");
610 let repo = Repo::discover(root).unwrap();
611 let diff = repo.commit_file_diff(&sha, &uni.path).unwrap();
612 assert_eq!(diff.added, 2);
613 assert_eq!(diff.deleted, 0);
614 }
615
616 #[cfg(unix)]
619 #[test]
620 fn show_stat_preserves_non_utf8_paths() {
621 use std::os::unix::ffi::OsStrExt;
622 let dir = tempfile::tempdir().unwrap();
623 let root = dir.path();
624 git_here(root, &["init", "-q"]);
625 git_here(root, &["config", "user.email", "t@t.t"]);
626 git_here(root, &["config", "user.name", "t"]);
627 std::fs::create_dir(root.join("src")).unwrap();
628 let name = std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs");
629 std::fs::write(root.join(name), "fn x() {}\n").unwrap();
630 git_here(root, &["add", "."]);
631 git_here(root, &["commit", "-qm", "first"]);
632 let sha = git_here(root, &["rev-parse", "HEAD"]);
633
634 let files = show_stat(root, &sha).unwrap();
635 assert_eq!(files.len(), 1, "{files:?}");
636 assert_eq!(files[0].path.as_os_str().as_bytes(), b"src/\xff\xfe.rs");
637 assert_eq!(files[0].added, 1);
638 }
639}