1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use base64::Engine;
5use semver::Version;
6use sr_core::commit::Commit;
7use sr_core::error::ReleaseError;
8use sr_core::git::{GitRepository, TagInfo};
9
10pub struct NativeGitRepository {
12 path: PathBuf,
13 http_auth: Option<(String, String)>, }
15
16impl NativeGitRepository {
17 pub fn open(path: &Path) -> Result<Self, ReleaseError> {
18 let repo = Self {
19 path: path.to_path_buf(),
20 http_auth: None,
21 };
22 repo.git(&["rev-parse", "--git-dir"])?;
24 Ok(repo)
25 }
26
27 pub fn with_http_auth(mut self, hostname: String, token: String) -> Self {
32 self.http_auth = Some((hostname, token));
33 self
34 }
35
36 fn git(&self, args: &[&str]) -> Result<String, ReleaseError> {
37 let mut cmd = Command::new("git");
38 cmd.env("GIT_TERMINAL_PROMPT", "0");
41 cmd.arg("-C").arg(&self.path);
42
43 if let Some((hostname, token)) = &self.http_auth {
47 let credentials = format!("x-access-token:{token}");
48 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
49 let config_key = format!("http.https://{hostname}/.extraheader");
50 let config_val = format!("AUTHORIZATION: basic {encoded}");
51 cmd.args(["-c", &format!("{config_key}=")]);
52 cmd.args(["-c", &format!("{config_key}={config_val}")]);
53 }
54
55 let output = cmd
56 .args(args)
57 .output()
58 .map_err(|e| ReleaseError::Git(format!("failed to run git: {e}")))?;
59
60 if !output.status.success() {
61 let stderr = String::from_utf8_lossy(&output.stderr);
62 return Err(ReleaseError::Git(format!(
63 "git {} failed: {}",
64 args.join(" "),
65 stderr.trim()
66 )));
67 }
68
69 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
70 }
71
72 pub fn parse_remote(&self) -> Result<(String, String), ReleaseError> {
74 let url = self.git(&["remote", "get-url", "origin"])?;
75 parse_owner_repo(&url)
76 }
77
78 pub fn parse_remote_full(&self) -> Result<(String, String, String), ReleaseError> {
80 let url = self.git(&["remote", "get-url", "origin"])?;
81 parse_remote_url(&url)
82 }
83}
84
85pub fn parse_remote_url(url: &str) -> Result<(String, String, String), ReleaseError> {
88 let trimmed = url.trim_end_matches(".git");
89
90 if let Some(rest) = trimmed
92 .strip_prefix("https://")
93 .or_else(|| trimmed.strip_prefix("http://"))
94 {
95 let (hostname, path) = rest
96 .split_once('/')
97 .ok_or_else(|| ReleaseError::Git(format!("cannot parse remote URL: {url}")))?;
98 let (owner, repo) = path
99 .split_once('/')
100 .ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
101 return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
102 }
103
104 if let Some((host_part, path)) = trimmed.split_once(':') {
106 let hostname = host_part.rsplit('@').next().unwrap_or(host_part);
107 let (owner, repo) = path
108 .split_once('/')
109 .ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
110 return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
111 }
112
113 Err(ReleaseError::Git(format!("cannot parse remote URL: {url}")))
114}
115
116pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
118 let (_, owner, repo) = parse_remote_url(url)?;
119 Ok((owner, repo))
120}
121
122fn parse_commit_log(output: &str) -> Vec<Commit> {
124 if output.is_empty() {
125 return Vec::new();
126 }
127
128 let mut commits = Vec::new();
129 let mut current_sha: Option<String> = None;
130 let mut current_message = String::new();
131
132 for line in output.lines() {
133 if line == "--END--" {
134 if let Some(sha) = current_sha.take() {
135 commits.push(Commit {
136 sha,
137 message: current_message.trim().to_string(),
138 });
139 current_message.clear();
140 }
141 } else if current_sha.is_none()
142 && line.len() == 40
143 && line.chars().all(|c| c.is_ascii_hexdigit())
144 {
145 current_sha = Some(line.to_string());
146 } else {
147 if !current_message.is_empty() {
148 current_message.push('\n');
149 }
150 current_message.push_str(line);
151 }
152 }
153
154 if let Some(sha) = current_sha {
156 commits.push(Commit {
157 sha,
158 message: current_message.trim().to_string(),
159 });
160 }
161
162 commits
163}
164
165impl GitRepository for NativeGitRepository {
166 fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
167 let pattern = format!("{prefix}*");
168 let result = self.git(&["tag", "--list", &pattern, "--sort=-v:refname"]);
169
170 let tags_output = match result {
171 Ok(output) if output.is_empty() => return Ok(None),
172 Ok(output) => output,
173 Err(_) => return Ok(None),
174 };
175
176 let tag_name = match tags_output.lines().next() {
177 Some(name) => name.trim(),
178 None => return Ok(None),
179 };
180
181 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
182 let version = match Version::parse(version_str) {
183 Ok(v) => v,
184 Err(_) => return Ok(None),
185 };
186
187 let sha = self.git(&["rev-list", "-1", tag_name])?;
188
189 Ok(Some(TagInfo {
190 name: tag_name.to_string(),
191 version,
192 sha,
193 }))
194 }
195
196 fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
197 let range = match from {
198 Some(sha) => format!("{sha}..HEAD"),
199 None => "HEAD".to_string(),
200 };
201
202 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
203 Ok(parse_commit_log(&output))
204 }
205
206 fn create_tag(&self, name: &str, message: &str, sign: bool) -> Result<(), ReleaseError> {
207 let flag = if sign { "-s" } else { "-a" };
208 self.git(&["tag", flag, name, "-m", message])?;
209 Ok(())
210 }
211
212 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
213 self.git(&["push", "origin", name])?;
214 Ok(())
215 }
216
217 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
218 let mut args = vec!["add", "--"];
219 args.extend(paths);
220 self.git(&args)?;
221
222 let status = self.git(&["status", "--porcelain"]);
223 match status {
224 Ok(s) if s.is_empty() => Ok(false),
225 _ => {
226 self.git(&["commit", "-m", message])?;
227 Ok(true)
228 }
229 }
230 }
231
232 fn push(&self) -> Result<(), ReleaseError> {
233 self.git(&["push", "origin", "HEAD"])?;
234 Ok(())
235 }
236
237 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
238 match self.git(&["rev-parse", "--verify", &format!("refs/tags/{name}")]) {
239 Ok(_) => Ok(true),
240 Err(_) => Ok(false),
241 }
242 }
243
244 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
245 let output = self.git(&["ls-remote", "--tags", "origin", name])?;
246 Ok(!output.is_empty())
247 }
248
249 fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
250 let pattern = format!("{prefix}*");
251 let result = self.git(&["tag", "--list", &pattern, "--sort=v:refname"]);
252
253 let tags_output = match result {
254 Ok(output) if output.is_empty() => return Ok(Vec::new()),
255 Ok(output) => output,
256 Err(_) => return Ok(Vec::new()),
257 };
258
259 let mut tags = Vec::new();
260 for line in tags_output.lines() {
261 let tag_name = line.trim();
262 if tag_name.is_empty() {
263 continue;
264 }
265 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
266 let version = match Version::parse(version_str) {
267 Ok(v) => v,
268 Err(_) => continue,
269 };
270 let sha = self.git(&["rev-list", "-1", tag_name])?;
271 tags.push(TagInfo {
272 name: tag_name.to_string(),
273 version,
274 sha,
275 });
276 }
277
278 Ok(tags)
279 }
280
281 fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError> {
282 let range = match from {
283 Some(sha) => format!("{sha}..{to}"),
284 None => to.to_string(),
285 };
286
287 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
288 Ok(parse_commit_log(&output))
289 }
290
291 fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError> {
292 let date = self.git(&["log", "-1", "--format=%cd", "--date=short", tag_name])?;
293 Ok(date)
294 }
295
296 fn force_create_tag(&self, name: &str, message: &str, sign: bool) -> Result<(), ReleaseError> {
297 let flag = if sign { "-fs" } else { "-fa" };
298 self.git(&["tag", flag, name, "-m", message])?;
299 Ok(())
300 }
301
302 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
303 self.git(&["push", "origin", name, "--force"])?;
304 Ok(())
305 }
306
307 fn head_sha(&self) -> Result<String, ReleaseError> {
308 self.git(&["rev-parse", "HEAD"])
309 }
310
311 fn commits_since_in_path(
312 &self,
313 from: Option<&str>,
314 path: &str,
315 ) -> Result<Vec<Commit>, ReleaseError> {
316 let range = match from {
317 Some(sha) => format!("{sha}..HEAD"),
318 None => "HEAD".to_string(),
319 };
320 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range, "--", path])?;
321 Ok(parse_commit_log(&output))
322 }
323
324 fn commits_between_in_path(
325 &self,
326 from: Option<&str>,
327 to: &str,
328 path: &str,
329 ) -> Result<Vec<Commit>, ReleaseError> {
330 let range = match from {
331 Some(sha) => format!("{sha}..{to}"),
332 None => to.to_string(),
333 };
334 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range, "--", path])?;
335 Ok(parse_commit_log(&output))
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn parse_ssh_remote() {
345 let (owner, repo) = parse_owner_repo("git@github.com:urmzd/sr.git").unwrap();
346 assert_eq!(owner, "urmzd");
347 assert_eq!(repo, "sr");
348 }
349
350 #[test]
351 fn parse_https_remote() {
352 let (owner, repo) = parse_owner_repo("https://github.com/urmzd/sr.git").unwrap();
353 assert_eq!(owner, "urmzd");
354 assert_eq!(repo, "sr");
355 }
356
357 #[test]
358 fn parse_https_no_git_suffix() {
359 let (owner, repo) = parse_owner_repo("https://github.com/urmzd/sr").unwrap();
360 assert_eq!(owner, "urmzd");
361 assert_eq!(repo, "sr");
362 }
363
364 #[test]
365 fn parse_remote_url_github_https() {
366 let (host, owner, repo) = parse_remote_url("https://github.com/urmzd/sr.git").unwrap();
367 assert_eq!(host, "github.com");
368 assert_eq!(owner, "urmzd");
369 assert_eq!(repo, "sr");
370 }
371
372 #[test]
373 fn parse_remote_url_github_ssh() {
374 let (host, owner, repo) = parse_remote_url("git@github.com:urmzd/sr.git").unwrap();
375 assert_eq!(host, "github.com");
376 assert_eq!(owner, "urmzd");
377 assert_eq!(repo, "sr");
378 }
379
380 #[test]
381 fn parse_remote_url_ghes_https() {
382 let (host, owner, repo) =
383 parse_remote_url("https://ghes.example.com/org/my-repo.git").unwrap();
384 assert_eq!(host, "ghes.example.com");
385 assert_eq!(owner, "org");
386 assert_eq!(repo, "my-repo");
387 }
388
389 #[test]
390 fn parse_remote_url_ghes_ssh() {
391 let (host, owner, repo) = parse_remote_url("git@ghes.example.com:org/my-repo.git").unwrap();
392 assert_eq!(host, "ghes.example.com");
393 assert_eq!(owner, "org");
394 assert_eq!(repo, "my-repo");
395 }
396
397 #[test]
398 fn parse_remote_url_no_git_suffix() {
399 let (host, owner, repo) = parse_remote_url("https://github.com/urmzd/sr").unwrap();
400 assert_eq!(host, "github.com");
401 assert_eq!(owner, "urmzd");
402 assert_eq!(repo, "sr");
403 }
404
405 #[test]
406 fn http_auth_header_encodes_correctly() {
407 use base64::Engine;
408
409 let token = "ghp_testtoken123";
410 let credentials = format!("x-access-token:{token}");
411 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
412
413 let decoded_bytes = base64::engine::general_purpose::STANDARD
415 .decode(&encoded)
416 .expect("base64 should decode");
417 let decoded = String::from_utf8(decoded_bytes).expect("should be valid utf-8");
418 assert_eq!(decoded, "x-access-token:ghp_testtoken123");
419 }
420
421 #[test]
422 fn http_auth_header_scoped_to_hostname() {
423 let hostname = "ghes.example.com";
424 let config_key = format!("http.https://{hostname}/.extraheader");
425 assert_eq!(config_key, "http.https://ghes.example.com/.extraheader");
426
427 let hostname = "github.com";
429 let config_key = format!("http.https://{hostname}/.extraheader");
430 assert_eq!(config_key, "http.https://github.com/.extraheader");
431 }
432}