1use std::collections::HashSet;
2use std::path::Path;
3use std::process::Command;
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Local};
7
8use crate::model::{BranchLog, Commit, DiffStat, ProjectLog, RepoOrigin};
9use crate::period::TimeRange;
10
11pub fn default_author() -> Option<String> {
12 Command::new("git")
13 .args(["config", "--global", "user.name"])
14 .output()
15 .ok()
16 .and_then(|out| {
17 if out.status.success() {
18 String::from_utf8(out.stdout)
19 .ok()
20 .map(|s| s.trim().to_string())
21 .filter(|s| !s.is_empty())
22 } else {
23 None
24 }
25 })
26}
27
28fn list_branches(repo: &Path) -> Result<Vec<String>> {
29 let output = Command::new("git")
30 .args([
31 "-C",
32 &repo.to_string_lossy(),
33 "branch",
34 "--format=%(refname:short)",
35 ])
36 .output()
37 .context("Failed to run git branch")?;
38
39 if !output.status.success() {
40 return Ok(vec![]);
41 }
42
43 Ok(String::from_utf8_lossy(&output.stdout)
44 .lines()
45 .map(|l| l.trim().to_string())
46 .filter(|l| !l.is_empty())
47 .collect())
48}
49
50fn log_branch(
51 repo: &Path,
52 branch: &str,
53 range: &TimeRange,
54 author: Option<&str>,
55 with_stat: bool,
56) -> Result<(Vec<Commit>, Option<DiffStat>, HashSet<String>)> {
57 let since_str = range.since.to_rfc3339();
58
59 let mut args = vec![
60 "-C".to_string(),
61 repo.to_string_lossy().to_string(),
62 "log".to_string(),
63 branch.to_string(),
64 format!("--after={since_str}"),
65 "--format=%h%x00%s%x00%aI".to_string(),
66 "--no-merges".to_string(),
67 ];
68
69 if with_stat {
70 args.push("--numstat".to_string());
71 }
72
73 if let Some(until) = &range.until {
74 args.push(format!("--before={}", until.to_rfc3339()));
75 }
76
77 if let Some(author) = author {
78 args.push(format!("--author={author}"));
79 }
80
81 let output = Command::new("git")
82 .args(&args)
83 .output()
84 .context("Failed to run git log")?;
85
86 if !output.status.success() {
87 return Ok((vec![], None, HashSet::new()));
88 }
89
90 let now = Local::now();
91 let stdout = String::from_utf8_lossy(&output.stdout);
92 let (commits, branch_files) = parse_log_output(&stdout, now, with_stat);
93
94 let branch_stat = if with_stat && !commits.is_empty() {
95 let insertions: u32 = commits
96 .iter()
97 .filter_map(|c| c.diff_stat.as_ref())
98 .map(|s| s.insertions)
99 .sum();
100 let deletions: u32 = commits
101 .iter()
102 .filter_map(|c| c.diff_stat.as_ref())
103 .map(|s| s.deletions)
104 .sum();
105 Some(DiffStat {
106 files_changed: branch_files.len() as u32,
107 insertions,
108 deletions,
109 })
110 } else {
111 None
112 };
113
114 Ok((commits, branch_stat, branch_files))
115}
116
117fn parse_log_output(
118 stdout: &str,
119 now: DateTime<Local>,
120 with_stat: bool,
121) -> (Vec<Commit>, HashSet<String>) {
122 let mut commits = Vec::new();
123 let mut branch_files = HashSet::new();
124 let mut current_insertions: u32 = 0;
125 let mut current_deletions: u32 = 0;
126 let mut current_files: u32 = 0;
127
128 for line in stdout.lines() {
129 if line.is_empty() {
130 continue;
131 }
132 if line.contains('\0') {
133 if with_stat {
135 if let Some(prev) = commits.last_mut() {
136 let prev: &mut Commit = prev;
137 if current_files > 0 {
138 prev.diff_stat = Some(DiffStat {
139 files_changed: current_files,
140 insertions: current_insertions,
141 deletions: current_deletions,
142 });
143 }
144 }
145 }
146 current_insertions = 0;
147 current_deletions = 0;
148 current_files = 0;
149
150 if let Some(commit) = parse_commit_line(line, now) {
151 commits.push(commit);
152 }
153 } else if with_stat {
154 if let Some((ins, del, path)) = parse_numstat_line(line) {
155 current_insertions += ins;
156 current_deletions += del;
157 current_files += 1;
158 branch_files.insert(path);
159 }
160 }
161 }
162
163 if with_stat {
165 if let Some(last) = commits.last_mut() {
166 if current_files > 0 {
167 last.diff_stat = Some(DiffStat {
168 files_changed: current_files,
169 insertions: current_insertions,
170 deletions: current_deletions,
171 });
172 }
173 }
174 }
175
176 (commits, branch_files)
177}
178
179fn parse_numstat_line(line: &str) -> Option<(u32, u32, String)> {
180 let parts: Vec<&str> = line.split('\t').collect();
181 if parts.len() != 3 {
182 return None;
183 }
184 let ins = parts[0].parse::<u32>().unwrap_or(0);
185 let del = parts[1].parse::<u32>().unwrap_or(0);
186 Some((ins, del, parts[2].to_string()))
187}
188
189fn parse_commit_line(line: &str, now: DateTime<Local>) -> Option<Commit> {
190 let parts: Vec<&str> = line.splitn(3, '\0').collect();
191 if parts.len() != 3 {
192 return None;
193 }
194
195 let time = DateTime::parse_from_rfc3339(parts[2])
196 .ok()?
197 .with_timezone(&Local);
198
199 Some(Commit {
200 hash: parts[0].to_string(),
201 message: parts[1].to_string(),
202 commit_type: detect_commit_type(parts[1]),
203 relative_time: format_relative(now, time),
204 time,
205 url: None,
206 diff_stat: None,
207 })
208}
209
210fn detect_commit_type(message: &str) -> Option<String> {
211 let prefix = message.split([':', '(']).next()?;
212 let trimmed = prefix.trim();
213 let is_kebab_case = !trimmed.is_empty()
214 && trimmed
215 .split('-')
216 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_lowercase()));
217 if is_kebab_case {
218 Some(trimmed.to_string())
219 } else {
220 None
221 }
222}
223
224fn format_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
225 let duration = now.signed_duration_since(then);
226 let mins = duration.num_minutes();
227
228 if mins < 1 {
229 "just now".to_string()
230 } else if mins < 60 {
231 format!("{mins}m ago")
232 } else if mins < 1440 {
233 format!("{}h ago", duration.num_hours())
234 } else {
235 format!("{}d ago", duration.num_days())
236 }
237}
238
239fn get_remote_url(repo: &Path) -> Option<String> {
240 let output = Command::new("git")
241 .args(["-C", &repo.to_string_lossy(), "remote", "get-url", "origin"])
242 .output()
243 .ok()?;
244
245 if !output.status.success() {
246 return None;
247 }
248
249 let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
250 if url.is_empty() {
251 None
252 } else {
253 Some(url)
254 }
255}
256
257fn extract_hostname(url: &str) -> Option<&str> {
258 if let Some(rest) = url.strip_prefix("git@") {
260 return rest.split(':').next();
261 }
262 if let Some(rest) = url.strip_prefix("ssh://") {
264 let after_at = rest.split('@').next_back()?;
265 return after_at
266 .split('/')
267 .next()
268 .map(|h| h.split(':').next().unwrap_or(h));
269 }
270 if url.starts_with("https://") || url.starts_with("http://") {
272 let without_scheme = url.split("://").nth(1)?;
273 let after_auth = without_scheme.split('@').next_back()?;
274 return after_auth.split('/').next();
275 }
276 None
277}
278
279fn classify_host(hostname: &str) -> RepoOrigin {
280 let lower = hostname.to_lowercase();
281 if lower == "github.com" {
282 RepoOrigin::GitHub
283 } else if lower == "gitlab.com" {
284 RepoOrigin::GitLab
285 } else if lower == "bitbucket.org" {
286 RepoOrigin::Bitbucket
287 } else if lower.contains("gitlab") {
288 RepoOrigin::GitLabSelfHosted
289 } else {
290 RepoOrigin::Custom(hostname.to_string())
291 }
292}
293
294pub fn remote_to_browser_url(raw: &str) -> Option<String> {
296 let mut url = raw.trim().to_string();
297
298 if url.starts_with("git@") {
300 url = url.replacen("git@", "https://", 1);
301 if let Some(pos) = url.find(':') {
302 let after_scheme = &url["https://".len()..];
304 if let Some(colon) = after_scheme.find(':') {
305 let abs = "https://".len() + colon;
306 url.replace_range(abs..abs + 1, "/");
307 } else {
308 url.replace_range(pos..pos + 1, "/");
309 }
310 }
311 }
312
313 if url.starts_with("ssh://") {
315 url = url.replacen("ssh://", "https://", 1);
316 if let Some(at) = url.find('@') {
317 url = format!("https://{}", &url[at + 1..]);
318 }
319 }
320
321 if url.ends_with(".git") {
322 url.truncate(url.len() - 4);
323 }
324
325 if url.starts_with("https://") || url.starts_with("http://") {
326 Some(url)
327 } else {
328 None
329 }
330}
331
332pub fn browser_url(repo: &Path) -> Option<String> {
333 let raw = get_remote_url(repo)?;
334 remote_to_browser_url(&raw)
335}
336
337pub fn detect_origin(repo: &Path) -> Option<RepoOrigin> {
338 let url = get_remote_url(repo)?;
339 let hostname = extract_hostname(&url)?;
340 Some(classify_host(hostname))
341}
342
343pub fn branch_url(remote_url: &str, origin: Option<&RepoOrigin>, branch: &str) -> String {
345 let encoded = urlencoded(branch);
346 match origin {
347 Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
348 format!("{remote_url}/-/tree/{encoded}")
349 }
350 Some(RepoOrigin::Bitbucket) => {
351 format!("{remote_url}/branch/{encoded}")
352 }
353 _ => {
354 format!("{remote_url}/tree/{encoded}")
356 }
357 }
358}
359
360pub fn commit_url(remote_url: &str, origin: Option<&RepoOrigin>, hash: &str) -> String {
362 match origin {
363 Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
364 format!("{remote_url}/-/commit/{hash}")
365 }
366 Some(RepoOrigin::Bitbucket) => {
367 format!("{remote_url}/commits/{hash}")
368 }
369 _ => {
370 format!("{remote_url}/commit/{hash}")
371 }
372 }
373}
374
375fn urlencoded(s: &str) -> String {
377 s.replace('%', "%25")
378 .replace(' ', "%20")
379 .replace('#', "%23")
380 .replace('?', "%3F")
381}
382
383pub fn collect_project_log(
384 repo: &Path,
385 range: &TimeRange,
386 author: Option<&str>,
387 with_stat: bool,
388) -> Option<ProjectLog> {
389 let project_name = repo.file_name()?.to_string_lossy().to_string();
390 let branches = list_branches(repo).ok()?;
391 let origin = detect_origin(repo);
392 let remote = browser_url(repo);
393
394 let mut project_files: HashSet<String> = HashSet::new();
395 let mut project_insertions: u32 = 0;
396 let mut project_deletions: u32 = 0;
397
398 let mut branch_logs: Vec<BranchLog> = branches
399 .into_iter()
400 .filter_map(|branch_name| {
401 let (mut commits, branch_stat, branch_file_set) =
402 log_branch(repo, &branch_name, range, author, with_stat).ok()?;
403 if commits.is_empty() {
404 None
405 } else {
406 if let Some(base) = &remote {
407 for c in &mut commits {
408 c.url = Some(commit_url(base, origin.as_ref(), &c.hash));
409 }
410 }
411 let b_url = remote
412 .as_deref()
413 .map(|base| branch_url(base, origin.as_ref(), &branch_name));
414
415 if let Some(stat) = &branch_stat {
416 project_insertions += stat.insertions;
417 project_deletions += stat.deletions;
418 project_files.extend(branch_file_set);
419 }
420
421 Some(BranchLog {
422 name: branch_name,
423 url: b_url,
424 commits,
425 diff_stat: branch_stat,
426 })
427 }
428 })
429 .collect();
430
431 if branch_logs.is_empty() {
432 return None;
433 }
434
435 branch_logs.sort_by(|a, b| {
436 let a_primary = is_primary_branch(&a.name);
437 let b_primary = is_primary_branch(&b.name);
438 b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
439 });
440
441 let project_stat = if with_stat {
442 Some(DiffStat {
443 files_changed: project_files.len() as u32,
444 insertions: project_insertions,
445 deletions: project_deletions,
446 })
447 } else {
448 None
449 };
450
451 Some(ProjectLog {
452 project: project_name,
453 path: repo.to_string_lossy().to_string(),
454 origin,
455 remote_url: remote,
456 branches: branch_logs,
457 diff_stat: project_stat,
458 })
459}
460
461fn is_primary_branch(name: &str) -> bool {
462 matches!(name, "main" | "master")
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use chrono::Duration;
469
470 #[test]
471 fn format_relative_just_now() {
472 let now = Local::now();
473 assert_eq!(format_relative(now, now), "just now");
474 }
475
476 #[test]
477 fn format_relative_minutes() {
478 let now = Local::now();
479 let then = now - Duration::minutes(5);
480 assert_eq!(format_relative(now, then), "5m ago");
481 }
482
483 #[test]
484 fn format_relative_hours() {
485 let now = Local::now();
486 let then = now - Duration::hours(3);
487 assert_eq!(format_relative(now, then), "3h ago");
488 }
489
490 #[test]
491 fn format_relative_days() {
492 let now = Local::now();
493 let then = now - Duration::days(2);
494 assert_eq!(format_relative(now, then), "2d ago");
495 }
496
497 #[test]
498 fn detect_feat() {
499 assert_eq!(
500 detect_commit_type("feat: add spinner"),
501 Some("feat".to_string())
502 );
503 }
504
505 #[test]
506 fn detect_fix() {
507 assert_eq!(
508 detect_commit_type("fix: off-by-one error"),
509 Some("fix".to_string())
510 );
511 }
512
513 #[test]
514 fn detect_scoped() {
515 assert_eq!(
516 detect_commit_type("feat(auth): add OAuth"),
517 Some("feat".to_string())
518 );
519 }
520
521 #[test]
522 fn detect_release() {
523 assert_eq!(
524 detect_commit_type("release: v1.0.0"),
525 Some("release".to_string())
526 );
527 }
528
529 #[test]
530 fn detect_none_for_lone_hyphen() {
531 assert_eq!(detect_commit_type("-: bad"), None);
532 }
533
534 #[test]
535 fn detect_none_for_double_hyphen() {
536 assert_eq!(detect_commit_type("feat--api: bad"), None);
537 }
538
539 #[test]
540 fn detect_none_for_regular_message() {
541 assert_eq!(detect_commit_type("Update README"), None);
542 }
543
544 #[test]
545 fn detect_none_for_empty() {
546 assert_eq!(detect_commit_type(""), None);
547 }
548
549 #[test]
550 fn parse_commit_line_valid() {
551 let now = Local::now();
552 let time_str = now.to_rfc3339();
553 let line = format!("abc1234\x00feat: add feature\x00{time_str}");
554 let commit = parse_commit_line(&line, now);
555 assert!(commit.is_some());
556 let c = commit.unwrap_or_else(|| panic!("Expected Some"));
557 assert_eq!(c.hash, "abc1234");
558 assert_eq!(c.message, "feat: add feature");
559 assert_eq!(c.commit_type, Some("feat".to_string()));
560 }
561
562 #[test]
563 fn parse_commit_line_invalid() {
564 let now = Local::now();
565 assert!(parse_commit_line("incomplete line", now).is_none());
566 }
567
568 #[test]
569 fn primary_branch_detected() {
570 assert!(is_primary_branch("main"));
571 assert!(is_primary_branch("master"));
572 assert!(!is_primary_branch("feature/auth"));
573 assert!(!is_primary_branch("develop"));
574 }
575
576 #[test]
577 fn extract_hostname_https() {
578 assert_eq!(
579 extract_hostname("https://github.com/user/repo.git"),
580 Some("github.com")
581 );
582 assert_eq!(
583 extract_hostname("https://gitlab.com/group/project"),
584 Some("gitlab.com")
585 );
586 }
587
588 #[test]
589 fn extract_hostname_http() {
590 assert_eq!(
591 extract_hostname("http://gitea.local/org/repo"),
592 Some("gitea.local")
593 );
594 }
595
596 #[test]
597 fn extract_hostname_ssh_git_at() {
598 assert_eq!(
599 extract_hostname("git@github.com:user/repo.git"),
600 Some("github.com")
601 );
602 assert_eq!(
603 extract_hostname("git@gitlab.company.de:group/project.git"),
604 Some("gitlab.company.de")
605 );
606 }
607
608 #[test]
609 fn extract_hostname_ssh_scheme() {
610 assert_eq!(
611 extract_hostname("ssh://git@bitbucket.org/team/repo.git"),
612 Some("bitbucket.org")
613 );
614 assert_eq!(
615 extract_hostname("ssh://git@gitlab.internal:2222/group/repo.git"),
616 Some("gitlab.internal")
617 );
618 }
619
620 #[test]
621 fn extract_hostname_https_with_auth() {
622 assert_eq!(
623 extract_hostname("https://token@github.com/user/repo.git"),
624 Some("github.com")
625 );
626 }
627
628 #[test]
629 fn extract_hostname_empty() {
630 assert_eq!(extract_hostname(""), None);
631 assert_eq!(extract_hostname("not-a-url"), None);
632 }
633
634 #[test]
635 fn classify_github() {
636 assert_eq!(classify_host("github.com"), RepoOrigin::GitHub);
637 assert_eq!(classify_host("GitHub.com"), RepoOrigin::GitHub);
638 }
639
640 #[test]
641 fn classify_gitlab() {
642 assert_eq!(classify_host("gitlab.com"), RepoOrigin::GitLab);
643 }
644
645 #[test]
646 fn classify_bitbucket() {
647 assert_eq!(classify_host("bitbucket.org"), RepoOrigin::Bitbucket);
648 }
649
650 #[test]
651 fn classify_gitlab_self_hosted() {
652 assert_eq!(
653 classify_host("gitlab.company.de"),
654 RepoOrigin::GitLabSelfHosted
655 );
656 assert_eq!(
657 classify_host("gitlab.internal"),
658 RepoOrigin::GitLabSelfHosted
659 );
660 }
661
662 #[test]
663 fn classify_custom() {
664 assert_eq!(
665 classify_host("gitea.local"),
666 RepoOrigin::Custom("gitea.local".to_string())
667 );
668 assert_eq!(
669 classify_host("codeberg.org"),
670 RepoOrigin::Custom("codeberg.org".to_string())
671 );
672 }
673
674 #[test]
675 fn branch_url_github() {
676 let url = branch_url(
677 "https://github.com/user/repo",
678 Some(&RepoOrigin::GitHub),
679 "main",
680 );
681 assert_eq!(url, "https://github.com/user/repo/tree/main");
682 }
683
684 #[test]
685 fn branch_url_github_with_slash() {
686 let url = branch_url(
687 "https://github.com/user/repo",
688 Some(&RepoOrigin::GitHub),
689 "feature/auth",
690 );
691 assert_eq!(url, "https://github.com/user/repo/tree/feature/auth");
692 }
693
694 #[test]
695 fn branch_url_gitlab() {
696 let url = branch_url(
697 "https://gitlab.com/group/project",
698 Some(&RepoOrigin::GitLab),
699 "develop",
700 );
701 assert_eq!(url, "https://gitlab.com/group/project/-/tree/develop");
702 }
703
704 #[test]
705 fn branch_url_gitlab_self_hosted() {
706 let url = branch_url(
707 "https://gitlab.company.de/team/repo",
708 Some(&RepoOrigin::GitLabSelfHosted),
709 "main",
710 );
711 assert_eq!(url, "https://gitlab.company.de/team/repo/-/tree/main");
712 }
713
714 #[test]
715 fn branch_url_bitbucket() {
716 let url = branch_url(
717 "https://bitbucket.org/team/repo",
718 Some(&RepoOrigin::Bitbucket),
719 "main",
720 );
721 assert_eq!(url, "https://bitbucket.org/team/repo/branch/main");
722 }
723
724 #[test]
725 fn branch_url_no_origin_defaults_to_tree() {
726 let url = branch_url("https://gitea.local/org/repo", None, "main");
727 assert_eq!(url, "https://gitea.local/org/repo/tree/main");
728 }
729
730 #[test]
731 fn commit_url_github() {
732 let url = commit_url(
733 "https://github.com/user/repo",
734 Some(&RepoOrigin::GitHub),
735 "abc1234",
736 );
737 assert_eq!(url, "https://github.com/user/repo/commit/abc1234");
738 }
739
740 #[test]
741 fn commit_url_gitlab() {
742 let url = commit_url(
743 "https://gitlab.com/group/project",
744 Some(&RepoOrigin::GitLab),
745 "abc1234",
746 );
747 assert_eq!(url, "https://gitlab.com/group/project/-/commit/abc1234");
748 }
749
750 #[test]
751 fn commit_url_bitbucket() {
752 let url = commit_url(
753 "https://bitbucket.org/team/repo",
754 Some(&RepoOrigin::Bitbucket),
755 "abc1234",
756 );
757 assert_eq!(url, "https://bitbucket.org/team/repo/commits/abc1234");
758 }
759
760 #[test]
761 fn commit_url_no_origin_defaults_to_commit() {
762 let url = commit_url("https://gitea.local/org/repo", None, "abc1234");
763 assert_eq!(url, "https://gitea.local/org/repo/commit/abc1234");
764 }
765
766 #[test]
767 fn urlencoded_special_chars() {
768 assert_eq!(urlencoded("feature/auth"), "feature/auth");
769 assert_eq!(urlencoded("my branch"), "my%20branch");
770 assert_eq!(urlencoded("fix#123"), "fix%23123");
771 }
772
773 #[test]
774 fn parse_numstat_line_normal() {
775 let result = parse_numstat_line("3\t1\tsrc/foo.rs");
776 assert_eq!(result, Some((3, 1, "src/foo.rs".to_string())));
777 }
778
779 #[test]
780 fn parse_numstat_line_binary() {
781 let result = parse_numstat_line("-\t-\timage.png");
782 assert_eq!(result, Some((0, 0, "image.png".to_string())));
783 }
784
785 #[test]
786 fn parse_numstat_line_invalid() {
787 assert!(parse_numstat_line("not a numstat line").is_none());
788 assert!(parse_numstat_line("").is_none());
789 }
790
791 #[test]
792 fn parse_log_output_with_stat() {
793 let now = Local::now();
794 let ts = now.to_rfc3339();
795 let input = format!(
796 "abc1234\x00feat: add feature\x00{ts}\n\
797 3\t1\tsrc/main.rs\n\
798 10\t0\tsrc/lib.rs\n\
799 \n\
800 def5678\x00fix: bug\x00{ts}\n\
801 2\t5\tsrc/main.rs\n"
802 );
803 let (commits, files) = parse_log_output(&input, now, true);
804 assert_eq!(commits.len(), 2);
805
806 let s0 = commits[0]
807 .diff_stat
808 .as_ref()
809 .expect("commit 0 should have diff_stat");
810 assert_eq!(s0.insertions, 13);
811 assert_eq!(s0.deletions, 1);
812 assert_eq!(s0.files_changed, 2);
813
814 let s1 = commits[1]
815 .diff_stat
816 .as_ref()
817 .expect("commit 1 should have diff_stat");
818 assert_eq!(s1.insertions, 2);
819 assert_eq!(s1.deletions, 5);
820 assert_eq!(s1.files_changed, 1);
821
822 assert_eq!(files.len(), 2);
824 assert!(files.contains("src/main.rs"));
825 assert!(files.contains("src/lib.rs"));
826 }
827
828 #[test]
829 fn parse_log_output_without_stat() {
830 let now = Local::now();
831 let ts = now.to_rfc3339();
832 let input = format!("abc1234\x00feat: add feature\x00{ts}\n");
833 let (commits, files) = parse_log_output(&input, now, false);
834 assert_eq!(commits.len(), 1);
835 assert!(commits[0].diff_stat.is_none());
836 assert!(files.is_empty());
837 }
838}