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    matches.sort();
208    matches.dedup();
209    match matches.len() {
210        0 => {
211            let nearby = list_nearby_capabilities(repo, capability_type)?;
212            let hint = if nearby.is_empty() {
213                String::new()
214            } else {
215                format!("\nAvailable {dir_plural}: {}", nearby.join(", "))
216            };
217            Err(TuffError::new(format!(
218                "{} '{}' not found in repository{hint}",
219                capability_type, name
220            )))
221        }
222        1 => Ok(matches[0].clone()),
223        _ => {
224            let paths: Vec<_> = matches
225                .iter()
226                .map(|p| p.strip_prefix(repo).unwrap_or(p).display().to_string())
227                .collect();
228            Err(TuffError::new(format!(
229                "ambiguous capability name '{}' matches multiple paths: {}",
230                name,
231                paths.join(", ")
232            )))
233        }
234    }
235}
236
237fn list_nearby_capabilities(repo: &Path, capability_type: CapabilityType) -> Result<Vec<String>> {
238    let dir_plural = capability_type.plural_dir();
239    let capabilities_dir = repo.join(dir_plural);
240    if !capabilities_dir.is_dir() {
241        return Ok(Vec::new());
242    }
243
244    let mut names = Vec::new();
245    for entry in std::fs::read_dir(&capabilities_dir)? {
246        let entry = entry?;
247        if entry.file_type()?.is_dir() {
248            let name = entry.file_name().to_string_lossy().to_string();
249            if !name.starts_with('.') {
250                names.push(name);
251            }
252        }
253    }
254    names.sort();
255    Ok(names)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn detect_github_clean_url() {
264        assert!(is_git_url("https://github.com/owner/repo"));
265        assert!(is_git_url("http://github.com/owner/repo"));
266    }
267
268    #[test]
269    fn detect_github_tree_url() {
270        assert!(is_git_url("https://github.com/owner/repo/tree/main/skills"));
271    }
272
273    #[test]
274    fn detect_ssh_url() {
275        assert!(is_git_url("git@github.com:owner/repo.git"));
276    }
277
278    #[test]
279    fn detect_file_url() {
280        assert!(is_git_url("file:///path/to/repo"));
281    }
282
283    #[test]
284    fn reject_local_path() {
285        assert!(!is_git_url("./my-skill"));
286        assert!(!is_git_url("/absolute/path"));
287    }
288
289    #[test]
290    fn clean_github_tree_extracts_repo_and_branch() {
291        let (url, branch) = clean_git_url("https://github.com/owner/repo/tree/main/skills");
292        assert_eq!(url, "https://github.com/owner/repo");
293        assert_eq!(branch, Some("main".to_string()));
294    }
295
296    #[test]
297    fn clean_github_blob_extracts_repo_and_branch() {
298        let (url, branch) = clean_git_url("https://github.com/owner/repo/blob/main/README.md");
299        assert_eq!(url, "https://github.com/owner/repo");
300        assert_eq!(branch, Some("main".to_string()));
301    }
302
303    #[test]
304    fn clean_plain_url_passes_through() {
305        let (url, branch) = clean_git_url("https://github.com/vercel-labs/skills");
306        assert_eq!(url, "https://github.com/vercel-labs/skills");
307        assert_eq!(branch, None);
308    }
309
310    #[test]
311    fn github_folder_url_is_normalized_and_preserves_subdirectory() {
312        let (url, branch) = clean_git_url(
313            "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger",
314        );
315        assert_eq!(url, "https://github.com/am-will/codex-skills");
316        assert_eq!(branch, None);
317        assert_eq!(
318            source_subdirectory(
319                "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger"
320            ),
321            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
322        );
323    }
324
325    #[test]
326    fn github_tree_url_preserves_branch_and_subdirectory() {
327        assert_eq!(
328            source_subdirectory(
329                "https://github.com/am-will/codex-skills/tree/main/hooks/aitmpl-codex/automation/change-logger"
330            ),
331            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
332        );
333    }
334
335    #[test]
336    fn clean_gitlab_tree_extracts_repo_and_branch() {
337        let (url, branch) = clean_git_url("https://gitlab.com/owner/repo/-/tree/main/src");
338        assert_eq!(url, "https://gitlab.com/owner/repo");
339        assert_eq!(branch, Some("main".to_string()));
340    }
341
342    #[test]
343    fn clean_file_url_passes_through() {
344        let (url, branch) = clean_git_url("file:///path/to/repo");
345        assert_eq!(url, "file:///path/to/repo");
346        assert_eq!(branch, None);
347    }
348}