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, RepoOrigin};
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
137fn get_remote_url(repo: &Path) -> Option<String> {
138    let output = Command::new("git")
139        .args(["-C", &repo.to_string_lossy(), "remote", "get-url", "origin"])
140        .output()
141        .ok()?;
142
143    if !output.status.success() {
144        return None;
145    }
146
147    let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
148    if url.is_empty() {
149        None
150    } else {
151        Some(url)
152    }
153}
154
155fn extract_hostname(url: &str) -> Option<&str> {
156    // SSH: git@github.com:user/repo.git
157    if let Some(rest) = url.strip_prefix("git@") {
158        return rest.split(':').next();
159    }
160    // SSH variant: ssh://git@host/...
161    if let Some(rest) = url.strip_prefix("ssh://") {
162        let after_at = rest.split('@').next_back()?;
163        return after_at
164            .split('/')
165            .next()
166            .map(|h| h.split(':').next().unwrap_or(h));
167    }
168    // HTTPS: https://github.com/user/repo.git
169    if url.starts_with("https://") || url.starts_with("http://") {
170        let without_scheme = url.split("://").nth(1)?;
171        let after_auth = without_scheme.split('@').next_back()?;
172        return after_auth.split('/').next();
173    }
174    None
175}
176
177fn classify_host(hostname: &str) -> RepoOrigin {
178    let lower = hostname.to_lowercase();
179    if lower == "github.com" {
180        RepoOrigin::GitHub
181    } else if lower == "gitlab.com" {
182        RepoOrigin::GitLab
183    } else if lower == "bitbucket.org" {
184        RepoOrigin::Bitbucket
185    } else if lower.contains("gitlab") {
186        RepoOrigin::GitLabSelfHosted
187    } else {
188        RepoOrigin::Custom(hostname.to_string())
189    }
190}
191
192/// Convert a git remote URL (SSH or HTTPS) into a browser-friendly HTTPS URL.
193pub fn remote_to_browser_url(raw: &str) -> Option<String> {
194    let mut url = raw.trim().to_string();
195
196    // SSH: git@github.com:user/repo.git → https://github.com/user/repo
197    if url.starts_with("git@") {
198        url = url.replacen("git@", "https://", 1);
199        if let Some(pos) = url.find(':') {
200            // Only replace the first colon after the host (not in https://)
201            let after_scheme = &url["https://".len()..];
202            if let Some(colon) = after_scheme.find(':') {
203                let abs = "https://".len() + colon;
204                url.replace_range(abs..abs + 1, "/");
205            } else {
206                url.replace_range(pos..pos + 1, "/");
207            }
208        }
209    }
210
211    // ssh://git@host/... → https://host/...
212    if url.starts_with("ssh://") {
213        url = url.replacen("ssh://", "https://", 1);
214        if let Some(at) = url.find('@') {
215            url = format!("https://{}", &url[at + 1..]);
216        }
217    }
218
219    if url.ends_with(".git") {
220        url.truncate(url.len() - 4);
221    }
222
223    if url.starts_with("https://") || url.starts_with("http://") {
224        Some(url)
225    } else {
226        None
227    }
228}
229
230pub fn browser_url(repo: &Path) -> Option<String> {
231    let raw = get_remote_url(repo)?;
232    remote_to_browser_url(&raw)
233}
234
235pub fn detect_origin(repo: &Path) -> Option<RepoOrigin> {
236    let url = get_remote_url(repo)?;
237    let hostname = extract_hostname(&url)?;
238    Some(classify_host(hostname))
239}
240
241pub fn collect_project_log(
242    repo: &Path,
243    range: &TimeRange,
244    author: Option<&str>,
245) -> Option<ProjectLog> {
246    let project_name = repo.file_name()?.to_string_lossy().to_string();
247    let branches = list_branches(repo).ok()?;
248
249    let mut branch_logs: Vec<BranchLog> = branches
250        .into_iter()
251        .filter_map(|branch_name| {
252            let commits = log_branch(repo, &branch_name, range, author).ok()?;
253            if commits.is_empty() {
254                None
255            } else {
256                Some(BranchLog {
257                    name: branch_name,
258                    commits,
259                })
260            }
261        })
262        .collect();
263
264    if branch_logs.is_empty() {
265        return None;
266    }
267
268    branch_logs.sort_by(|a, b| {
269        let a_primary = is_primary_branch(&a.name);
270        let b_primary = is_primary_branch(&b.name);
271        b_primary.cmp(&a_primary).then_with(|| a.name.cmp(&b.name))
272    });
273
274    Some(ProjectLog {
275        project: project_name,
276        path: repo.to_string_lossy().to_string(),
277        origin: detect_origin(repo),
278        remote_url: browser_url(repo),
279        branches: branch_logs,
280    })
281}
282
283fn is_primary_branch(name: &str) -> bool {
284    matches!(name, "main" | "master")
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use chrono::Duration;
291
292    #[test]
293    fn format_relative_just_now() {
294        let now = Local::now();
295        assert_eq!(format_relative(now, now), "just now");
296    }
297
298    #[test]
299    fn format_relative_minutes() {
300        let now = Local::now();
301        let then = now - Duration::minutes(5);
302        assert_eq!(format_relative(now, then), "5m ago");
303    }
304
305    #[test]
306    fn format_relative_hours() {
307        let now = Local::now();
308        let then = now - Duration::hours(3);
309        assert_eq!(format_relative(now, then), "3h ago");
310    }
311
312    #[test]
313    fn format_relative_days() {
314        let now = Local::now();
315        let then = now - Duration::days(2);
316        assert_eq!(format_relative(now, then), "2d ago");
317    }
318
319    #[test]
320    fn detect_feat() {
321        assert_eq!(
322            detect_commit_type("feat: add spinner"),
323            Some("feat".to_string())
324        );
325    }
326
327    #[test]
328    fn detect_fix() {
329        assert_eq!(
330            detect_commit_type("fix: off-by-one error"),
331            Some("fix".to_string())
332        );
333    }
334
335    #[test]
336    fn detect_scoped() {
337        assert_eq!(
338            detect_commit_type("feat(auth): add OAuth"),
339            Some("feat".to_string())
340        );
341    }
342
343    #[test]
344    fn detect_none_for_regular_message() {
345        assert_eq!(detect_commit_type("update README"), None);
346    }
347
348    #[test]
349    fn detect_none_for_empty() {
350        assert_eq!(detect_commit_type(""), None);
351    }
352
353    #[test]
354    fn parse_commit_line_valid() {
355        let now = Local::now();
356        let time_str = now.to_rfc3339();
357        let line = format!("abc1234\x00feat: add feature\x00{time_str}");
358        let commit = parse_commit_line(&line, now);
359        assert!(commit.is_some());
360        let c = commit.unwrap_or_else(|| panic!("Expected Some"));
361        assert_eq!(c.hash, "abc1234");
362        assert_eq!(c.message, "feat: add feature");
363        assert_eq!(c.commit_type, Some("feat".to_string()));
364    }
365
366    #[test]
367    fn parse_commit_line_invalid() {
368        let now = Local::now();
369        assert!(parse_commit_line("incomplete line", now).is_none());
370    }
371
372    #[test]
373    fn primary_branch_detected() {
374        assert!(is_primary_branch("main"));
375        assert!(is_primary_branch("master"));
376        assert!(!is_primary_branch("feature/auth"));
377        assert!(!is_primary_branch("develop"));
378    }
379
380    #[test]
381    fn extract_hostname_https() {
382        assert_eq!(
383            extract_hostname("https://github.com/user/repo.git"),
384            Some("github.com")
385        );
386        assert_eq!(
387            extract_hostname("https://gitlab.com/group/project"),
388            Some("gitlab.com")
389        );
390    }
391
392    #[test]
393    fn extract_hostname_http() {
394        assert_eq!(
395            extract_hostname("http://gitea.local/org/repo"),
396            Some("gitea.local")
397        );
398    }
399
400    #[test]
401    fn extract_hostname_ssh_git_at() {
402        assert_eq!(
403            extract_hostname("git@github.com:user/repo.git"),
404            Some("github.com")
405        );
406        assert_eq!(
407            extract_hostname("git@gitlab.company.de:group/project.git"),
408            Some("gitlab.company.de")
409        );
410    }
411
412    #[test]
413    fn extract_hostname_ssh_scheme() {
414        assert_eq!(
415            extract_hostname("ssh://git@bitbucket.org/team/repo.git"),
416            Some("bitbucket.org")
417        );
418        assert_eq!(
419            extract_hostname("ssh://git@gitlab.internal:2222/group/repo.git"),
420            Some("gitlab.internal")
421        );
422    }
423
424    #[test]
425    fn extract_hostname_https_with_auth() {
426        assert_eq!(
427            extract_hostname("https://token@github.com/user/repo.git"),
428            Some("github.com")
429        );
430    }
431
432    #[test]
433    fn extract_hostname_empty() {
434        assert_eq!(extract_hostname(""), None);
435        assert_eq!(extract_hostname("not-a-url"), None);
436    }
437
438    #[test]
439    fn classify_github() {
440        assert_eq!(classify_host("github.com"), RepoOrigin::GitHub);
441        assert_eq!(classify_host("GitHub.com"), RepoOrigin::GitHub);
442    }
443
444    #[test]
445    fn classify_gitlab() {
446        assert_eq!(classify_host("gitlab.com"), RepoOrigin::GitLab);
447    }
448
449    #[test]
450    fn classify_bitbucket() {
451        assert_eq!(classify_host("bitbucket.org"), RepoOrigin::Bitbucket);
452    }
453
454    #[test]
455    fn classify_gitlab_self_hosted() {
456        assert_eq!(
457            classify_host("gitlab.company.de"),
458            RepoOrigin::GitLabSelfHosted
459        );
460        assert_eq!(
461            classify_host("gitlab.internal"),
462            RepoOrigin::GitLabSelfHosted
463        );
464    }
465
466    #[test]
467    fn classify_custom() {
468        assert_eq!(
469            classify_host("gitea.local"),
470            RepoOrigin::Custom("gitea.local".to_string())
471        );
472        assert_eq!(
473            classify_host("codeberg.org"),
474            RepoOrigin::Custom("codeberg.org".to_string())
475        );
476    }
477}