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
51/// Extract owner/repo from a GitHub remote URL.
52/// Supports SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git).
53pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
54    let trimmed = url.trim_end_matches(".git");
55
56    // Try HTTPS/HTTP first: https://github.com/owner/repo
57    let path = trimmed
58        .strip_prefix("https://")
59        .or_else(|| trimmed.strip_prefix("http://"))
60        .and_then(|s| {
61            // Skip the hostname: "github.com/owner/repo" -> "owner/repo"
62            s.split_once('/').map(|(_, rest)| rest)
63        })
64        // Fall back to SSH style: git@github.com:owner/repo
65        .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
75/// Parse the output of `git log --format=%H%n%B%n--END--` into commits.
76fn 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    // Handle last commit if no trailing --END--
108    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}