Skip to main content

tuff_core/
git.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Command,
4};
5
6use tempfile::TempDir;
7use url::Url;
8
9use crate::error::{Result, TuffError};
10use crate::manifest::CapabilityType;
11
12pub fn is_git_url(s: &str) -> bool {
13    s.starts_with("http://")
14        || s.starts_with("https://")
15        || s.starts_with("git@")
16        || s.starts_with("file://")
17}
18
19fn clean_git_url(raw: &str) -> (String, Option<String>) {
20    let parsed = match Url::parse(raw) {
21        Ok(u) => u,
22        Err(_) => return (raw.to_string(), None),
23    };
24
25    let host = parsed.host_str().unwrap_or("");
26    let path = parsed.path();
27    let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
28
29    if (host == "github.com" || host.ends_with(".github.com"))
30        && segments.len() >= 4
31        && (segments[2] == "tree" || segments[2] == "blob")
32    {
33        let clean = format!("https://{}/{}/{}", host, segments[0], segments[1]);
34        return (clean, Some(segments[3].to_string()));
35    }
36
37    if (host == "github.com" || host.ends_with(".github.com")) && segments.len() > 2 {
38        return (
39            format!("https://{}/{}/{}", host, segments[0], segments[1]),
40            None,
41        );
42    }
43
44    if (host == "gitlab.com" || host.ends_with(".gitlab.com"))
45        && segments.len() >= 5
46        && segments[2] == "-"
47        && (segments[3] == "tree" || segments[3] == "blob")
48    {
49        let clean = format!("https://{}/{}/{}", host, segments[0], segments[1]);
50        return (clean, Some(segments[4].to_string()));
51    }
52
53    if (host == "gitlab.com" || host.ends_with(".gitlab.com")) && segments.len() > 2 {
54        return (
55            format!("https://{}/{}/{}", host, segments[0], segments[1]),
56            None,
57        );
58    }
59
60    (raw.to_string(), None)
61}
62
63/// Returns a repository-relative folder selected by a GitHub/GitLab URL.
64///
65/// A repository URL has no subdirectory. Folder URLs may use either the
66/// provider's normal `/tree/<branch>/...` form or a direct `/...` path.
67pub fn source_subdirectory(raw: &str) -> Option<String> {
68    let parsed = Url::parse(raw).ok()?;
69    let host = parsed.host_str()?;
70    let segments: Vec<&str> = parsed.path().trim_start_matches('/').split('/').collect();
71
72    if host == "github.com" || host.ends_with(".github.com") {
73        if segments.len() >= 5 && (segments[2] == "tree" || segments[2] == "blob") {
74            return Some(segments[4..].join("/"));
75        }
76        if segments.len() > 2 && segments[2] != "tree" && segments[2] != "blob" {
77            return Some(segments[2..].join("/"));
78        }
79    }
80
81    if host == "gitlab.com" || host.ends_with(".gitlab.com") {
82        if segments.len() >= 6
83            && segments[2] == "-"
84            && (segments[3] == "tree" || segments[3] == "blob")
85        {
86            return Some(segments[5..].join("/"));
87        }
88        if segments.len() > 2 && segments[2] != "-" {
89            return Some(segments[2..].join("/"));
90        }
91    }
92
93    None
94}
95
96pub fn clone_to_temp(
97    raw_url: &str,
98    resolved_ref: Option<&str>,
99) -> Result<(TempDir, PathBuf, String)> {
100    let (clean_url, branch) = clean_git_url(raw_url);
101    let temp = TempDir::new()?;
102    let checkout = temp.path().join("source");
103    let mut clone = Command::new("git");
104    clone.args(["clone", "--quiet"]);
105    if resolved_ref.is_none() {
106        clone.args(["--depth", "1"]);
107        if let Some(branch) = branch.as_deref() {
108            clone.args(["--branch", branch]);
109        }
110    }
111    clone.arg(&clean_url).arg(&checkout);
112    run_git(
113        &mut clone,
114        &format!("git clone failed for {clean_url}; is the repo accessible?"),
115    )?;
116    if let Some(reference) = resolved_ref {
117        run_git(
118            Command::new("git")
119                .args(["checkout", "--quiet", "--detach", reference])
120                .current_dir(&checkout),
121            &format!("could not check out recorded ref {reference}"),
122        )?;
123    }
124    Ok((temp, checkout, clean_url))
125}
126
127fn run_git(cmd: &mut Command, context: &str) -> Result<()> {
128    let output = cmd.output()?;
129    if output.status.success() {
130        return Ok(());
131    }
132
133    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
134    if stderr.is_empty() {
135        Err(TuffError::new(context.to_string()))
136    } else {
137        Err(TuffError::new(format!("{context}: {stderr}")))
138    }
139}
140
141pub fn resolve_ref(repo: &Path) -> Result<String> {
142    let output = Command::new("git")
143        .args(["rev-parse", "HEAD"])
144        .current_dir(repo)
145        .output()?;
146
147    if !output.status.success() {
148        return Err(TuffError::new("failed to resolve git ref"));
149    }
150
151    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
152    if sha.is_empty() {
153        return Err(TuffError::new("empty git ref"));
154    }
155    Ok(sha)
156}
157
158pub fn discover_capability(
159    repo: &Path,
160    name: &str,
161    capability_type: CapabilityType,
162) -> Result<PathBuf> {
163    let dir_plural = capability_type.plural_dir(); // "skills", "tools", "hooks", "workflows"
164    let dir_singular = capability_type.as_str(); // "skill", "tool", "hook", "workflow"
165
166    let mut matches = Vec::new();
167
168    // A URL-selected path can be nested arbitrarily deep, so try the exact
169    // repository-relative path before the conventional capability layouts.
170    let direct = repo.join(name);
171    if direct.is_dir() {
172        matches.push(direct);
173    }
174
175    // Pattern 1: <plural>/<name>/ (e.g. skills/security-review/)
176    let p1 = repo.join(dir_plural).join(name);
177    if p1.is_dir() {
178        matches.push(p1);
179    }
180
181    // Pattern 2: <singular>/<name>/ (e.g. skill/security-review/)
182    let p2 = repo.join(dir_singular).join(name);
183    if p2.is_dir() {
184        matches.push(p2);
185    }
186
187    // Pattern 3: <name>/ at root level
188    let p3 = repo.join(name);
189    if p3.is_dir() {
190        matches.push(p3);
191    }
192
193    // Pattern 4: Walk <plural>/ subdirs for <category>/<name>/
194    let plural_dir = repo.join(dir_plural);
195    if plural_dir.is_dir() {
196        for entry in std::fs::read_dir(&plural_dir)? {
197            let entry = entry?;
198            if entry.file_type()?.is_dir() {
199                let candidate = entry.path().join(name);
200                if candidate.is_dir() {
201                    matches.push(candidate);
202                }
203            }
204        }
205    }
206
207    match matches.len() {
208        0 => {
209            let nearby = list_nearby_capabilities(repo, capability_type)?;
210            let hint = if nearby.is_empty() {
211                String::new()
212            } else {
213                format!("\nAvailable {dir_plural}: {}", nearby.join(", "))
214            };
215            Err(TuffError::new(format!(
216                "{} '{}' not found in repository{hint}",
217                capability_type, name
218            )))
219        }
220        1 => Ok(matches[0].clone()),
221        _ => {
222            let paths: Vec<_> = matches
223                .iter()
224                .map(|p| p.strip_prefix(repo).unwrap_or(p).display().to_string())
225                .collect();
226            Err(TuffError::new(format!(
227                "ambiguous capability name '{}' matches multiple paths: {}",
228                name,
229                paths.join(", ")
230            )))
231        }
232    }
233}
234
235fn list_nearby_capabilities(repo: &Path, capability_type: CapabilityType) -> Result<Vec<String>> {
236    let dir_plural = capability_type.plural_dir();
237    let capabilities_dir = repo.join(dir_plural);
238    if !capabilities_dir.is_dir() {
239        return Ok(Vec::new());
240    }
241
242    let mut names = Vec::new();
243    for entry in std::fs::read_dir(&capabilities_dir)? {
244        let entry = entry?;
245        if entry.file_type()?.is_dir() {
246            let name = entry.file_name().to_string_lossy().to_string();
247            if !name.starts_with('.') {
248                names.push(name);
249            }
250        }
251    }
252    names.sort();
253    Ok(names)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn detect_github_clean_url() {
262        assert!(is_git_url("https://github.com/owner/repo"));
263        assert!(is_git_url("http://github.com/owner/repo"));
264    }
265
266    #[test]
267    fn detect_github_tree_url() {
268        assert!(is_git_url("https://github.com/owner/repo/tree/main/skills"));
269    }
270
271    #[test]
272    fn detect_ssh_url() {
273        assert!(is_git_url("git@github.com:owner/repo.git"));
274    }
275
276    #[test]
277    fn detect_file_url() {
278        assert!(is_git_url("file:///path/to/repo"));
279    }
280
281    #[test]
282    fn reject_local_path() {
283        assert!(!is_git_url("./my-skill"));
284        assert!(!is_git_url("/absolute/path"));
285    }
286
287    #[test]
288    fn clean_github_tree_extracts_repo_and_branch() {
289        let (url, branch) = clean_git_url("https://github.com/owner/repo/tree/main/skills");
290        assert_eq!(url, "https://github.com/owner/repo");
291        assert_eq!(branch, Some("main".to_string()));
292    }
293
294    #[test]
295    fn clean_github_blob_extracts_repo_and_branch() {
296        let (url, branch) = clean_git_url("https://github.com/owner/repo/blob/main/README.md");
297        assert_eq!(url, "https://github.com/owner/repo");
298        assert_eq!(branch, Some("main".to_string()));
299    }
300
301    #[test]
302    fn clean_plain_url_passes_through() {
303        let (url, branch) = clean_git_url("https://github.com/vercel-labs/skills");
304        assert_eq!(url, "https://github.com/vercel-labs/skills");
305        assert_eq!(branch, None);
306    }
307
308    #[test]
309    fn github_folder_url_is_normalized_and_preserves_subdirectory() {
310        let (url, branch) = clean_git_url(
311            "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger",
312        );
313        assert_eq!(url, "https://github.com/am-will/codex-skills");
314        assert_eq!(branch, None);
315        assert_eq!(
316            source_subdirectory(
317                "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger"
318            ),
319            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
320        );
321    }
322
323    #[test]
324    fn github_tree_url_preserves_branch_and_subdirectory() {
325        assert_eq!(
326            source_subdirectory(
327                "https://github.com/am-will/codex-skills/tree/main/hooks/aitmpl-codex/automation/change-logger"
328            ),
329            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
330        );
331    }
332
333    #[test]
334    fn clean_gitlab_tree_extracts_repo_and_branch() {
335        let (url, branch) = clean_git_url("https://gitlab.com/owner/repo/-/tree/main/src");
336        assert_eq!(url, "https://gitlab.com/owner/repo");
337        assert_eq!(branch, Some("main".to_string()));
338    }
339
340    #[test]
341    fn clean_file_url_passes_through() {
342        let (url, branch) = clean_git_url("file:///path/to/repo");
343        assert_eq!(url, "file:///path/to/repo");
344        assert_eq!(branch, None);
345    }
346}