1use crate::model::{Commit, CommitFilter, Release};
2use anyhow::{bail, Context, Result};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub struct PreparedRepo {
7 pub git_dir: PathBuf,
8 pub slug: Option<String>,
10 pub url: Option<String>,
12 pub display_name: String,
13 pub branch: String,
14 pub is_remote: bool,
17}
18
19pub fn prepare(input: &str, branch: Option<&str>) -> Result<PreparedRepo> {
23 let path = Path::new(input);
24 if path.exists() {
25 return prepare_local(path, branch);
26 }
27
28 if let Some(slug) = parse_github_url(input) {
29 let url = format!("https://github.com/{slug}");
30 return prepare_remote(&format!("{url}.git"), Some(slug), branch);
31 }
32 if looks_like_slug(input) {
33 let url = format!("https://github.com/{input}.git");
34 return prepare_remote(&url, Some(input.to_string()), branch);
35 }
36 if input.contains("://") || input.starts_with("git@") {
37 return prepare_remote(input, None, branch);
38 }
39
40 bail!(
41 "'{input}' is not a local path, an owner/repo GitHub slug, or a git URL.\n\
42 Examples: contributor-graphs . | contributor-graphs nf-core/rnaseq | \
43 contributor-graphs https://github.com/MultiQC/MultiQC"
44 )
45}
46
47fn prepare_local(path: &Path, branch: Option<&str>) -> Result<PreparedRepo> {
48 let canonical = path
49 .canonicalize()
50 .with_context(|| format!("cannot resolve path {}", path.display()))?;
51 let ok = git(&canonical, &["rev-parse", "--git-dir"]).is_ok();
52 if !ok {
53 bail!("{} is not a git repository", canonical.display());
54 }
55 let remote = git(&canonical, &["remote", "get-url", "origin"]).ok();
56 let slug = remote.as_deref().and_then(parse_github_url);
57 let display_name = slug.clone().unwrap_or_else(|| {
58 canonical
59 .file_name()
60 .map(|n| n.to_string_lossy().to_string())
61 .unwrap_or_else(|| "repository".into())
62 });
63 let url = slug.as_ref().map(|s| format!("https://github.com/{s}"));
64 let branch = resolve_branch(&canonical, branch);
65 Ok(PreparedRepo {
66 git_dir: canonical,
67 slug,
68 url,
69 display_name,
70 branch,
71 is_remote: false,
72 })
73}
74
75fn clones_dir() -> PathBuf {
78 crate::cache::root()
79 .unwrap_or_else(std::env::temp_dir)
80 .join("clones")
81}
82
83fn prepare_remote(
84 clone_url: &str,
85 slug: Option<String>,
86 branch: Option<&str>,
87) -> Result<PreparedRepo> {
88 let cache_key = sanitize(slug.as_deref().unwrap_or(clone_url));
89 let cache_dir = clones_dir().join(cache_key);
90
91 if !cache_dir.join("HEAD").exists() {
94 std::fs::create_dir_all(cache_dir.parent().unwrap()).ok();
95 eprintln!(" cloning {clone_url} (commit history only)");
96 let status = Command::new("git")
97 .args(["clone", "--bare", "--filter=tree:0", "--quiet", clone_url])
98 .arg(&cache_dir)
99 .status()
100 .context("failed to run git clone")?;
101 if !status.success() {
102 bail!("git clone of {clone_url} failed");
103 }
104 let _ = git(&cache_dir, &["remote", "set-head", "origin", "--auto"]);
106 }
107
108 let display_name = slug.clone().unwrap_or_else(|| {
109 clone_url
110 .trim_end_matches(".git")
111 .rsplit('/')
112 .next()
113 .unwrap_or("repository")
114 .to_string()
115 });
116 let url = slug.as_ref().map(|s| format!("https://github.com/{s}"));
117 let branch = resolve_branch(&cache_dir, branch);
118 Ok(PreparedRepo {
119 git_dir: cache_dir,
120 slug,
121 url,
122 display_name,
123 branch,
124 is_remote: true,
125 })
126}
127
128pub fn remote_tip(repo: &PreparedRepo) -> Option<String> {
132 if !repo.is_remote {
133 return None;
134 }
135 let out = git(&repo.git_dir, &["ls-remote", "origin", &repo.branch]).ok()?;
136 out.split_whitespace().next().map(str::to_string)
137}
138
139pub fn local_tip(repo: &PreparedRepo) -> Option<String> {
141 git(&repo.git_dir, &["rev-parse", &repo.branch]).ok()
142}
143
144pub fn fetch(repo: &PreparedRepo) -> bool {
147 eprintln!(" updating cached clone {}", repo.git_dir.display());
148 git(
149 &repo.git_dir,
150 &[
151 "fetch",
152 "--quiet",
153 "--prune",
154 "origin",
155 "+refs/heads/*:refs/heads/*",
156 "+refs/tags/*:refs/tags/*",
158 ],
159 )
160 .is_ok()
161}
162
163pub fn read_tags(repo: &PreparedRepo) -> Vec<Release> {
167 let out = match git(
168 &repo.git_dir,
169 &[
170 "for-each-ref",
171 "--sort=creatordate",
172 "--format=%(refname:short)%09%(creatordate:unix)",
173 "refs/tags",
174 ],
175 ) {
176 Ok(s) => s,
177 Err(_) => return Vec::new(),
178 };
179 out.lines()
180 .filter_map(|line| {
181 let (name, ts) = line.split_once('\t')?;
182 let ts: i64 = ts.trim().parse().ok()?;
183 let name = name.trim();
184 (!name.is_empty() && ts > 0).then(|| Release {
185 name: name.to_string(),
186 ts,
187 })
188 })
189 .collect()
190}
191
192fn resolve_branch(dir: &Path, requested: Option<&str>) -> String {
193 if let Some(b) = requested {
194 return b.to_string();
195 }
196 git(dir, &["symbolic-ref", "--short", "HEAD"]).unwrap_or_else(|_| "HEAD".into())
197}
198
199pub fn read_commits(
203 repo: &PreparedRepo,
204 branch: Option<&str>,
205 filter: &CommitFilter,
206) -> Result<Vec<Commit>> {
207 let mut cmd = Command::new("git");
208 cmd.arg("-C").arg(&repo.git_dir).args([
209 "log",
210 "--use-mailmap",
211 "--pretty=format:%x1e%H%x09%at%x09%aN%x09%aE%x09\
212 %(trailers:key=Co-authored-by,valueonly,separator=%x1f)",
213 ]);
214 if filter.no_merges {
215 cmd.arg("--no-merges");
216 }
217 if let Some(s) = &filter.since {
218 cmd.arg(format!("--since={s}"));
219 }
220 if let Some(u) = &filter.until {
221 cmd.arg(format!("--until={u}"));
222 }
223 cmd.arg(branch.unwrap_or("HEAD")).arg("--");
224
225 let out = cmd.output().context("failed to run git log")?;
226 if !out.status.success() {
227 bail!(
228 "git log failed: {}",
229 String::from_utf8_lossy(&out.stderr).trim()
230 );
231 }
232 let text = String::from_utf8_lossy(&out.stdout);
233 let mut commits = Vec::new();
234 for rec in text.split('\u{1e}') {
235 let mut parts = rec.splitn(5, '\t');
236 let (Some(sha), Some(ts), Some(name), Some(email)) =
237 (parts.next(), parts.next(), parts.next(), parts.next())
238 else {
239 continue;
240 };
241 let Ok(ts) = ts.parse::<i64>() else { continue };
242 let email = email.trim().to_lowercase();
243 let coauthors = parts
244 .next()
245 .into_iter()
246 .flat_map(|block| block.split('\u{1f}'))
247 .filter_map(parse_coauthor)
248 .filter(|(_, e)| e != &email)
249 .collect();
250 commits.push(Commit {
251 sha: sha.to_string(),
252 ts,
253 name: name.trim().to_string(),
254 email,
255 coauthors,
256 src: 0,
257 });
258 }
259 Ok(commits)
260}
261
262fn parse_coauthor(raw: &str) -> Option<(String, String)> {
264 let s = raw.trim();
265 if s.is_empty() {
266 return None;
267 }
268 match (s.find('<'), s.rfind('>')) {
269 (Some(lt), Some(gt)) if gt > lt => {
270 let name = s[..lt].trim().to_string();
271 let email = s[lt + 1..gt].trim().to_lowercase();
272 (!email.is_empty() || !name.is_empty()).then_some((name, email))
273 }
274 _ => Some((s.to_string(), String::new())),
275 }
276}
277
278fn git(dir: &Path, args: &[&str]) -> Result<String> {
279 let out = Command::new("git").arg("-C").arg(dir).args(args).output()?;
280 if !out.status.success() {
281 bail!("git {:?} failed", args);
282 }
283 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
284}
285
286pub fn parse_github_url(url: &str) -> Option<String> {
288 let u = url.trim().trim_end_matches('/').trim_end_matches(".git");
289 let rest = u
290 .strip_prefix("git@github.com:")
291 .or_else(|| u.strip_prefix("https://github.com/"))
292 .or_else(|| u.strip_prefix("http://github.com/"))
293 .or_else(|| u.strip_prefix("ssh://git@github.com/"))
294 .or_else(|| u.strip_prefix("github.com/"))?;
295 let mut parts = rest.splitn(3, '/');
296 let owner = parts.next()?;
297 let repo = parts.next()?;
298 if owner.is_empty() || repo.is_empty() {
299 return None;
300 }
301 Some(format!("{owner}/{repo}"))
302}
303
304pub fn looks_like_owner(input: &str) -> bool {
308 let s = input.trim();
309 if s.is_empty() || s.contains('/') || s.contains(':') || s.contains('.') {
310 return false;
311 }
312 if Path::new(s).exists() {
313 return false;
314 }
315 !s.starts_with('-') && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
316}
317
318fn looks_like_slug(s: &str) -> bool {
319 let parts: Vec<&str> = s.split('/').collect();
320 parts.len() == 2
321 && parts.iter().all(|p| {
322 !p.is_empty()
323 && p.chars()
324 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
325 })
326}
327
328pub fn sanitize(s: &str) -> String {
329 s.chars()
330 .map(|c| {
331 if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
332 c
333 } else {
334 '-'
335 }
336 })
337 .collect()
338}