Skip to main content

devcap_core/
git.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Local};
6
7use crate::model::{BranchLog, Commit, ProjectLog};
8use crate::period::TimeRange;
9
10pub fn default_author() -> Option<String> {
11    Command::new("git")
12        .args(["config", "--global", "user.name"])
13        .output()
14        .ok()
15        .and_then(|out| {
16            if out.status.success() {
17                String::from_utf8(out.stdout)
18                    .ok()
19                    .map(|s| s.trim().to_string())
20                    .filter(|s| !s.is_empty())
21            } else {
22                None
23            }
24        })
25}
26
27fn list_branches(repo: &Path) -> Result<Vec<String>> {
28    let output = Command::new("git")
29        .args([
30            "-C",
31            &repo.to_string_lossy(),
32            "branch",
33            "--format=%(refname:short)",
34        ])
35        .output()
36        .context("Failed to run git branch")?;
37
38    if !output.status.success() {
39        return Ok(vec![]);
40    }
41
42    Ok(String::from_utf8_lossy(&output.stdout)
43        .lines()
44        .map(|l| l.trim().to_string())
45        .filter(|l| !l.is_empty())
46        .collect())
47}
48
49fn log_branch(
50    repo: &Path,
51    branch: &str,
52    range: &TimeRange,
53    author: Option<&str>,
54) -> Result<Vec<Commit>> {
55    let since_str = range.since.to_rfc3339();
56
57    let mut args = vec![
58        "-C".to_string(),
59        repo.to_string_lossy().to_string(),
60        "log".to_string(),
61        branch.to_string(),
62        format!("--after={since_str}"),
63        "--format=%h%x00%s%x00%aI".to_string(),
64        "--no-merges".to_string(),
65    ];
66
67    if let Some(until) = &range.until {
68        args.push(format!("--before={}", until.to_rfc3339()));
69    }
70
71    if let Some(author) = author {
72        args.push(format!("--author={author}"));
73    }
74
75    let output = Command::new("git")
76        .args(&args)
77        .output()
78        .context("Failed to run git log")?;
79
80    if !output.status.success() {
81        return Ok(vec![]);
82    }
83
84    let now = Local::now();
85
86    Ok(String::from_utf8_lossy(&output.stdout)
87        .lines()
88        .filter(|l| !l.is_empty())
89        .filter_map(|line| parse_commit_line(line, now))
90        .collect())
91}
92
93fn parse_commit_line(line: &str, now: DateTime<Local>) -> Option<Commit> {
94    let parts: Vec<&str> = line.splitn(3, '\0').collect();
95    if parts.len() != 3 {
96        return None;
97    }
98
99    let time = DateTime::parse_from_rfc3339(parts[2])
100        .ok()?
101        .with_timezone(&Local);
102
103    Some(Commit {
104        hash: parts[0].to_string(),
105        message: parts[1].to_string(),
106        commit_type: detect_commit_type(parts[1]),
107        relative_time: format_relative(now, time),
108        time,
109    })
110}
111
112fn detect_commit_type(message: &str) -> Option<String> {
113    let prefix = message.split([':', '(']).next()?;
114    let trimmed = prefix.trim();
115    match trimmed {
116        "feat" | "fix" | "refactor" | "docs" | "test" | "chore" | "perf" | "ci" | "build"
117        | "style" => Some(trimmed.to_string()),
118        _ => None,
119    }
120}
121
122fn format_relative(now: DateTime<Local>, then: DateTime<Local>) -> String {
123    let duration = now.signed_duration_since(then);
124    let mins = duration.num_minutes();
125
126    if mins < 1 {
127        "just now".to_string()
128    } else if mins < 60 {
129        format!("{mins}m ago")
130    } else if mins < 1440 {
131        format!("{}h ago", duration.num_hours())
132    } else {
133        format!("{}d ago", duration.num_days())
134    }
135}
136
137pub fn collect_project_log(
138    repo: &Path,
139    range: &TimeRange,
140    author: Option<&str>,
141) -> Option<ProjectLog> {
142    let project_name = repo.file_name()?.to_string_lossy().to_string();
143    let branches = list_branches(repo).ok()?;
144
145    let mut branch_logs: Vec<BranchLog> = branches
146        .into_iter()
147        .filter_map(|branch_name| {
148            let commits = log_branch(repo, &branch_name, range, author).ok()?;
149            if commits.is_empty() {
150                None
151            } else {
152                Some(BranchLog {
153                    name: branch_name,
154                    commits,
155                })
156            }
157        })
158        .collect();
159
160    if branch_logs.is_empty() {
161        return None;
162    }
163
164    branch_logs.sort_by(|a, b| {
165        let a_primary = is_primary_branch(&a.name);
166        let b_primary = is_primary_branch(&b.name);
167        b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
168    });
169
170    Some(ProjectLog {
171        project: project_name,
172        path: repo.to_string_lossy().to_string(),
173        branches: branch_logs,
174    })
175}
176
177fn is_primary_branch(name: &str) -> bool {
178    matches!(name, "main" | "master")
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use chrono::Duration;
185
186    #[test]
187    fn format_relative_just_now() {
188        let now = Local::now();
189        assert_eq!(format_relative(now, now), "just now");
190    }
191
192    #[test]
193    fn format_relative_minutes() {
194        let now = Local::now();
195        let then = now - Duration::minutes(5);
196        assert_eq!(format_relative(now, then), "5m ago");
197    }
198
199    #[test]
200    fn format_relative_hours() {
201        let now = Local::now();
202        let then = now - Duration::hours(3);
203        assert_eq!(format_relative(now, then), "3h ago");
204    }
205
206    #[test]
207    fn format_relative_days() {
208        let now = Local::now();
209        let then = now - Duration::days(2);
210        assert_eq!(format_relative(now, then), "2d ago");
211    }
212
213    #[test]
214    fn detect_feat() {
215        assert_eq!(
216            detect_commit_type("feat: add spinner"),
217            Some("feat".to_string())
218        );
219    }
220
221    #[test]
222    fn detect_fix() {
223        assert_eq!(
224            detect_commit_type("fix: off-by-one error"),
225            Some("fix".to_string())
226        );
227    }
228
229    #[test]
230    fn detect_scoped() {
231        assert_eq!(
232            detect_commit_type("feat(auth): add OAuth"),
233            Some("feat".to_string())
234        );
235    }
236
237    #[test]
238    fn detect_none_for_regular_message() {
239        assert_eq!(detect_commit_type("update README"), None);
240    }
241
242    #[test]
243    fn detect_none_for_empty() {
244        assert_eq!(detect_commit_type(""), None);
245    }
246
247    #[test]
248    fn parse_commit_line_valid() {
249        let now = Local::now();
250        let time_str = now.to_rfc3339();
251        let line = format!("abc1234\x00feat: add feature\x00{time_str}");
252        let commit = parse_commit_line(&line, now);
253        assert!(commit.is_some());
254        let c = commit.unwrap_or_else(|| panic!("Expected Some"));
255        assert_eq!(c.hash, "abc1234");
256        assert_eq!(c.message, "feat: add feature");
257        assert_eq!(c.commit_type, Some("feat".to_string()));
258    }
259
260    #[test]
261    fn parse_commit_line_invalid() {
262        let now = Local::now();
263        assert!(parse_commit_line("incomplete line", now).is_none());
264    }
265
266    #[test]
267    fn primary_branch_detected() {
268        assert!(is_primary_branch("main"));
269        assert!(is_primary_branch("master"));
270        assert!(!is_primary_branch("feature/auth"));
271        assert!(!is_primary_branch("develop"));
272    }
273}