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
127/// Clone a repository at one tag, shallowly. The caller reads the commit
128/// the tag names with [`resolve_ref`]; the lockfile pins that, not the tag.
129pub fn clone_tag_to_temp(raw_url: &str, tag: &str) -> Result<(TempDir, PathBuf, String)> {
130    let (clean_url, _) = clean_git_url(raw_url);
131    let temp = TempDir::new()?;
132    let checkout = temp.path().join("source");
133    run_git(
134        Command::new("git")
135            .args(["clone", "--quiet", "--depth", "1", "--branch", tag])
136            .arg(&clean_url)
137            .arg(&checkout),
138        &format!("git clone failed for {clean_url} at tag {tag}; is the repo accessible?"),
139    )?;
140    Ok((temp, checkout, clean_url))
141}
142
143/// One tag a remote publishes and the commit it names right now.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct RemoteTag {
146    pub name: String,
147    /// The commit, not the tag object: an annotated tag is peeled.
148    pub commit: String,
149}
150
151/// The tags a repository publishes, without cloning it, each with the
152/// commit it currently names. That commit is what a repointed tag changes.
153pub fn list_remote_tags(raw_url: &str) -> Result<Vec<RemoteTag>> {
154    let (clean_url, _) = clean_git_url(raw_url);
155    let listing = ls_remote(&clean_url, &["--tags"], &[])?;
156    Ok(parse_ls_remote_tags(&listing))
157}
158
159/// What one `ls-remote` says about a repository: the commit its HEAD names
160/// and every tag it publishes.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct RemoteRefs {
163    /// The commit HEAD names now: the branch a `/tree/<branch>` URL selects,
164    /// otherwise the remote's default branch. `None` when the remote
165    /// advertises no such ref, as for an empty repository or a `/tree/`
166    /// segment that names a commit rather than a branch.
167    pub head: Option<String>,
168    pub tags: Vec<RemoteTag>,
169}
170
171/// HEAD and the tags in one round trip, without cloning. `outdated` uses
172/// this so that "did HEAD move" and "does the repository publish releases"
173/// cost one network call between them rather than a clone plus a listing.
174pub fn list_remote_refs(raw_url: &str) -> Result<RemoteRefs> {
175    let (clean_url, branch) = clean_git_url(raw_url);
176    let head_ref = match branch {
177        Some(branch) => format!("refs/heads/{branch}"),
178        None => "HEAD".to_string(),
179    };
180    let listing = ls_remote(&clean_url, &[], &[&head_ref, "refs/tags/*"])?;
181    let head = listing.lines().find_map(|line| {
182        let (sha, reference) = line.split_once('\t')?;
183        (reference == head_ref).then(|| sha.to_string())
184    });
185    Ok(RemoteRefs {
186        head,
187        tags: parse_ls_remote_tags(&listing),
188    })
189}
190
191/// Run `git ls-remote <flags> <url> <patterns>` and return its stdout.
192fn ls_remote(clean_url: &str, flags: &[&str], patterns: &[&str]) -> Result<String> {
193    let output = Command::new("git")
194        .arg("ls-remote")
195        .args(flags)
196        .arg(clean_url)
197        .args(patterns)
198        .output()?;
199    if !output.status.success() {
200        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
201        let context = format!("git ls-remote failed for {clean_url}; is the repo accessible?");
202        return Err(TuffError::source_failed(if stderr.is_empty() {
203            context
204        } else {
205            format!("{context}: {stderr}")
206        }));
207    }
208    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
209}
210
211/// Read `git ls-remote --tags` output. A lightweight tag is one row naming
212/// the commit. An annotated tag is two: the tag object, then a `^{}` row
213/// naming the commit it points at, which is the one that matters.
214fn parse_ls_remote_tags(output: &str) -> Vec<RemoteTag> {
215    let mut tags: Vec<RemoteTag> = Vec::new();
216    for line in output.lines() {
217        let Some((sha, reference)) = line.split_once('\t') else {
218            continue;
219        };
220        let Some(name) = reference.strip_prefix("refs/tags/") else {
221            continue;
222        };
223        match name.strip_suffix("^{}") {
224            Some(peeled) => {
225                if let Some(tag) = tags.iter_mut().find(|tag| tag.name == peeled) {
226                    tag.commit = sha.to_string();
227                }
228            }
229            None => tags.push(RemoteTag {
230                name: name.to_string(),
231                commit: sha.to_string(),
232            }),
233        }
234    }
235    tags
236}
237
238fn run_git(cmd: &mut Command, context: &str) -> Result<()> {
239    let output = cmd.output()?;
240    if output.status.success() {
241        return Ok(());
242    }
243
244    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
245    if stderr.is_empty() {
246        Err(TuffError::source_failed(context.to_string()))
247    } else {
248        Err(TuffError::source_failed(format!("{context}: {stderr}")))
249    }
250}
251
252pub fn resolve_ref(repo: &Path) -> Result<String> {
253    let output = Command::new("git")
254        .args(["rev-parse", "HEAD"])
255        .current_dir(repo)
256        .output()?;
257
258    if !output.status.success() {
259        return Err(TuffError::source_failed("failed to resolve git ref"));
260    }
261
262    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
263    if sha.is_empty() {
264        return Err(TuffError::source_failed("empty git ref"));
265    }
266    Ok(sha)
267}
268
269pub fn discover_capability(
270    repo: &Path,
271    name: &str,
272    capability_type: CapabilityType,
273) -> Result<PathBuf> {
274    let dir_plural = capability_type.plural_dir(); // "skills", "tools", "hooks", "workflows"
275    let dir_singular = capability_type.as_str(); // "skill", "tool", "hook", "workflow"
276
277    let mut matches = Vec::new();
278
279    // A URL-selected path can be nested arbitrarily deep, so try the exact
280    // repository-relative path before the conventional capability layouts.
281    let direct = repo.join(name);
282    if direct.is_dir() {
283        matches.push(direct);
284    }
285
286    // Pattern 1: <plural>/<name>/ (e.g. skills/security-review/)
287    let p1 = repo.join(dir_plural).join(name);
288    if p1.is_dir() {
289        matches.push(p1);
290    }
291
292    // Pattern 2: <singular>/<name>/ (e.g. skill/security-review/)
293    let p2 = repo.join(dir_singular).join(name);
294    if p2.is_dir() {
295        matches.push(p2);
296    }
297
298    // Pattern 3: <name>/ at root level
299    let p3 = repo.join(name);
300    if p3.is_dir() {
301        matches.push(p3);
302    }
303
304    // Pattern 4: Walk <plural>/ subdirs for <category>/<name>/
305    let plural_dir = repo.join(dir_plural);
306    if plural_dir.is_dir() {
307        for entry in std::fs::read_dir(&plural_dir)? {
308            let entry = entry?;
309            if entry.file_type()?.is_dir() {
310                let candidate = entry.path().join(name);
311                if candidate.is_dir() {
312                    matches.push(candidate);
313                }
314            }
315        }
316    }
317
318    matches.sort();
319    matches.dedup();
320    match matches.len() {
321        0 => {
322            let nearby = list_nearby_capabilities(repo, capability_type)?;
323            let hint = if nearby.is_empty() {
324                String::new()
325            } else {
326                format!("\nAvailable {dir_plural}: {}", nearby.join(", "))
327            };
328            Err(TuffError::not_found(format!(
329                "{} '{}' not found in repository{hint}",
330                capability_type, name
331            )))
332        }
333        1 => Ok(matches[0].clone()),
334        _ => {
335            let paths: Vec<_> = matches
336                .iter()
337                .map(|p| p.strip_prefix(repo).unwrap_or(p).display().to_string())
338                .collect();
339            Err(TuffError::usage(format!(
340                "ambiguous capability name '{}' matches multiple paths: {}",
341                name,
342                paths.join(", ")
343            )))
344        }
345    }
346}
347
348fn list_nearby_capabilities(repo: &Path, capability_type: CapabilityType) -> Result<Vec<String>> {
349    let dir_plural = capability_type.plural_dir();
350    let capabilities_dir = repo.join(dir_plural);
351    if !capabilities_dir.is_dir() {
352        return Ok(Vec::new());
353    }
354
355    let mut names = Vec::new();
356    for entry in std::fs::read_dir(&capabilities_dir)? {
357        let entry = entry?;
358        if entry.file_type()?.is_dir() {
359            let name = entry.file_name().to_string_lossy().to_string();
360            if !name.starts_with('.') {
361                names.push(name);
362            }
363        }
364    }
365    names.sort();
366    Ok(names)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn ls_remote_tags_peel_annotated_tags_to_their_commit() {
375        let output = "aaaa\trefs/tags/v1.0.0\n\
376                      bbbb\trefs/tags/v1.2.0\n\
377                      cccc\trefs/tags/v1.2.0^{}\n\
378                      dddd\trefs/heads/main\n";
379        let tags = parse_ls_remote_tags(output);
380        assert_eq!(
381            tags,
382            vec![
383                RemoteTag {
384                    name: "v1.0.0".into(),
385                    commit: "aaaa".into()
386                },
387                RemoteTag {
388                    name: "v1.2.0".into(),
389                    commit: "cccc".into()
390                },
391            ]
392        );
393    }
394
395    #[test]
396    fn detect_github_clean_url() {
397        assert!(is_git_url("https://github.com/owner/repo"));
398        assert!(is_git_url("http://github.com/owner/repo"));
399    }
400
401    #[test]
402    fn detect_github_tree_url() {
403        assert!(is_git_url("https://github.com/owner/repo/tree/main/skills"));
404    }
405
406    #[test]
407    fn detect_ssh_url() {
408        assert!(is_git_url("git@github.com:owner/repo.git"));
409    }
410
411    #[test]
412    fn detect_file_url() {
413        assert!(is_git_url("file:///path/to/repo"));
414    }
415
416    #[test]
417    fn reject_local_path() {
418        assert!(!is_git_url("./my-skill"));
419        assert!(!is_git_url("/absolute/path"));
420    }
421
422    #[test]
423    fn clean_github_tree_extracts_repo_and_branch() {
424        let (url, branch) = clean_git_url("https://github.com/owner/repo/tree/main/skills");
425        assert_eq!(url, "https://github.com/owner/repo");
426        assert_eq!(branch, Some("main".to_string()));
427    }
428
429    #[test]
430    fn clean_github_blob_extracts_repo_and_branch() {
431        let (url, branch) = clean_git_url("https://github.com/owner/repo/blob/main/README.md");
432        assert_eq!(url, "https://github.com/owner/repo");
433        assert_eq!(branch, Some("main".to_string()));
434    }
435
436    #[test]
437    fn clean_plain_url_passes_through() {
438        let (url, branch) = clean_git_url("https://github.com/vercel-labs/skills");
439        assert_eq!(url, "https://github.com/vercel-labs/skills");
440        assert_eq!(branch, None);
441    }
442
443    #[test]
444    fn github_folder_url_is_normalized_and_preserves_subdirectory() {
445        let (url, branch) = clean_git_url(
446            "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger",
447        );
448        assert_eq!(url, "https://github.com/am-will/codex-skills");
449        assert_eq!(branch, None);
450        assert_eq!(
451            source_subdirectory(
452                "https://github.com/am-will/codex-skills/hooks/aitmpl-codex/automation/change-logger"
453            ),
454            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
455        );
456    }
457
458    #[test]
459    fn github_tree_url_preserves_branch_and_subdirectory() {
460        assert_eq!(
461            source_subdirectory(
462                "https://github.com/am-will/codex-skills/tree/main/hooks/aitmpl-codex/automation/change-logger"
463            ),
464            Some("hooks/aitmpl-codex/automation/change-logger".to_string())
465        );
466    }
467
468    #[test]
469    fn clean_gitlab_tree_extracts_repo_and_branch() {
470        let (url, branch) = clean_git_url("https://gitlab.com/owner/repo/-/tree/main/src");
471        assert_eq!(url, "https://gitlab.com/owner/repo");
472        assert_eq!(branch, Some("main".to_string()));
473    }
474
475    #[test]
476    fn clean_file_url_passes_through() {
477        let (url, branch) = clean_git_url("file:///path/to/repo");
478        assert_eq!(url, "file:///path/to/repo");
479        assert_eq!(branch, None);
480    }
481}