skill_runtime/
git_source.rs

1// Git source URL parsing and types
2//
3// Supports various Git URL formats:
4// - HTTPS: https://github.com/user/repo
5// - Shorthand: github:user/repo, gitlab:user/repo
6// - SSH: git@github.com:user/repo.git
7// - With ref: github:user/repo@v1.0.0, github:user/repo@main
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12/// Represents a parsed Git source URL
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct GitSource {
15    /// Repository URL (normalized to HTTPS for cloning)
16    pub url: String,
17    /// Repository owner/organization
18    pub owner: String,
19    /// Repository name
20    pub repo: String,
21    /// Git reference (branch, tag, or commit)
22    pub git_ref: GitRef,
23    /// Original input string for display
24    pub original: String,
25}
26
27/// Git reference type
28#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
29pub enum GitRef {
30    /// Use the default branch (main/master)
31    #[default]
32    DefaultBranch,
33    /// A specific branch
34    Branch(String),
35    /// A tag (usually versions like v1.0.0)
36    Tag(String),
37    /// A specific commit SHA
38    Commit(String),
39}
40
41impl GitRef {
42    /// Get the refspec string for checkout
43    pub fn as_refspec(&self) -> Option<&str> {
44        match self {
45            GitRef::DefaultBranch => None,
46            GitRef::Branch(b) => Some(b),
47            GitRef::Tag(t) => Some(t),
48            GitRef::Commit(c) => Some(c),
49        }
50    }
51
52    /// Check if this is a pinned ref (tag or commit)
53    pub fn is_pinned(&self) -> bool {
54        matches!(self, GitRef::Tag(_) | GitRef::Commit(_))
55    }
56}
57
58impl std::fmt::Display for GitRef {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            GitRef::DefaultBranch => write!(f, "HEAD"),
62            GitRef::Branch(b) => write!(f, "{}", b),
63            GitRef::Tag(t) => write!(f, "{}", t),
64            GitRef::Commit(c) => write!(f, "{}", &c[..7.min(c.len())]),
65        }
66    }
67}
68
69impl GitSource {
70    /// Get a unique identifier for this source (for caching)
71    pub fn cache_key(&self) -> String {
72        format!("{}/{}", self.owner, self.repo)
73    }
74
75    /// Get display name
76    pub fn display_name(&self) -> String {
77        match &self.git_ref {
78            GitRef::DefaultBranch => format!("{}/{}", self.owner, self.repo),
79            ref_type => format!("{}/{}@{}", self.owner, self.repo, ref_type),
80        }
81    }
82}
83
84/// Parse various Git URL formats into a normalized GitSource
85///
86/// Supported formats:
87/// - `https://github.com/user/repo`
88/// - `https://github.com/user/repo.git`
89/// - `github:user/repo`
90/// - `github:user/repo@v1.0.0`
91/// - `git@github.com:user/repo.git`
92/// - `gitlab:user/repo`
93/// - `https://gitlab.com/user/repo`
94pub fn parse_git_url(input: &str) -> Result<GitSource> {
95    let original = input.to_string();
96
97    // Handle shorthand formats: github:user/repo[@ref]
98    if let Some(rest) = input.strip_prefix("github:") {
99        return parse_shorthand("github.com", rest, original);
100    }
101    if let Some(rest) = input.strip_prefix("gitlab:") {
102        return parse_shorthand("gitlab.com", rest, original);
103    }
104    if let Some(rest) = input.strip_prefix("bitbucket:") {
105        return parse_shorthand("bitbucket.org", rest, original);
106    }
107
108    // Handle SSH format: git@github.com:user/repo.git
109    if input.starts_with("git@") {
110        return parse_ssh_url(input, original);
111    }
112
113    // Handle HTTPS URLs
114    if input.starts_with("https://") || input.starts_with("http://") {
115        return parse_https_url(input, original);
116    }
117
118    anyhow::bail!(
119        "Unsupported Git URL format: {}\n\
120         Supported formats:\n\
121         - github:user/repo\n\
122         - github:user/repo@v1.0.0\n\
123         - https://github.com/user/repo\n\
124         - git@github.com:user/repo.git",
125        input
126    );
127}
128
129fn parse_shorthand(host: &str, rest: &str, original: String) -> Result<GitSource> {
130    // Split by @ for ref: user/repo@v1.0.0
131    let (path, git_ref) = if let Some(at_pos) = rest.rfind('@') {
132        let ref_str = &rest[at_pos + 1..];
133        let path = &rest[..at_pos];
134        (path, parse_ref(ref_str))
135    } else {
136        (rest, GitRef::DefaultBranch)
137    };
138
139    let parts: Vec<&str> = path.split('/').collect();
140    if parts.len() < 2 {
141        anyhow::bail!(
142            "Invalid shorthand format '{}'. Expected: user/repo or user/repo@version",
143            rest
144        );
145    }
146
147    let owner = parts[0].to_string();
148    let repo = parts[1].trim_end_matches(".git").to_string();
149
150    Ok(GitSource {
151        url: format!("https://{}/{}/{}.git", host, owner, repo),
152        owner,
153        repo,
154        git_ref,
155        original,
156    })
157}
158
159fn parse_ssh_url(input: &str, original: String) -> Result<GitSource> {
160    // git@github.com:user/repo.git
161    let without_prefix = input
162        .strip_prefix("git@")
163        .context("Invalid SSH URL format")?;
164
165    let colon_pos = without_prefix
166        .find(':')
167        .context("Invalid SSH URL: missing colon separator")?;
168
169    let host = &without_prefix[..colon_pos];
170    let path = &without_prefix[colon_pos + 1..];
171
172    let parts: Vec<&str> = path.trim_end_matches(".git").split('/').collect();
173    if parts.len() < 2 {
174        anyhow::bail!("Invalid SSH URL: expected user/repo format after host");
175    }
176
177    Ok(GitSource {
178        url: format!("https://{}/{}", host, path),
179        owner: parts[0].to_string(),
180        repo: parts[1].trim_end_matches(".git").to_string(),
181        git_ref: GitRef::DefaultBranch,
182        original,
183    })
184}
185
186fn parse_https_url(input: &str, original: String) -> Result<GitSource> {
187    let url = url::Url::parse(input).context("Invalid URL")?;
188    let host = url.host_str().context("Missing host in URL")?;
189
190    let path_segments: Vec<&str> = url
191        .path_segments()
192        .context("Invalid URL path")?
193        .filter(|s| !s.is_empty())
194        .collect();
195
196    if path_segments.len() < 2 {
197        anyhow::bail!("URL must include owner/repo path: {}", input);
198    }
199
200    let owner = path_segments[0].to_string();
201    let repo = path_segments[1].trim_end_matches(".git").to_string();
202
203    // Check for ref in URL fragment
204    let git_ref = if let Some(fragment) = url.fragment() {
205        parse_ref(fragment)
206    } else {
207        GitRef::DefaultBranch
208    };
209
210    Ok(GitSource {
211        url: format!("https://{}/{}/{}.git", host, owner, repo),
212        owner,
213        repo,
214        git_ref,
215        original,
216    })
217}
218
219fn parse_ref(ref_str: &str) -> GitRef {
220    // Tags typically start with 'v' followed by a number
221    if ref_str.starts_with('v')
222        && ref_str
223            .chars()
224            .nth(1)
225            .map_or(false, |c| c.is_ascii_digit())
226    {
227        GitRef::Tag(ref_str.to_string())
228    }
229    // 40-character hex strings are commit SHAs
230    else if ref_str.len() == 40 && ref_str.chars().all(|c| c.is_ascii_hexdigit()) {
231        GitRef::Commit(ref_str.to_string())
232    }
233    // Everything else is treated as a branch name
234    else {
235        GitRef::Branch(ref_str.to_string())
236    }
237}
238
239/// Check if a string looks like a Git URL
240pub fn is_git_url(input: &str) -> bool {
241    input.starts_with("https://github.com")
242        || input.starts_with("https://gitlab.com")
243        || input.starts_with("https://bitbucket.org")
244        || input.starts_with("github:")
245        || input.starts_with("gitlab:")
246        || input.starts_with("bitbucket:")
247        || input.starts_with("git@")
248        || (input.starts_with("https://") && input.ends_with(".git"))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_github_shorthand() {
257        let source = parse_git_url("github:user/my-skill").unwrap();
258        assert_eq!(source.owner, "user");
259        assert_eq!(source.repo, "my-skill");
260        assert_eq!(source.url, "https://github.com/user/my-skill.git");
261        assert_eq!(source.git_ref, GitRef::DefaultBranch);
262    }
263
264    #[test]
265    fn test_github_shorthand_with_tag() {
266        let source = parse_git_url("github:user/my-skill@v1.0.0").unwrap();
267        assert_eq!(source.repo, "my-skill");
268        assert!(matches!(source.git_ref, GitRef::Tag(ref t) if t == "v1.0.0"));
269    }
270
271    #[test]
272    fn test_github_shorthand_with_branch() {
273        let source = parse_git_url("github:user/repo@main").unwrap();
274        assert!(matches!(source.git_ref, GitRef::Branch(ref b) if b == "main"));
275    }
276
277    #[test]
278    fn test_https_url() {
279        let source = parse_git_url("https://github.com/user/repo").unwrap();
280        assert_eq!(source.owner, "user");
281        assert_eq!(source.repo, "repo");
282        assert_eq!(source.url, "https://github.com/user/repo.git");
283    }
284
285    #[test]
286    fn test_https_url_with_git_suffix() {
287        let source = parse_git_url("https://github.com/user/repo.git").unwrap();
288        assert_eq!(source.repo, "repo");
289    }
290
291    #[test]
292    fn test_ssh_url() {
293        let source = parse_git_url("git@github.com:user/repo.git").unwrap();
294        assert_eq!(source.owner, "user");
295        assert_eq!(source.repo, "repo");
296    }
297
298    #[test]
299    fn test_gitlab_shorthand() {
300        let source = parse_git_url("gitlab:org/project").unwrap();
301        assert_eq!(source.url, "https://gitlab.com/org/project.git");
302    }
303
304    #[test]
305    fn test_is_git_url() {
306        assert!(is_git_url("github:user/repo"));
307        assert!(is_git_url("https://github.com/user/repo"));
308        assert!(is_git_url("git@github.com:user/repo.git"));
309        assert!(!is_git_url("./local/path"));
310        assert!(!is_git_url("/absolute/path"));
311        assert!(!is_git_url("my-skill"));
312    }
313
314    #[test]
315    fn test_cache_key() {
316        let source = parse_git_url("github:user/repo@v1.0.0").unwrap();
317        assert_eq!(source.cache_key(), "user/repo");
318    }
319
320    #[test]
321    fn test_display_name() {
322        let source = parse_git_url("github:user/repo").unwrap();
323        assert_eq!(source.display_name(), "user/repo");
324
325        let source_with_tag = parse_git_url("github:user/repo@v1.0.0").unwrap();
326        assert_eq!(source_with_tag.display_name(), "user/repo@v1.0.0");
327    }
328
329    #[test]
330    fn test_commit_sha() {
331        let sha = "abc123def456789012345678901234567890abcd";
332        let source = parse_git_url(&format!("github:user/repo@{}", sha)).unwrap();
333        assert!(matches!(source.git_ref, GitRef::Commit(ref c) if c == sha));
334    }
335}