From dc5b84dc8be68b698a3b9e645c2540f2982647fe Mon Sep 17 00:00:00 2001 From: Chris Lally <24978693+ChrisLally@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:08:44 -0400 Subject: [PATCH] feat: add subpath support for monorepo sparse checkout --- packages/opensrc/cli/src/commands/path.rs | 23 +++- packages/opensrc/cli/src/commands/remove.rs | 29 ++++- packages/opensrc/cli/src/core/git.rs | 104 +++++++++++++++++- .../opensrc/cli/src/core/registries/mod.rs | 5 + .../opensrc/cli/src/core/registries/repo.rs | 80 ++++++++++++-- 5 files changed, 216 insertions(+), 25 deletions(-) diff --git a/packages/opensrc/cli/src/commands/path.rs b/packages/opensrc/cli/src/commands/path.rs index 2179c81..ef7294c 100644 --- a/packages/opensrc/cli/src/commands/path.rs +++ b/packages/opensrc/cli/src/commands/path.rs @@ -106,10 +106,15 @@ fn handle_repo(spec: &str, _cwd: &str, verbose: bool) -> Result<(), Box Result<(), Box format!("Fetching {}/{}/{}...", repo_spec.owner, repo_spec.repo, sp), + None => format!("Fetching {}/{}...", repo_spec.owner, repo_spec.repo), + }, ); let resolved = resolve_repo(&repo_spec)?; + + if repo_spec.git_ref.is_none() && repo_spec.subpath.is_some() { + if let Some(existing) = get_repo_info(&display_with_subpath) { + if existing.version == resolved.git_ref { + let abs = get_absolute_path(&existing.path); + println!("{}", abs.display()); + return Ok(()); + } + } + } + log(verbose, &format!(" → Cloning at {}...", resolved.git_ref)); let result = fetch_repo_source(&resolved); diff --git a/packages/opensrc/cli/src/commands/remove.rs b/packages/opensrc/cli/src/commands/remove.rs index 7c93cc3..4b9b357 100644 --- a/packages/opensrc/cli/src/commands/remove.rs +++ b/packages/opensrc/cli/src/commands/remove.rs @@ -1,5 +1,6 @@ use crate::core::cache::{ - get_package_info, list_sources, remove_package_source, remove_repo_source, write_sources, + get_package_info, get_repo_info, list_sources, remove_package_source, remove_repo_source, + write_sources, }; use crate::core::registries::repo::{is_repo_spec, parse_repo_spec}; use crate::core::registries::{detect_registry, Registry}; @@ -16,20 +17,36 @@ pub fn run(items: &[String]) -> Result<(), Box> { let is_repo = is_repo_spec(item) || (item.contains('/') && !item.contains(':')); if is_repo { - let display_name = match parse_repo_spec(item) { - Some(spec) => format!("{}/{}/{}", spec.host, spec.owner, spec.repo), + let parsed = match parse_repo_spec(item) { + Some(spec) => spec, None => { println!(" ✗ Could not parse repo spec: {item}"); had_errors = true; continue; } }; + let display_name = format!("{}/{}/{}", parsed.host, parsed.owner, parsed.repo); + let sources_key = parsed + .subpath + .as_ref() + .map(|s| format!("{display_name}/{s}")) + .unwrap_or_else(|| display_name.clone()); + + let removal = if let Some(entry) = get_repo_info(&sources_key) { + remove_repo_source(&display_name, Some(&entry.version)) + } else { + remove_repo_source(&display_name, None) + }; - match remove_repo_source(&display_name, None) { + match removal { Ok(true) => { - println!(" ✓ Removed {display_name}"); + if let Some(sp) = &parsed.subpath { + println!(" ✓ Removed {display_name}/{sp}"); + } else { + println!(" ✓ Removed {display_name}"); + } removed += 1; - removed_repos.push(display_name); + removed_repos.push(sources_key); } Ok(false) => { println!(" ⚠ {item} not found"); diff --git a/packages/opensrc/cli/src/core/git.rs b/packages/opensrc/cli/src/core/git.rs index 4a4fca4..40a6f02 100644 --- a/packages/opensrc/cli/src/core/git.rs +++ b/packages/opensrc/cli/src/core/git.rs @@ -91,6 +91,80 @@ fn clone_at_tag(repo_url: &str, target: &Path, version: &str) -> CloneResult { } } +fn clone_at_ref_sparse(repo_url: &str, target: &Path, git_ref: &str, subpath: &str) -> CloneResult { + let target_str = target.to_string_lossy(); + + let output = git_clone_output(&[ + "clone", + "--filter=blob:none", + "--sparse", + "--depth", + "1", + "--branch", + git_ref, + "--single-branch", + repo_url, + &target_str, + ]); + + match output { + Ok(o) if o.status.success() => {} + Ok(o) => { + let _ = fs::remove_dir_all(target); + let stderr = stderr_string(&o); + let msg = if stderr.is_empty() { + "Failed to sparse clone repository".to_string() + } else { + format!("Failed to sparse clone repository: {stderr}") + }; + return CloneResult { + success: false, + error: Some(msg), + }; + } + Err(e) => { + return CloneResult { + success: false, + error: Some(format!("Failed to run git: {e}")), + }; + } + } + + let sparse_out = Command::new("git") + .current_dir(target) + .args(["sparse-checkout", "set", subpath]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .output(); + + match sparse_out { + Ok(o) if o.status.success() => CloneResult { + success: true, + error: None, + }, + Ok(o) => { + let _ = fs::remove_dir_all(target); + let stderr = stderr_string(&o); + let msg = if stderr.is_empty() { + "Failed to run git sparse-checkout".to_string() + } else { + format!("Failed to run git sparse-checkout: {stderr}") + }; + CloneResult { + success: false, + error: Some(msg), + } + } + Err(e) => { + let _ = fs::remove_dir_all(target); + CloneResult { + success: false, + error: Some(format!("Failed to run git: {e}")), + } + } + } +} + fn clone_at_ref(repo_url: &str, target: &Path, git_ref: &str) -> CloneResult { let target_str = target.to_string_lossy(); @@ -224,7 +298,7 @@ pub fn fetch_source(resolved: &ResolvedPackage) -> FetchResult { pub fn fetch_repo_source(resolved: &ResolvedRepo) -> FetchResult { let repo_path = get_repo_path(&resolved.display_name, &resolved.git_ref); - if repo_path.exists() { + if resolved.subpath.is_none() && repo_path.exists() { return FetchResult { package: resolved.display_name.clone(), version: resolved.git_ref.clone(), @@ -235,16 +309,26 @@ pub fn fetch_repo_source(resolved: &ResolvedRepo) -> FetchResult { }; } + if resolved.subpath.is_some() && repo_path.exists() { + let _ = fs::remove_dir_all(&repo_path); + } + if let Some(parent) = repo_path.parent() { let _ = fs::create_dir_all(parent); } let clone_url = authenticated_clone_url(&resolved.repo_url); - let clone = clone_at_ref(&clone_url, &repo_path, &resolved.git_ref); + let clone = match resolved.subpath.as_deref() { + Some(sp) => clone_at_ref_sparse(&clone_url, &repo_path, &resolved.git_ref, sp), + None => clone_at_ref(&clone_url, &repo_path, &resolved.git_ref), + }; if !clone.success { return FetchResult { - package: resolved.display_name.clone(), + package: match &resolved.subpath { + Some(sp) => format!("{}/{}", resolved.display_name, sp), + None => resolved.display_name.clone(), + }, version: resolved.git_ref.clone(), path: get_repo_relative_path(&resolved.display_name, &resolved.git_ref), success: false, @@ -256,9 +340,19 @@ pub fn fetch_repo_source(resolved: &ResolvedRepo) -> FetchResult { remove_git_dir(&repo_path); FetchResult { - package: resolved.display_name.clone(), + package: match &resolved.subpath { + Some(sp) => format!("{}/{}", resolved.display_name, sp), + None => resolved.display_name.clone(), + }, version: resolved.git_ref.clone(), - path: get_repo_relative_path(&resolved.display_name, &resolved.git_ref), + path: match &resolved.subpath { + Some(sp) => format!( + "{}/{}", + get_repo_relative_path(&resolved.display_name, &resolved.git_ref), + sp + ), + None => get_repo_relative_path(&resolved.display_name, &resolved.git_ref), + }, success: true, error: clone.error, registry: None, diff --git a/packages/opensrc/cli/src/core/registries/mod.rs b/packages/opensrc/cli/src/core/registries/mod.rs index b3875a2..673e7d2 100644 --- a/packages/opensrc/cli/src/core/registries/mod.rs +++ b/packages/opensrc/cli/src/core/registries/mod.rs @@ -245,4 +245,9 @@ mod tests { "https://github.com/owner/repo" ); } + + #[test] + fn test_detect_input_type_repo_subpath() { + assert_eq!(detect_input_type("vercel/ai/packages/ai"), "repo"); + } } diff --git a/packages/opensrc/cli/src/core/registries/repo.rs b/packages/opensrc/cli/src/core/registries/repo.rs index 7766ab1..612fca9 100644 --- a/packages/opensrc/cli/src/core/registries/repo.rs +++ b/packages/opensrc/cli/src/core/registries/repo.rs @@ -19,6 +19,7 @@ pub struct RepoSpec { pub owner: String, pub repo: String, pub git_ref: Option, + pub subpath: Option, } #[derive(Debug, Clone)] @@ -26,6 +27,7 @@ pub struct ResolvedRepo { pub git_ref: String, pub repo_url: String, pub display_name: String, + pub subpath: Option, } pub fn parse_repo_spec(spec: &str) -> Option { @@ -68,8 +70,15 @@ pub fn parse_repo_spec(spec: &str) -> Option { repo = repo[..repo.len() - 4].to_string(); } + let mut subpath: Option = None; + if path_parts.len() >= 4 && (path_parts[2] == "tree" || path_parts[2] == "blob") { git_ref = Some(path_parts[3].to_string()); + if path_parts.len() > 4 { + subpath = Some(path_parts[4..].join("/")); + } + } else if path_parts.len() > 2 { + subpath = Some(path_parts[2..].join("/")); } return Some(RepoSpec { @@ -77,6 +86,7 @@ pub fn parse_repo_spec(spec: &str) -> Option { owner, repo, git_ref, + subpath, }); } else if SUPPORTED_HOSTS .iter() @@ -86,8 +96,20 @@ pub fn parse_repo_spec(spec: &str) -> Option { host = remaining[..idx].to_string(); remaining = remaining[idx + 1..].to_string(); } - } else if remaining.starts_with('@') || remaining.split('/').count() != 2 { + } else if remaining.starts_with('@') { return None; + } else { + let path_before_ref = remaining + .split('@') + .next() + .unwrap_or("") + .split('#') + .next() + .unwrap_or(""); + let seg_count = path_before_ref.split('/').filter(|s| !s.is_empty()).count(); + if seg_count < 2 { + return None; + } } // Extract ref from @ or # @@ -103,16 +125,23 @@ pub fn parse_repo_spec(spec: &str) -> Option { } } - let parts: Vec<&str> = remaining.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + let parts: Vec<&str> = remaining.split('/').filter(|s| !s.is_empty()).collect(); + if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { return None; } + let subpath = if parts.len() > 2 { + Some(parts[2..].join("/")) + } else { + None + }; + Some(RepoSpec { host, owner: parts[0].to_string(), repo: parts[1].to_string(), git_ref, + subpath, }) } @@ -141,15 +170,16 @@ pub fn is_repo_spec(spec: &str) -> bool { return false; } - let parts: Vec<&str> = trimmed.split('/').collect(); - if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { - let repo_part = parts[1] - .split('@') - .next() - .unwrap_or("") - .split('#') - .next() - .unwrap_or(""); + let path_part = trimmed + .split('@') + .next() + .unwrap_or("") + .split('#') + .next() + .unwrap_or(""); + let parts: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect(); + if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() { + let repo_part = parts[1]; return RE_OWNER.is_match(parts[0]) && RE_REPO.is_match(repo_part); } @@ -174,6 +204,7 @@ pub fn resolve_repo(spec: &RepoSpec) -> Result Result Result