Skip to main content

sr_git/
lib.rs

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