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