Skip to main content

sr_git/
lib.rs

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
9/// Git repository implementation backed by native `git` CLI commands.
10pub 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        // Validate this is a git repo
20        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    /// Parse owner/repo from a git remote URL.
45    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    /// Parse (hostname, owner, repo) from a git remote URL.
51    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
57/// Extract (hostname, owner, repo) from a git remote URL.
58/// Supports SSH (git@hostname:owner/repo.git) and HTTPS (https://hostname/owner/repo.git).
59pub fn parse_remote_url(url: &str) -> Result<(String, String, String), ReleaseError> {
60    let trimmed = url.trim_end_matches(".git");
61
62    // Try HTTPS/HTTP first: https://hostname/owner/repo
63    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    // SSH style: git@hostname:owner/repo
77    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
88/// Extract owner/repo from a git remote URL (convenience wrapper).
89pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
90    let (_, owner, repo) = parse_remote_url(url)?;
91    Ok((owner, repo))
92}
93
94/// Parse the output of `git log --format=%H%n%B%n--END--` into commits.
95fn 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    // Handle last commit if no trailing --END--
127    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}