Skip to main content

sr_core/
native_git.rs

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