diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index 73ce73e..4210ad1 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -110,20 +110,27 @@ async function fetchRepoInput(spec: string, cwd: string): Promise { } const displayName = `${repoSpec.host}/${repoSpec.owner}/${repoSpec.repo}`; - console.log( - `\nFetching ${repoSpec.owner}/${repoSpec.repo} from ${repoSpec.host}...`, - ); + const displayNameWithSubpath = repoSpec.subpath + ? `${displayName}/${repoSpec.subpath}` + : displayName; + const logTarget = repoSpec.subpath + ? `${repoSpec.owner}/${repoSpec.repo}/${repoSpec.subpath}` + : `${repoSpec.owner}/${repoSpec.repo}`; + console.log(`\nFetching ${logTarget} from ${repoSpec.host}...`); try { // Check if already exists with the same ref if (repoExists(displayName, cwd)) { - const existing = await getRepoInfo(displayName, cwd); + const existing = await getRepoInfo(displayNameWithSubpath, cwd); if (existing && repoSpec.ref && existing.version === repoSpec.ref) { console.log(` ✓ Already up to date (${repoSpec.ref})`); + const relativePath = repoSpec.subpath + ? `${getRepoRelativePath(displayName)}/${repoSpec.subpath}` + : getRepoRelativePath(displayName); return { - package: displayName, + package: displayNameWithSubpath, version: existing.version, - path: getRepoRelativePath(displayName), + path: relativePath, success: true, }; } else if (existing) { @@ -140,7 +147,10 @@ async function fetchRepoInput(spec: string, cwd: string): Promise { console.log(` → Ref: ${resolved.ref}`); // Fetch the source - console.log(` → Cloning at ${resolved.ref}...`); + const cloneMsg = resolved.subpath + ? ` → Sparse cloning ${resolved.subpath} at ${resolved.ref}...` + : ` → Cloning at ${resolved.ref}...`; + console.log(cloneMsg); const result = await fetchRepoSource(resolved, cwd); if (result.success) { @@ -157,7 +167,7 @@ async function fetchRepoInput(spec: string, cwd: string): Promise { const errorMessage = err instanceof Error ? err.message : String(err); console.log(` ✗ Error: ${errorMessage}`); return { - package: displayName, + package: displayNameWithSubpath, version: "", path: "", success: false, diff --git a/src/lib/git.ts b/src/lib/git.ts index 39f1dfe..7533a63 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -255,6 +255,35 @@ async function cloneAtRef( } } +/** + * Clone with sparse checkout - fetches only the specified subpath + */ +async function cloneAtRefSparse( + git: SimpleGit, + repoUrl: string, + targetPath: string, + ref: string, + subpath: string, +): Promise<{ success: boolean; ref?: string; error?: string }> { + try { + await git.clone(repoUrl, targetPath, [ + "--filter=blob:none", + "--sparse", + "--branch", + ref, + "--single-branch", + ]); + const repoGit = simpleGit(targetPath); + await repoGit.raw(["sparse-checkout", "set", subpath]); + return { success: true, ref }; + } catch (err) { + return { + success: false, + error: `Failed to sparse clone: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + /** * Fetch source code for a resolved package */ @@ -364,17 +393,22 @@ export async function fetchRepoSource( await mkdir(parentDir, { recursive: true }); } - // Clone the repository - const cloneResult = await cloneAtRef( - git, - resolved.repoUrl, - repoPath, - resolved.ref, - ); + const cloneResult = resolved.subpath + ? await cloneAtRefSparse( + git, + resolved.repoUrl, + repoPath, + resolved.ref, + resolved.subpath, + ) + : await cloneAtRef(git, resolved.repoUrl, repoPath, resolved.ref); if (!cloneResult.success) { + const displayName = resolved.subpath + ? `${resolved.displayName}/${resolved.subpath}` + : resolved.displayName; return { - package: resolved.displayName, + package: displayName, version: resolved.ref, path: getRepoRelativePath(resolved.displayName), success: false, @@ -388,10 +422,17 @@ export async function fetchRepoSource( await rm(gitDir, { recursive: true, force: true }); } + const displayName = resolved.subpath + ? `${resolved.displayName}/${resolved.subpath}` + : resolved.displayName; + const relativePath = resolved.subpath + ? `${getRepoRelativePath(resolved.displayName)}/${resolved.subpath}` + : getRepoRelativePath(resolved.displayName); + return { - package: resolved.displayName, + package: displayName, version: resolved.ref, - path: getRepoRelativePath(resolved.displayName), + path: relativePath, success: true, error: cloneResult.error, }; diff --git a/src/lib/registries/index.test.ts b/src/lib/registries/index.test.ts index 8035182..7e398ae 100644 --- a/src/lib/registries/index.test.ts +++ b/src/lib/registries/index.test.ts @@ -232,5 +232,9 @@ describe("detectInputType", () => { it("GitLab URL", () => { expect(detectInputType("https://gitlab.com/owner/repo")).toBe("repo"); }); + + it("owner/repo/subpath (monorepo subdirectory)", () => { + expect(detectInputType("vercel/ai/packages/ai")).toBe("repo"); + }); }); }); diff --git a/src/lib/repo.test.ts b/src/lib/repo.test.ts index 41582f2..5ebe3fd 100644 --- a/src/lib/repo.test.ts +++ b/src/lib/repo.test.ts @@ -15,6 +15,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: undefined, + subpath: undefined, }); }); @@ -25,6 +26,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: "v14.0.0", + subpath: undefined, }); }); @@ -35,6 +37,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: "main", + subpath: undefined, }); }); }); @@ -47,6 +50,7 @@ describe("parseRepoSpec", () => { owner: "gitlab-org", repo: "gitlab", ref: undefined, + subpath: undefined, }); }); @@ -57,6 +61,7 @@ describe("parseRepoSpec", () => { owner: "gitlab-org", repo: "gitlab", ref: "v16.0.0", + subpath: undefined, }); }); }); @@ -69,6 +74,7 @@ describe("parseRepoSpec", () => { owner: "atlassian", repo: "python-bitbucket", ref: undefined, + subpath: undefined, }); }); }); @@ -81,6 +87,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "ai", ref: undefined, + subpath: undefined, }); }); @@ -91,6 +98,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "ai", ref: undefined, + subpath: undefined, }); }); @@ -101,6 +109,31 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "ai", ref: "canary", + subpath: undefined, + }); + }); + + it("parses https://github.com/owner/repo/tree/branch/subpath", () => { + const result = parseRepoSpec( + "https://github.com/vercel/ai/tree/main/packages/ai", + ); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: "main", + subpath: "packages/ai", + }); + }); + + it("parses https://github.com/owner/repo with subpath (no tree)", () => { + const result = parseRepoSpec("https://github.com/vercel/ai/packages/ai"); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: undefined, + subpath: "packages/ai", }); }); @@ -111,6 +144,7 @@ describe("parseRepoSpec", () => { owner: "gitlab-org", repo: "gitlab", ref: undefined, + subpath: undefined, }); }); @@ -121,6 +155,7 @@ describe("parseRepoSpec", () => { owner: "owner", repo: "repo", ref: undefined, + subpath: undefined, }); }); }); @@ -133,6 +168,18 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: undefined, + subpath: undefined, + }); + }); + + it("parses github.com/owner/repo/subpath", () => { + const result = parseRepoSpec("github.com/vercel/ai/packages/ai"); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: undefined, + subpath: "packages/ai", }); }); @@ -143,6 +190,7 @@ describe("parseRepoSpec", () => { owner: "gitlab-org", repo: "gitlab", ref: undefined, + subpath: undefined, }); }); }); @@ -155,6 +203,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: undefined, + subpath: undefined, }); }); @@ -165,6 +214,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: "v14.0.0", + subpath: undefined, }); }); @@ -175,6 +225,51 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "ai", ref: "main", + subpath: undefined, + }); + }); + + it("parses owner/repo/subpath (monorepo subdirectory)", () => { + const result = parseRepoSpec("vercel/ai/packages/ai"); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: undefined, + subpath: "packages/ai", + }); + }); + + it("parses owner/repo/subpath with ref", () => { + const result = parseRepoSpec("vercel/ai/packages/ai@main"); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: "main", + subpath: "packages/ai", + }); + }); + + it("parses owner/repo/nested/subpath", () => { + const result = parseRepoSpec("facebook/react/packages/react"); + expect(result).toEqual({ + host: "github.com", + owner: "facebook", + repo: "react", + ref: undefined, + subpath: "packages/react", + }); + }); + + it("parses github:owner/repo/subpath", () => { + const result = parseRepoSpec("github:vercel/ai/packages/ai"); + expect(result).toEqual({ + host: "github.com", + owner: "vercel", + repo: "ai", + ref: undefined, + subpath: "packages/ai", }); }); @@ -185,6 +280,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "next.js", ref: undefined, + subpath: undefined, }); }); @@ -195,6 +291,7 @@ describe("parseRepoSpec", () => { owner: "facebook", repo: "react-native", ref: undefined, + subpath: undefined, }); }); }); @@ -224,6 +321,7 @@ describe("parseRepoSpec", () => { owner: "vercel", repo: "ai", ref: undefined, + subpath: undefined, }); }); }); @@ -269,6 +367,11 @@ describe("isRepoSpec", () => { expect(isRepoSpec("vercel/ai@main")).toBe(true); expect(isRepoSpec("vercel/ai#canary")).toBe(true); }); + + it("owner/repo/subpath (monorepo subdirectory)", () => { + expect(isRepoSpec("vercel/ai/packages/ai")).toBe(true); + expect(isRepoSpec("facebook/react/packages/react")).toBe(true); + }); }); describe("returns false for non-repo specs", () => { diff --git a/src/lib/repo.ts b/src/lib/repo.ts index b4e6f81..05bdf0f 100644 --- a/src/lib/repo.ts +++ b/src/lib/repo.ts @@ -53,15 +53,22 @@ export function parseRepoSpec(spec: string): RepoSpec | null { repo = repo.slice(0, -4); } + let subpath: string | undefined; + // Handle /tree/branch or /blob/branch URLs if ( pathParts.length >= 4 && (pathParts[2] === "tree" || pathParts[2] === "blob") ) { ref = pathParts[3]; + if (pathParts.length > 4) { + subpath = pathParts.slice(4).join("/"); + } + } else if (pathParts.length > 2) { + subpath = pathParts.slice(2).join("/"); } - return { host, owner, repo, ref }; + return { host, owner, repo, ref, subpath }; } catch { return null; } @@ -76,13 +83,13 @@ export function parseRepoSpec(spec: string): RepoSpec | null { else if (input.startsWith("@")) { return null; } - // Must contain exactly one / to be a repo (owner/repo) - else if (input.split("/").length !== 2) { + // Must contain at least owner/repo (2 segments) + else if (input.split("/").length < 2) { return null; } // Extract ref from @ or # suffix - // owner/repo@v1.0.0 or owner/repo#main + // owner/repo@v1.0.0 or owner/repo#main or owner/repo/path@main const atIndex = input.indexOf("@"); const hashIndex = input.indexOf("#"); @@ -94,17 +101,21 @@ export function parseRepoSpec(spec: string): RepoSpec | null { input = input.slice(0, hashIndex); } - // Split into owner/repo + // Split into owner/repo[/subpath] const parts = input.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { + if (parts.length < 2 || !parts[0] || !parts[1]) { return null; } + const subpath = + parts.length > 2 ? parts.slice(2).join("/") : undefined; + return { host, owner: parts[0], repo: parts[1], ref, + subpath, }; } @@ -138,16 +149,14 @@ export function isRepoSpec(spec: string): boolean { return false; } - // owner/repo format (must have exactly one /) - // But need to distinguish from things that aren't repos - const parts = trimmed.split("/"); - if (parts.length === 2 && parts[0] && parts[1]) { - // Extract the repo part (before any @ or #) - const repoPart = parts[1].split("@")[0].split("#")[0]; + // owner/repo or owner/repo/subpath format (2+ segments) + const pathPart = trimmed.split("@")[0].split("#")[0]; + const parts = pathPart.split("/"); + if (parts.length >= 2 && parts[0] && parts[1]) { // Valid usernames and repos: alphanumeric, hyphens, underscores // Repos can also have dots const validOwner = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(parts[0]); - const validRepo = /^[a-zA-Z0-9._-]+$/.test(repoPart); + const validRepo = /^[a-zA-Z0-9._-]+$/.test(parts[1]); return validOwner && validRepo; } @@ -170,12 +179,12 @@ interface GitLabApiResponse { * Resolve a repo spec to full repository information using the appropriate API */ export async function resolveRepo(spec: RepoSpec): Promise { - const { host, owner, repo, ref } = spec; + const { host, owner, repo, ref, subpath } = spec; if (host === "github.com") { - return resolveGitHubRepo(host, owner, repo, ref); + return resolveGitHubRepo(host, owner, repo, ref, subpath); } else if (host === "gitlab.com") { - return resolveGitLabRepo(host, owner, repo, ref); + return resolveGitLabRepo(host, owner, repo, ref, subpath); } else { // For unsupported hosts, assume default branch is "main" return { @@ -185,6 +194,7 @@ export async function resolveRepo(spec: RepoSpec): Promise { ref: ref || "main", repoUrl: `https://${host}/${owner}/${repo}`, displayName: `${host}/${owner}/${repo}`, + subpath, }; } } @@ -194,6 +204,7 @@ async function resolveGitHubRepo( owner: string, repo: string, ref?: string, + subpath?: string, ): Promise { const apiUrl = `https://api.github.com/repos/${owner}/${repo}`; @@ -231,6 +242,7 @@ async function resolveGitHubRepo( ref: resolvedRef, repoUrl: `https://github.com/${owner}/${repo}`, displayName: `${host}/${owner}/${repo}`, + subpath, }; } @@ -239,6 +251,7 @@ async function resolveGitLabRepo( owner: string, repo: string, ref?: string, + subpath?: string, ): Promise { const projectPath = encodeURIComponent(`${owner}/${repo}`); const apiUrl = `https://gitlab.com/api/v4/projects/${projectPath}`; @@ -271,6 +284,7 @@ async function resolveGitLabRepo( ref: resolvedRef, repoUrl: `https://gitlab.com/${owner}/${repo}`, displayName: `${host}/${owner}/${repo}`, + subpath, }; } diff --git a/src/types.ts b/src/types.ts index 8d0a42a..af566b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,6 +60,7 @@ export interface RepoSpec { owner: string; repo: string; ref?: string; // branch, tag, or commit + subpath?: string; // e.g., "packages/ai" for monorepo subdirectory } /** @@ -86,4 +87,5 @@ export interface ResolvedRepo { ref: string; // branch, tag, or commit (resolved) repoUrl: string; displayName: string; // e.g., "github.com/owner/repo" + subpath?: string; // e.g., "packages/ai" for monorepo subdirectory }