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    match trimmed {
214        "feat" | "fix" | "refactor" | "docs" | "test" | "chore" | "perf" | "ci" | "build"
215        | "style" => Some(trimmed.to_string()),
216        _ => None,
217    }
218}
219
220fn format_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
221    let duration = now.signed_duration_since(then);
222    let mins = duration.num_minutes();
223
224    if mins < 1 {
225        "just now".to_string()
226    } else if mins < 60 {
227        format!("{mins}m ago")
228    } else if mins < 1440 {
229        format!("{}h ago", duration.num_hours())
230    } else {
231        format!("{}d ago", duration.num_days())
232    }
233}
234
235fn get_remote_url(repo: &Path) -> Option<String> {
236    let output = Command::new("git")
237        .args(["-C", &repo.to_string_lossy(), "remote", "get-url", "origin"])
238        .output()
239        .ok()?;
240
241    if !output.status.success() {
242        return None;
243    }
244
245    let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
246    if url.is_empty() {
247        None
248    } else {
249        Some(url)
250    }
251}
252
253fn extract_hostname(url: &str) -> Option<&str> {
254    // SSH: git@github.com:user/repo.git
255    if let Some(rest) = url.strip_prefix("git@") {
256        return rest.split(':').next();
257    }
258    // SSH variant: ssh://git@host/...
259    if let Some(rest) = url.strip_prefix("ssh://") {
260        let after_at = rest.split('@').next_back()?;
261        return after_at
262            .split('/')
263            .next()
264            .map(|h| h.split(':').next().unwrap_or(h));
265    }
266    // HTTPS: https://github.com/user/repo.git
267    if url.starts_with("https://") || url.starts_with("http://") {
268        let without_scheme = url.split("://").nth(1)?;
269        let after_auth = without_scheme.split('@').next_back()?;
270        return after_auth.split('/').next();
271    }
272    None
273}
274
275fn classify_host(hostname: &str) -> RepoOrigin {
276    let lower = hostname.to_lowercase();
277    if lower == "github.com" {
278        RepoOrigin::GitHub
279    } else if lower == "gitlab.com" {
280        RepoOrigin::GitLab
281    } else if lower == "bitbucket.org" {
282        RepoOrigin::Bitbucket
283    } else if lower.contains("gitlab") {
284        RepoOrigin::GitLabSelfHosted
285    } else {
286        RepoOrigin::Custom(hostname.to_string())
287    }
288}
289
290/// Convert a git remote URL (SSH or HTTPS) into a browser-friendly HTTPS URL.
291pub fn remote_to_browser_url(raw: &str) -> Option<String> {
292    let mut url = raw.trim().to_string();
293
294    // SSH: git@github.com:user/repo.git → https://github.com/user/repo
295    if url.starts_with("git@") {
296        url = url.replacen("git@", "https://", 1);
297        if let Some(pos) = url.find(':') {
298            // Only replace the first colon after the host (not in https://)
299            let after_scheme = &url["https://".len()..];
300            if let Some(colon) = after_scheme.find(':') {
301                let abs = "https://".len() + colon;
302                url.replace_range(abs..abs + 1, "/");
303            } else {
304                url.replace_range(pos..pos + 1, "/");
305            }
306        }
307    }
308
309    // ssh://git@host/... → https://host/...
310    if url.starts_with("ssh://") {
311        url = url.replacen("ssh://", "https://", 1);
312        if let Some(at) = url.find('@') {
313            url = format!("https://{}", &url[at + 1..]);
314        }
315    }
316
317    if url.ends_with(".git") {
318        url.truncate(url.len() - 4);
319    }
320
321    if url.starts_with("https://") || url.starts_with("http://") {
322        Some(url)
323    } else {
324        None
325    }
326}
327
328pub fn browser_url(repo: &Path) -> Option<String> {
329    let raw = get_remote_url(repo)?;
330    remote_to_browser_url(&raw)
331}
332
333pub fn detect_origin(repo: &Path) -> Option<RepoOrigin> {
334    let url = get_remote_url(repo)?;
335    let hostname = extract_hostname(&url)?;
336    Some(classify_host(hostname))
337}
338
339/// Build a browser URL for a branch, respecting platform-specific URL patterns.
340pub fn branch_url(remote_url: &str, origin: Option<&RepoOrigin>, branch: &str) -> String {
341    let encoded = urlencoded(branch);
342    match origin {
343        Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
344            format!("{remote_url}/-/tree/{encoded}")
345        }
346        Some(RepoOrigin::Bitbucket) => {
347            format!("{remote_url}/branch/{encoded}")
348        }
349        _ => {
350            // GitHub, Custom, and unknown all use /tree/
351            format!("{remote_url}/tree/{encoded}")
352        }
353    }
354}
355
356/// Build a browser URL for a commit, respecting platform-specific URL patterns.
357pub fn commit_url(remote_url: &str, origin: Option<&RepoOrigin>, hash: &str) -> String {
358    match origin {
359        Some(RepoOrigin::GitLab | RepoOrigin::GitLabSelfHosted) => {
360            format!("{remote_url}/-/commit/{hash}")
361        }
362        Some(RepoOrigin::Bitbucket) => {
363            format!("{remote_url}/commits/{hash}")
364        }
365        _ => {
366            format!("{remote_url}/commit/{hash}")
367        }
368    }
369}
370
371/// Minimal percent-encoding for branch names in URLs (spaces, special chars).
372fn urlencoded(s: &str) -> String {
373    s.replace('%', "%25")
374        .replace(' ', "%20")
375        .replace('#', "%23")
376        .replace('?', "%3F")
377}
378
379pub fn collect_project_log(
380    repo: &Path,
381    range: &TimeRange,
382    author: Option<&str>,
383    with_stat: bool,
384) -> Option<ProjectLog> {
385    let project_name = repo.file_name()?.to_string_lossy().to_string();
386    let branches = list_branches(repo).ok()?;
387    let origin = detect_origin(repo);
388    let remote = browser_url(repo);
389
390    let mut project_files: HashSet<String> = HashSet::new();
391    let mut project_insertions: u32 = 0;
392    let mut project_deletions: u32 = 0;
393
394    let mut branch_logs: Vec<BranchLog> = branches
395        .into_iter()
396        .filter_map(|branch_name| {
397            let (mut commits, branch_stat, branch_file_set) =
398                log_branch(repo, &branch_name, range, author, with_stat).ok()?;
399            if commits.is_empty() {
400                None
401            } else {
402                if let Some(base) = &remote {
403                    for c in &mut commits {
404                        c.url = Some(commit_url(base, origin.as_ref(), &c.hash));
405                    }
406                }
407                let b_url = remote
408                    .as_deref()
409                    .map(|base| branch_url(base, origin.as_ref(), &branch_name));
410
411                if let Some(stat) = &branch_stat {
412                    project_insertions += stat.insertions;
413                    project_deletions += stat.deletions;
414                    project_files.extend(branch_file_set);
415                }
416
417                Some(BranchLog {
418                    name: branch_name,
419                    url: b_url,
420                    commits,
421                    diff_stat: branch_stat,
422                })
423            }
424        })
425        .collect();
426
427    if branch_logs.is_empty() {
428        return None;
429    }
430
431    branch_logs.sort_by(|a, b| {
432        let a_primary = is_primary_branch(&a.name);
433        let b_primary = is_primary_branch(&b.name);
434        b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
435    });
436
437    let project_stat = if with_stat {
438        Some(DiffStat {
439            files_changed: project_files.len() as u32,
440            insertions: project_insertions,
441            deletions: project_deletions,
442        })
443    } else {
444        None
445    };
446
447    Some(ProjectLog {
448        project: project_name,
449        path: repo.to_string_lossy().to_string(),
450        origin,
451        remote_url: remote,
452        branches: branch_logs,
453        diff_stat: project_stat,
454    })
455}
456
457fn is_primary_branch(name: &str) -> bool {
458    matches!(name, "main" | "master")
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use chrono::Duration;
465
466    #[test]
467    fn format_relative_just_now() {
468        let now = Local::now();
469        assert_eq!(format_relative(now, now), "just now");
470    }
471
472    #[test]
473    fn format_relative_minutes() {
474        let now = Local::now();
475        let then = now - Duration::minutes(5);
476        assert_eq!(format_relative(now, then), "5m ago");
477    }
478
479    #[test]
480    fn format_relative_hours() {
481        let now = Local::now();
482        let then = now - Duration::hours(3);
483        assert_eq!(format_relative(now, then), "3h ago");
484    }
485
486    #[test]
487    fn format_relative_days() {
488        let now = Local::now();
489        let then = now - Duration::days(2);
490        assert_eq!(format_relative(now, then), "2d ago");
491    }
492
493    #[test]
494    fn detect_feat() {
495        assert_eq!(
496            detect_commit_type("feat: add spinner"),
497            Some("feat".to_string())
498        );
499    }
500
501    #[test]
502    fn detect_fix() {
503        assert_eq!(
504            detect_commit_type("fix: off-by-one error"),
505            Some("fix".to_string())
506        );
507    }
508
509    #[test]
510    fn detect_scoped() {
511        assert_eq!(
512            detect_commit_type("feat(auth): add OAuth"),
513            Some("feat".to_string())
514        );
515    }
516
517    #[test]
518    fn detect_none_for_regular_message() {
519        assert_eq!(detect_commit_type("update README"), None);
520    }
521
522    #[test]
523    fn detect_none_for_empty() {
524        assert_eq!(detect_commit_type(""), None);
525    }
526
527    #[test]
528    fn parse_commit_line_valid() {
529        let now = Local::now();
530        let time_str = now.to_rfc3339();
531        let line = format!("abc1234\x00feat: add feature\x00{time_str}");
532        let commit = parse_commit_line(&line, now);
533        assert!(commit.is_some());
534        let c = commit.unwrap_or_else(|| panic!("Expected Some"));
535        assert_eq!(c.hash, "abc1234");
536        assert_eq!(c.message, "feat: add feature");
537        assert_eq!(c.commit_type, Some("feat".to_string()));
538    }
539
540    #[test]
541    fn parse_commit_line_invalid() {
542        let now = Local::now();
543        assert!(parse_commit_line("incomplete line", now).is_none());
544    }
545
546    #[test]
547    fn primary_branch_detected() {
548        assert!(is_primary_branch("main"));
549        assert!(is_primary_branch("master"));
550        assert!(!is_primary_branch("feature/auth"));
551        assert!(!is_primary_branch("develop"));
552    }
553
554    #[test]
555    fn extract_hostname_https() {
556        assert_eq!(
557            extract_hostname("https://github.com/user/repo.git"),
558            Some("github.com")
559        );
560        assert_eq!(
561            extract_hostname("https://gitlab.com/group/project"),
562            Some("gitlab.com")
563        );
564    }
565
566    #[test]
567    fn extract_hostname_http() {
568        assert_eq!(
569            extract_hostname("http://gitea.local/org/repo"),
570            Some("gitea.local")
571        );
572    }
573
574    #[test]
575    fn extract_hostname_ssh_git_at() {
576        assert_eq!(
577            extract_hostname("git@github.com:user/repo.git"),
578            Some("github.com")
579        );
580        assert_eq!(
581            extract_hostname("git@gitlab.company.de:group/project.git"),
582            Some("gitlab.company.de")
583        );
584    }
585
586    #[test]
587    fn extract_hostname_ssh_scheme() {
588        assert_eq!(
589            extract_hostname("ssh://git@bitbucket.org/team/repo.git"),
590            Some("bitbucket.org")
591        );
592        assert_eq!(
593            extract_hostname("ssh://git@gitlab.internal:2222/group/repo.git"),
594            Some("gitlab.internal")
595        );
596    }
597
598    #[test]
599    fn extract_hostname_https_with_auth() {
600        assert_eq!(
601            extract_hostname("https://token@github.com/user/repo.git"),
602            Some("github.com")
603        );
604    }
605
606    #[test]
607    fn extract_hostname_empty() {
608        assert_eq!(extract_hostname(""), None);
609        assert_eq!(extract_hostname("not-a-url"), None);
610    }
611
612    #[test]
613    fn classify_github() {
614        assert_eq!(classify_host("github.com"), RepoOrigin::GitHub);
615        assert_eq!(classify_host("GitHub.com"), RepoOrigin::GitHub);
616    }
617
618    #[test]
619    fn classify_gitlab() {
620        assert_eq!(classify_host("gitlab.com"), RepoOrigin::GitLab);
621    }
622
623    #[test]
624    fn classify_bitbucket() {
625        assert_eq!(classify_host("bitbucket.org"), RepoOrigin::Bitbucket);
626    }
627
628    #[test]
629    fn classify_gitlab_self_hosted() {
630        assert_eq!(
631            classify_host("gitlab.company.de"),
632            RepoOrigin::GitLabSelfHosted
633        );
634        assert_eq!(
635            classify_host("gitlab.internal"),
636            RepoOrigin::GitLabSelfHosted
637        );
638    }
639
640    #[test]
641    fn classify_custom() {
642        assert_eq!(
643            classify_host("gitea.local"),
644            RepoOrigin::Custom("gitea.local".to_string())
645        );
646        assert_eq!(
647            classify_host("codeberg.org"),
648            RepoOrigin::Custom("codeberg.org".to_string())
649        );
650    }
651
652    #[test]
653    fn branch_url_github() {
654        let url = branch_url(
655            "https://github.com/user/repo",
656            Some(&RepoOrigin::GitHub),
657            "main",
658        );
659        assert_eq!(url, "https://github.com/user/repo/tree/main");
660    }
661
662    #[test]
663    fn branch_url_github_with_slash() {
664        let url = branch_url(
665            "https://github.com/user/repo",
666            Some(&RepoOrigin::GitHub),
667            "feature/auth",
668        );
669        assert_eq!(url, "https://github.com/user/repo/tree/feature/auth");
670    }
671
672    #[test]
673    fn branch_url_gitlab() {
674        let url = branch_url(
675            "https://gitlab.com/group/project",
676            Some(&RepoOrigin::GitLab),
677            "develop",
678        );
679        assert_eq!(url, "https://gitlab.com/group/project/-/tree/develop");
680    }
681
682    #[test]
683    fn branch_url_gitlab_self_hosted() {
684        let url = branch_url(
685            "https://gitlab.company.de/team/repo",
686            Some(&RepoOrigin::GitLabSelfHosted),
687            "main",
688        );
689        assert_eq!(url, "https://gitlab.company.de/team/repo/-/tree/main");
690    }
691
692    #[test]
693    fn branch_url_bitbucket() {
694        let url = branch_url(
695            "https://bitbucket.org/team/repo",
696            Some(&RepoOrigin::Bitbucket),
697            "main",
698        );
699        assert_eq!(url, "https://bitbucket.org/team/repo/branch/main");
700    }
701
702    #[test]
703    fn branch_url_no_origin_defaults_to_tree() {
704        let url = branch_url("https://gitea.local/org/repo", None, "main");
705        assert_eq!(url, "https://gitea.local/org/repo/tree/main");
706    }
707
708    #[test]
709    fn commit_url_github() {
710        let url = commit_url(
711            "https://github.com/user/repo",
712            Some(&RepoOrigin::GitHub),
713            "abc1234",
714        );
715        assert_eq!(url, "https://github.com/user/repo/commit/abc1234");
716    }
717
718    #[test]
719    fn commit_url_gitlab() {
720        let url = commit_url(
721            "https://gitlab.com/group/project",
722            Some(&RepoOrigin::GitLab),
723            "abc1234",
724        );
725        assert_eq!(url, "https://gitlab.com/group/project/-/commit/abc1234");
726    }
727
728    #[test]
729    fn commit_url_bitbucket() {
730        let url = commit_url(
731            "https://bitbucket.org/team/repo",
732            Some(&RepoOrigin::Bitbucket),
733            "abc1234",
734        );
735        assert_eq!(url, "https://bitbucket.org/team/repo/commits/abc1234");
736    }
737
738    #[test]
739    fn commit_url_no_origin_defaults_to_commit() {
740        let url = commit_url("https://gitea.local/org/repo", None, "abc1234");
741        assert_eq!(url, "https://gitea.local/org/repo/commit/abc1234");
742    }
743
744    #[test]
745    fn urlencoded_special_chars() {
746        assert_eq!(urlencoded("feature/auth"), "feature/auth");
747        assert_eq!(urlencoded("my branch"), "my%20branch");
748        assert_eq!(urlencoded("fix#123"), "fix%23123");
749    }
750
751    #[test]
752    fn parse_numstat_line_normal() {
753        let result = parse_numstat_line("3\t1\tsrc/foo.rs");
754        assert_eq!(result, Some((3, 1, "src/foo.rs".to_string())));
755    }
756
757    #[test]
758    fn parse_numstat_line_binary() {
759        let result = parse_numstat_line("-\t-\timage.png");
760        assert_eq!(result, Some((0, 0, "image.png".to_string())));
761    }
762
763    #[test]
764    fn parse_numstat_line_invalid() {
765        assert!(parse_numstat_line("not a numstat line").is_none());
766        assert!(parse_numstat_line("").is_none());
767    }
768
769    #[test]
770    fn parse_log_output_with_stat() {
771        let now = Local::now();
772        let ts = now.to_rfc3339();
773        let input = format!(
774            "abc1234\x00feat: add feature\x00{ts}\n\
775             3\t1\tsrc/main.rs\n\
776             10\t0\tsrc/lib.rs\n\
777             \n\
778             def5678\x00fix: bug\x00{ts}\n\
779             2\t5\tsrc/main.rs\n"
780        );
781        let (commits, files) = parse_log_output(&input, now, true);
782        assert_eq!(commits.len(), 2);
783
784        let s0 = commits[0]
785            .diff_stat
786            .as_ref()
787            .expect("commit 0 should have diff_stat");
788        assert_eq!(s0.insertions, 13);
789        assert_eq!(s0.deletions, 1);
790        assert_eq!(s0.files_changed, 2);
791
792        let s1 = commits[1]
793            .diff_stat
794            .as_ref()
795            .expect("commit 1 should have diff_stat");
796        assert_eq!(s1.insertions, 2);
797        assert_eq!(s1.deletions, 5);
798        assert_eq!(s1.files_changed, 1);
799
800        // Branch-level dedup: src/main.rs + src/lib.rs = 2 unique files
801        assert_eq!(files.len(), 2);
802        assert!(files.contains("src/main.rs"));
803        assert!(files.contains("src/lib.rs"));
804    }
805
806    #[test]
807    fn parse_log_output_without_stat() {
808        let now = Local::now();
809        let ts = now.to_rfc3339();
810        let input = format!("abc1234\x00feat: add feature\x00{ts}\n");
811        let (commits, files) = parse_log_output(&input, now, false);
812        assert_eq!(commits.len(), 1);
813        assert!(commits[0].diff_stat.is_none());
814        assert!(files.is_empty());
815    }
816}