Skip to main content

devcap_core/
git.rs

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            // Finalize previous commit's stat
134            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    // Finalize last commit
164    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    // SSH: git@github.com:user/repo.git
259    if let Some(rest) = url.strip_prefix("git@") {
260        return rest.split(':').next();
261    }
262    // SSH variant: ssh://git@host/...
263    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    // HTTPS: https://github.com/user/repo.git
271    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
294/// Convert a git remote URL (SSH or HTTPS) into a browser-friendly HTTPS URL.
295pub fn remote_to_browser_url(raw: &str) -> Option<String> {
296    let mut url = raw.trim().to_string();
297
298    // SSH: git@github.com:user/repo.git → https://github.com/user/repo
299    if url.starts_with("git@") {
300        url = url.replacen("git@", "https://", 1);
301        if let Some(pos) = url.find(':') {
302            // Only replace the first colon after the host (not in https://)
303            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    // ssh://git@host/... → https://host/...
314    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
343/// Build a browser URL for a branch, respecting platform-specific URL patterns.
344pub 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            // GitHub, Custom, and unknown all use /tree/
355            format!("{remote_url}/tree/{encoded}")
356        }
357    }
358}
359
360/// Build a browser URL for a commit, respecting platform-specific URL patterns.
361pub 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
375/// Minimal percent-encoding for branch names in URLs (spaces, special chars).
376fn 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        // Branch-level dedup: src/main.rs + src/lib.rs = 2 unique files
823        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}