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 mut cmd = std::process::Command::new("git");
14 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
17 cmd.args(["-c", "http.sslVerify=false"]);
18 }
19 let out = cmd
20 .args(args)
21 .current_dir(repo)
22 .output()
23 .context("failed to spawn git process")?;
24 if !out.status.success() {
25 let stderr = String::from_utf8_lossy(&out.stderr);
26 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
27 }
28 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
29}
30
31pub fn normalize_git_url(raw: &str) -> String {
40 let url = raw.trim();
41
42 if url.starts_with("git@") || url.starts_with("ssh://") {
44 return url.to_owned();
45 }
46
47 let scheme = if url.starts_with("https://") {
48 "https"
49 } else if url.starts_with("http://") {
50 "http"
51 } else {
52 return url.to_owned();
53 };
54
55 let authority_and_path = &url[scheme.len() + 3..]; let (host, path) = match authority_and_path.find('/') {
57 Some(i) => (&authority_and_path[..i], &authority_and_path[i..]),
58 None => (authority_and_path, "/"),
59 };
60 let path = path.trim_end_matches('/');
61
62 {
68 let path_lower = path.to_lowercase();
69 if let Some(proj_pos) = path_lower.find("/projects/") {
70 let after = &path[proj_pos + "/projects/".len()..];
71 let parts: Vec<&str> = after.splitn(4, '/').collect();
72 if parts.len() >= 3 && parts[1].eq_ignore_ascii_case("repos") {
74 let context = &path[..proj_pos]; let project = parts[0].to_lowercase();
76 let repo = parts[2].trim_end_matches(".git");
77 return format!("{scheme}://{host}{context}/scm/{project}/{repo}.git");
78 }
79 }
80 }
81
82 if let Some(idx) = path.find("/-/") {
86 let repo_path = &path[..idx];
87 let repo_path = repo_path.trim_end_matches(".git");
88 return format!("{scheme}://{host}{repo_path}.git");
89 }
90
91 if host == "github.com" || host.ends_with(".github.com") {
95 let p = path.trim_start_matches('/');
96 let parts: Vec<&str> = p.splitn(4, '/').collect();
97 if parts.len() >= 3
98 && matches!(
99 parts[2],
100 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
101 )
102 {
103 let owner = parts[0];
104 let repo = parts[1].trim_end_matches(".git");
105 return format!("{scheme}://{host}/{owner}/{repo}.git");
106 }
107 }
108
109 if host == "bitbucket.org" {
113 let p = path.trim_start_matches('/');
114 let parts: Vec<&str> = p.splitn(4, '/').collect();
115 if parts.len() >= 3 && parts[2] == "src" {
116 let ws = parts[0];
117 let repo = parts[1].trim_end_matches(".git");
118 return format!("{scheme}://{host}/{ws}/{repo}.git");
119 }
120 }
121
122 url.to_owned()
123}
124
125fn validate_clone_url(url: &str) -> Result<()> {
128 let lower = url.to_lowercase();
129 let allowed = ["https://", "http://", "git://", "ssh://", "git@"];
130 if !allowed.iter().any(|p| lower.starts_with(p)) {
131 bail!(
132 "git URL rejected: only https://, http://, git://, ssh://, and git@ URLs are \
133 permitted (got {url:?})"
134 );
135 }
136 Ok(())
137}
138
139pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
148 let normalized = normalize_git_url(url);
149 let url = normalized.as_str();
150 validate_clone_url(url)?;
151 if dest.join(".git").exists() {
152 run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
153 } else {
154 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
155 let dest_str = dest.to_str().unwrap_or(".");
156 let parent = dest.parent().unwrap_or(dest);
157 run_git(
158 parent,
159 &["clone", "--no-single-branch", "--depth=50", url, dest_str],
160 )?;
161 }
162 Ok(())
163}
164
165pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
170 run_git(repo, &["rev-parse", ref_name])
171}
172
173pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
180 let wt = worktree_path.to_str().unwrap_or(".");
181 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
182 Ok(())
183}
184
185pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
190 let wt = worktree_path.to_str().unwrap_or(".");
191 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
192 Ok(())
193}
194
195pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
202 Ok(RepoRefs {
203 branches: list_branches(repo)?,
204 tags: list_tags(repo)?,
205 recent_commits: list_commits(repo, "HEAD", 40)?,
206 })
207}
208
209fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
210 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
211 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
215 let refs = out
216 .lines()
217 .filter(|l| !l.trim().is_empty())
218 .map(|l| parse_ref_line(l, GitRefKind::Branch))
219 .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
221 .map(|mut r| {
222 if let Some(slash) = r.name.find('/') {
224 r.name = r.name[slash + 1..].to_owned();
225 }
226 r
227 })
228 .collect::<Vec<_>>();
229 Ok(refs)
230}
231
232fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
233 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
234 let out = run_git(
235 repo,
236 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
237 )?;
238 Ok(out
239 .lines()
240 .filter(|l| !l.trim().is_empty())
241 .map(|l| parse_ref_line(l, GitRefKind::Tag))
242 .collect())
243}
244
245fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
246 let parts: Vec<&str> = line.splitn(4, '|').collect();
247 let name = parts.first().copied().unwrap_or("").to_owned();
248 let sha = parts.get(1).copied().unwrap_or("").to_owned();
249 let date = parts.get(2).copied().and_then(parse_git_date);
250 let message = parts.get(3).map(|s| (*s).to_owned());
251 GitRef {
252 kind,
253 name,
254 sha,
255 date,
256 message,
257 }
258}
259
260pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
267 let fmt = "%H|%h|%an|%aI|%s";
268 let n = format!("-{limit}");
269 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
270 Ok(out
271 .lines()
272 .filter(|l| !l.trim().is_empty())
273 .map(parse_commit_line)
274 .collect())
275}
276
277fn parse_commit_line(line: &str) -> GitCommit {
278 let p: Vec<&str> = line.splitn(5, '|').collect();
279 let sha = p.first().copied().unwrap_or("").to_owned();
280 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
281 let author = p.get(2).copied().unwrap_or("").to_owned();
282 let date = p
283 .get(3)
284 .copied()
285 .and_then(parse_git_date)
286 .unwrap_or_default();
287 let subject = p.get(4).copied().unwrap_or("").to_owned();
288 GitCommit {
289 sha,
290 short_sha,
291 author,
292 date,
293 subject,
294 }
295}
296
297fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
298 chrono::DateTime::parse_from_rfc3339(s)
299 .ok()
300 .map(|d| d.with_timezone(&chrono::Utc))
301}