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
25fn validate_clone_url(url: &str) -> Result<()> {
28 let lower = url.to_lowercase();
29 let allowed = ["https://", "http://", "git://", "ssh://", "git@"];
30 if !allowed.iter().any(|p| lower.starts_with(p)) {
31 bail!(
32 "git URL rejected: only https://, http://, git://, ssh://, and git@ URLs are \
33 permitted (got {url:?})"
34 );
35 }
36 Ok(())
37}
38
39pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
45 validate_clone_url(url)?;
46 if dest.join(".git").exists() {
47 run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
48 } else {
49 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
50 let dest_str = dest.to_str().unwrap_or(".");
51 let parent = dest.parent().unwrap_or(dest);
52 run_git(
53 parent,
54 &["clone", "--no-single-branch", "--depth=50", url, dest_str],
55 )?;
56 }
57 Ok(())
58}
59
60pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
65 run_git(repo, &["rev-parse", ref_name])
66}
67
68pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
75 let wt = worktree_path.to_str().unwrap_or(".");
76 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
77 Ok(())
78}
79
80pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
85 let wt = worktree_path.to_str().unwrap_or(".");
86 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
87 Ok(())
88}
89
90pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
97 Ok(RepoRefs {
98 branches: list_branches(repo)?,
99 tags: list_tags(repo)?,
100 recent_commits: list_commits(repo, "HEAD", 40)?,
101 })
102}
103
104fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
105 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
106 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
110 let refs = out
111 .lines()
112 .filter(|l| !l.trim().is_empty())
113 .map(|l| parse_ref_line(l, GitRefKind::Branch))
114 .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
116 .map(|mut r| {
117 if let Some(slash) = r.name.find('/') {
119 r.name = r.name[slash + 1..].to_owned();
120 }
121 r
122 })
123 .collect::<Vec<_>>();
124 Ok(refs)
125}
126
127fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
128 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
129 let out = run_git(
130 repo,
131 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
132 )?;
133 Ok(out
134 .lines()
135 .filter(|l| !l.trim().is_empty())
136 .map(|l| parse_ref_line(l, GitRefKind::Tag))
137 .collect())
138}
139
140fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
141 let parts: Vec<&str> = line.splitn(4, '|').collect();
142 let name = parts.first().copied().unwrap_or("").to_owned();
143 let sha = parts.get(1).copied().unwrap_or("").to_owned();
144 let date = parts.get(2).copied().and_then(parse_git_date);
145 let message = parts.get(3).map(|s| (*s).to_owned());
146 GitRef {
147 kind,
148 name,
149 sha,
150 date,
151 message,
152 }
153}
154
155pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
162 let fmt = "%H|%h|%an|%aI|%s";
163 let n = format!("-{limit}");
164 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
165 Ok(out
166 .lines()
167 .filter(|l| !l.trim().is_empty())
168 .map(parse_commit_line)
169 .collect())
170}
171
172fn parse_commit_line(line: &str) -> GitCommit {
173 let p: Vec<&str> = line.splitn(5, '|').collect();
174 let sha = p.first().copied().unwrap_or("").to_owned();
175 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
176 let author = p.get(2).copied().unwrap_or("").to_owned();
177 let date = p
178 .get(3)
179 .copied()
180 .and_then(parse_git_date)
181 .unwrap_or_default();
182 let subject = p.get(4).copied().unwrap_or("").to_owned();
183 GitCommit {
184 sha,
185 short_sha,
186 author,
187 date,
188 subject,
189 }
190}
191
192fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
193 chrono::DateTime::parse_from_rfc3339(s)
194 .ok()
195 .map(|d| d.with_timezone(&chrono::Utc))
196}