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
51pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
54 let trimmed = url.trim_end_matches(".git");
55
56 let path = trimmed
58 .strip_prefix("https://")
59 .or_else(|| trimmed.strip_prefix("http://"))
60 .and_then(|s| {
61 s.split_once('/').map(|(_, rest)| rest)
63 })
64 .or_else(|| trimmed.rsplit_once(':').map(|(_, p)| p))
66 .ok_or_else(|| ReleaseError::Git(format!("cannot parse remote URL: {url}")))?;
67
68 let (owner, repo) = path
69 .split_once('/')
70 .ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
71
72 Ok((owner.to_string(), repo.to_string()))
73}
74
75fn parse_commit_log(output: &str) -> Vec<Commit> {
77 if output.is_empty() {
78 return Vec::new();
79 }
80
81 let mut commits = Vec::new();
82 let mut current_sha: Option<String> = None;
83 let mut current_message = String::new();
84
85 for line in output.lines() {
86 if line == "--END--" {
87 if let Some(sha) = current_sha.take() {
88 commits.push(Commit {
89 sha,
90 message: current_message.trim().to_string(),
91 });
92 current_message.clear();
93 }
94 } else if current_sha.is_none()
95 && line.len() == 40
96 && line.chars().all(|c| c.is_ascii_hexdigit())
97 {
98 current_sha = Some(line.to_string());
99 } else {
100 if !current_message.is_empty() {
101 current_message.push('\n');
102 }
103 current_message.push_str(line);
104 }
105 }
106
107 if let Some(sha) = current_sha {
109 commits.push(Commit {
110 sha,
111 message: current_message.trim().to_string(),
112 });
113 }
114
115 commits
116}
117
118impl GitRepository for NativeGitRepository {
119 fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
120 let pattern = format!("{prefix}*");
121 let result = self.git(&["tag", "--list", &pattern, "--sort=-v:refname"]);
122
123 let tags_output = match result {
124 Ok(output) if output.is_empty() => return Ok(None),
125 Ok(output) => output,
126 Err(_) => return Ok(None),
127 };
128
129 let tag_name = match tags_output.lines().next() {
130 Some(name) => name.trim(),
131 None => return Ok(None),
132 };
133
134 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
135 let version = match Version::parse(version_str) {
136 Ok(v) => v,
137 Err(_) => return Ok(None),
138 };
139
140 let sha = self.git(&["rev-list", "-1", tag_name])?;
141
142 Ok(Some(TagInfo {
143 name: tag_name.to_string(),
144 version,
145 sha,
146 }))
147 }
148
149 fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
150 let range = match from {
151 Some(sha) => format!("{sha}..HEAD"),
152 None => "HEAD".to_string(),
153 };
154
155 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
156 Ok(parse_commit_log(&output))
157 }
158
159 fn create_tag(&self, name: &str, message: &str) -> Result<(), ReleaseError> {
160 self.git(&["tag", "-a", name, "-m", message])?;
161 Ok(())
162 }
163
164 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
165 self.git(&["push", "origin", name])?;
166 Ok(())
167 }
168
169 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
170 let mut args = vec!["add", "--"];
171 args.extend(paths);
172 self.git(&args)?;
173
174 let status = self.git(&["status", "--porcelain"]);
175 match status {
176 Ok(s) if s.is_empty() => Ok(false),
177 _ => {
178 self.git(&["commit", "-m", message])?;
179 Ok(true)
180 }
181 }
182 }
183
184 fn push(&self) -> Result<(), ReleaseError> {
185 self.git(&["push", "origin", "HEAD"])?;
186 Ok(())
187 }
188
189 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
190 match self.git(&["rev-parse", "--verify", &format!("refs/tags/{name}")]) {
191 Ok(_) => Ok(true),
192 Err(_) => Ok(false),
193 }
194 }
195
196 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
197 let output = self.git(&["ls-remote", "--tags", "origin", name])?;
198 Ok(!output.is_empty())
199 }
200
201 fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
202 let pattern = format!("{prefix}*");
203 let result = self.git(&["tag", "--list", &pattern, "--sort=v:refname"]);
204
205 let tags_output = match result {
206 Ok(output) if output.is_empty() => return Ok(Vec::new()),
207 Ok(output) => output,
208 Err(_) => return Ok(Vec::new()),
209 };
210
211 let mut tags = Vec::new();
212 for line in tags_output.lines() {
213 let tag_name = line.trim();
214 if tag_name.is_empty() {
215 continue;
216 }
217 let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
218 let version = match Version::parse(version_str) {
219 Ok(v) => v,
220 Err(_) => continue,
221 };
222 let sha = self.git(&["rev-list", "-1", tag_name])?;
223 tags.push(TagInfo {
224 name: tag_name.to_string(),
225 version,
226 sha,
227 });
228 }
229
230 Ok(tags)
231 }
232
233 fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError> {
234 let range = match from {
235 Some(sha) => format!("{sha}..{to}"),
236 None => to.to_string(),
237 };
238
239 let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
240 Ok(parse_commit_log(&output))
241 }
242
243 fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError> {
244 let date = self.git(&["log", "-1", "--format=%cd", "--date=short", tag_name])?;
245 Ok(date)
246 }
247
248 fn force_create_tag(&self, name: &str, message: &str) -> Result<(), ReleaseError> {
249 self.git(&["tag", "-fa", name, "-m", message])?;
250 Ok(())
251 }
252
253 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
254 self.git(&["push", "origin", name, "--force"])?;
255 Ok(())
256 }
257
258 fn head_sha(&self) -> Result<String, ReleaseError> {
259 self.git(&["rev-parse", "HEAD"])
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn parse_ssh_remote() {
269 let (owner, repo) = parse_owner_repo("git@github.com:urmzd/semantic-release.git").unwrap();
270 assert_eq!(owner, "urmzd");
271 assert_eq!(repo, "semantic-release");
272 }
273
274 #[test]
275 fn parse_https_remote() {
276 let (owner, repo) =
277 parse_owner_repo("https://github.com/urmzd/semantic-release.git").unwrap();
278 assert_eq!(owner, "urmzd");
279 assert_eq!(repo, "semantic-release");
280 }
281
282 #[test]
283 fn parse_https_no_git_suffix() {
284 let (owner, repo) = parse_owner_repo("https://github.com/urmzd/semantic-release").unwrap();
285 assert_eq!(owner, "urmzd");
286 assert_eq!(repo, "semantic-release");
287 }
288}