1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use semver::Version;
5use sr_core::commit::Commit;
6use sr_core::error::ReleaseError;
7use sr_core::git::{GitRepository, TagInfo};
8
9pub struct NativeGitRepository {
11 path: PathBuf,
12}
13
14impl NativeGitRepository {
15 pub fn open(path: &Path) -> Result<Self, ReleaseError> {
16 let repo = Self {
17 path: path.to_path_buf(),
18 };
19 repo.git(&["rev-parse", "--git-dir"])?;
21 Ok(repo)
22 }
23
24 fn git(&self, args: &[&str]) -> Result<String, ReleaseError> {
25 let output = Command::new("git")
26 .arg("-C")
27 .arg(&self.path)
28 .args(args)
29 .output()
30 .map_err(|e| ReleaseError::Git(format!("failed to run git: {e}")))?;
31
32 if !output.status.success() {
33 let stderr = String::from_utf8_lossy(&output.stderr);
34 return Err(ReleaseError::Git(format!(
35 "git {} failed: {}",
36 args.join(" "),
37 stderr.trim()
38 )));
39 }
40
41 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
42 }
43
44 pub fn parse_remote(&self) -> Result<(String, String), ReleaseError> {
46 let url = self.git(&["remote", "get-url", "origin"])?;
47 parse_owner_repo(&url)
48 }
49
50 pub fn parse_remote_full(&self) -> Result<(String, String, String), ReleaseError> {
52 let url = self.git(&["remote", "get-url", "origin"])?;
53 parse_remote_url(&url)
54 }
55}
56
57pub fn parse_remote_url(url: &str) -> Result<(String, String, String), ReleaseError> {
60 let trimmed = url.trim_end_matches(".git");
61
62 if let Some(rest) = trimmed
64 .strip_prefix("https://")
65 .or_else(|| trimmed.strip_prefix("http://"))
66 {
67 let (hostname, path) = rest
68 .split_once('/')
69 .ok_or_else(|| ReleaseError::Git(format!("cannot parse remote URL: {url}")))?;
70 let (owner, repo) = path
71 .split_once('/')
72 .ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
73 return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
74 }
75
76 if let Some((host_part, path)) = trimmed.split_once(':') {
78 let hostname = host_part.rsplit('@').next().unwrap_or(host_part);
79 let (owner, repo) = path
80 .split_once('/')
81 .ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
82 return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
83 }
84
85 Err(ReleaseError::Git(format!("cannot parse remote URL: {url}")))
86}
87
88pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
90 let (_, owner, repo) = parse_remote_url(url)?;
91 Ok((owner, repo))
92}
93
94fn parse_commit_log(output: &str) -> Vec<Commit> {
96 if output.is_empty() {
97 return Vec::new();
98 }
99
100 let mut commits = Vec::new();
101 let mut current_sha: Option<String> = None;
102 let mut current_message = String::new();
103
104 for line in output.lines() {
105 if line == "--END--" {
106 if let Some(sha) = current_sha.take() {
107 commits.push(Commit {
108 sha,
109 message: current_message.trim().to_string(),
110 });
111 current_message.clear();
112 }
113 } else if current_sha.is_none()
114 && line.len() == 40
115 && line.chars().all(|c| c.is_ascii_hexdigit())
116 {
117 current_sha = Some(line.to_string());
118 } else {
119 if !current_message.is_empty() {
120 current_message.push('\n');
121 }
122 current_message.push_str(line);
123 }
124 }
125
126 if let Some(sha) = current_sha {
128 commits.push(Commit {
129 sha,
130 message: current_message.trim().to_string(),
131 });
132 }
133
134 commits
135}
136
137impl GitRepository for NativeGitRepository {
138 fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
139 let pattern = format!("{prefix}*");
140 let result = self.git(&["tag", "--list", &pattern, "--sort=-v:refname"]);
141
142 let tags_output = match result {
143 Ok(output) if output.is_empty() => return Ok(None),
144 Ok(output) => output,
145 Err(_) => return Ok(None),
146 };
147
148 let tag_name = match tags_output.lines().next() {
149 Some(name) => name.trim(),
150 None => return Ok(None),
151 };
152
153 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
154 let version = match Version::parse(version_str) {
155 Ok(v) => v,
156 Err(_) => return Ok(None),
157 };
158
159 let sha = self.git(&["rev-list", "-1", tag_name])?;
160
161 Ok(Some(TagInfo {
162 name: tag_name.to_string(),
163 version,
164 sha,
165 }))
166 }
167
168 fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
169 let range = match from {
170 Some(sha) => format!("{sha}..HEAD"),
171 None => "HEAD".to_string(),
172 };
173
174 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
175 Ok(parse_commit_log(&output))
176 }
177
178 fn create_tag(&self, name: &str, message: &str) -> Result<(), ReleaseError> {
179 self.git(&["tag", "-a", name, "-m", message])?;
180 Ok(())
181 }
182
183 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
184 self.git(&["push", "origin", name])?;
185 Ok(())
186 }
187
188 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
189 let mut args = vec!["add", "--"];
190 args.extend(paths);
191 self.git(&args)?;
192
193 let status = self.git(&["status", "--porcelain"]);
194 match status {
195 Ok(s) if s.is_empty() => Ok(false),
196 _ => {
197 self.git(&["commit", "-m", message])?;
198 Ok(true)
199 }
200 }
201 }
202
203 fn push(&self) -> Result<(), ReleaseError> {
204 self.git(&["push", "origin", "HEAD"])?;
205 Ok(())
206 }
207
208 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
209 match self.git(&["rev-parse", "--verify", &format!("refs/tags/{name}")]) {
210 Ok(_) => Ok(true),
211 Err(_) => Ok(false),
212 }
213 }
214
215 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
216 let output = self.git(&["ls-remote", "--tags", "origin", name])?;
217 Ok(!output.is_empty())
218 }
219
220 fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
221 let pattern = format!("{prefix}*");
222 let result = self.git(&["tag", "--list", &pattern, "--sort=v:refname"]);
223
224 let tags_output = match result {
225 Ok(output) if output.is_empty() => return Ok(Vec::new()),
226 Ok(output) => output,
227 Err(_) => return Ok(Vec::new()),
228 };
229
230 let mut tags = Vec::new();
231 for line in tags_output.lines() {
232 let tag_name = line.trim();
233 if tag_name.is_empty() {
234 continue;
235 }
236 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
237 let version = match Version::parse(version_str) {
238 Ok(v) => v,
239 Err(_) => continue,
240 };
241 let sha = self.git(&["rev-list", "-1", tag_name])?;
242 tags.push(TagInfo {
243 name: tag_name.to_string(),
244 version,
245 sha,
246 });
247 }
248
249 Ok(tags)
250 }
251
252 fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError> {
253 let range = match from {
254 Some(sha) => format!("{sha}..{to}"),
255 None => to.to_string(),
256 };
257
258 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
259 Ok(parse_commit_log(&output))
260 }
261
262 fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError> {
263 let date = self.git(&["log", "-1", "--format=%cd", "--date=short", tag_name])?;
264 Ok(date)
265 }
266
267 fn force_create_tag(&self, name: &str, message: &str) -> Result<(), ReleaseError> {
268 self.git(&["tag", "-fa", name, "-m", message])?;
269 Ok(())
270 }
271
272 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
273 self.git(&["push", "origin", name, "--force"])?;
274 Ok(())
275 }
276
277 fn head_sha(&self) -> Result<String, ReleaseError> {
278 self.git(&["rev-parse", "HEAD"])
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn parse_ssh_remote() {
288 let (owner, repo) = parse_owner_repo("git@github.com:urmzd/semantic-release.git").unwrap();
289 assert_eq!(owner, "urmzd");
290 assert_eq!(repo, "semantic-release");
291 }
292
293 #[test]
294 fn parse_https_remote() {
295 let (owner, repo) =
296 parse_owner_repo("https://github.com/urmzd/semantic-release.git").unwrap();
297 assert_eq!(owner, "urmzd");
298 assert_eq!(repo, "semantic-release");
299 }
300
301 #[test]
302 fn parse_https_no_git_suffix() {
303 let (owner, repo) = parse_owner_repo("https://github.com/urmzd/semantic-release").unwrap();
304 assert_eq!(owner, "urmzd");
305 assert_eq!(repo, "semantic-release");
306 }
307
308 #[test]
309 fn parse_remote_url_github_https() {
310 let (host, owner, repo) =
311 parse_remote_url("https://github.com/urmzd/semantic-release.git").unwrap();
312 assert_eq!(host, "github.com");
313 assert_eq!(owner, "urmzd");
314 assert_eq!(repo, "semantic-release");
315 }
316
317 #[test]
318 fn parse_remote_url_github_ssh() {
319 let (host, owner, repo) =
320 parse_remote_url("git@github.com:urmzd/semantic-release.git").unwrap();
321 assert_eq!(host, "github.com");
322 assert_eq!(owner, "urmzd");
323 assert_eq!(repo, "semantic-release");
324 }
325
326 #[test]
327 fn parse_remote_url_ghes_https() {
328 let (host, owner, repo) =
329 parse_remote_url("https://ghes.example.com/org/my-repo.git").unwrap();
330 assert_eq!(host, "ghes.example.com");
331 assert_eq!(owner, "org");
332 assert_eq!(repo, "my-repo");
333 }
334
335 #[test]
336 fn parse_remote_url_ghes_ssh() {
337 let (host, owner, repo) = parse_remote_url("git@ghes.example.com:org/my-repo.git").unwrap();
338 assert_eq!(host, "ghes.example.com");
339 assert_eq!(owner, "org");
340 assert_eq!(repo, "my-repo");
341 }
342
343 #[test]
344 fn parse_remote_url_no_git_suffix() {
345 let (host, owner, repo) =
346 parse_remote_url("https://github.com/urmzd/semantic-release").unwrap();
347 assert_eq!(host, "github.com");
348 assert_eq!(owner, "urmzd");
349 assert_eq!(repo, "semantic-release");
350 }
351}