1use std::path::Path;
5
6use anyhow::{bail, Context, Result};
7
8use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
9
10fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
13 let out = std::process::Command::new("git")
14 .args(args)
15 .current_dir(repo)
16 .output()
17 .context("failed to spawn git process")?;
18 if !out.status.success() {
19 let stderr = String::from_utf8_lossy(&out.stderr);
20 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
21 }
22 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
23}
24
25pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
29 if dest.join(".git").exists() {
30 run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
31 } else {
32 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
33 let dest_str = dest.to_str().unwrap_or(".");
34 let parent = dest.parent().unwrap_or(dest);
35 run_git(parent, &["clone", "--no-single-branch", url, dest_str])?;
36 }
37 Ok(())
38}
39
40pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
42 run_git(repo, &["rev-parse", ref_name])
43}
44
45pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
49 let wt = worktree_path.to_str().unwrap_or(".");
50 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
51 Ok(())
52}
53
54pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
56 let wt = worktree_path.to_str().unwrap_or(".");
57 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
58 Ok(())
59}
60
61pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
65 Ok(RepoRefs {
66 branches: list_branches(repo)?,
67 tags: list_tags(repo)?,
68 recent_commits: list_commits(repo, "HEAD", 40)?,
69 })
70}
71
72fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
73 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)";
74 let out = run_git(repo, &["branch", "-a", &format!("--format={fmt}")])?;
75 out.lines()
76 .filter(|l| !l.trim().is_empty())
77 .map(|l| parse_ref_line(l, GitRefKind::Branch))
78 .collect()
79}
80
81fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
82 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)";
83 let out = run_git(
84 repo,
85 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
86 )?;
87 out.lines()
88 .filter(|l| !l.trim().is_empty())
89 .map(|l| parse_ref_line(l, GitRefKind::Tag))
90 .collect()
91}
92
93fn parse_ref_line(line: &str, kind: GitRefKind) -> Result<GitRef> {
94 let parts: Vec<&str> = line.splitn(4, '|').collect();
95 let name = parts.first().copied().unwrap_or("").to_owned();
96 let sha = parts.get(1).copied().unwrap_or("").to_owned();
97 let date = parts.get(2).copied().and_then(parse_git_date);
98 let message = parts.get(3).map(|s| (*s).to_owned());
99 Ok(GitRef {
100 kind,
101 name,
102 sha,
103 date,
104 message,
105 })
106}
107
108pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
112 let fmt = "%H|%h|%an|%aI|%s";
113 let n = format!("-{limit}");
114 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
115 out.lines()
116 .filter(|l| !l.trim().is_empty())
117 .map(parse_commit_line)
118 .collect()
119}
120
121fn parse_commit_line(line: &str) -> Result<GitCommit> {
122 let p: Vec<&str> = line.splitn(5, '|').collect();
123 let sha = p.first().copied().unwrap_or("").to_owned();
124 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
125 let author = p.get(2).copied().unwrap_or("").to_owned();
126 let date = p
127 .get(3)
128 .copied()
129 .and_then(parse_git_date)
130 .unwrap_or_default();
131 let subject = p.get(4).copied().unwrap_or("").to_owned();
132 Ok(GitCommit {
133 sha,
134 short_sha,
135 author,
136 date,
137 subject,
138 })
139}
140
141fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
142 chrono::DateTime::parse_from_rfc3339(s)
143 .ok()
144 .map(|d| d.with_timezone(&chrono::Utc))
145}