Skip to content

Commit 675dc69

Browse files
killagu-clawclaude
andcommitted
feat(pm): internalize git dependency resolution into ruborist
Move git clone & resolution logic into `utoo-ruborist` behind a `native-git` Cargo feature flag. Git specs (git+https://, github:, etc.) are now resolved on-the-fly during BFS traversal via `resolve_non_registry_dep`, so transitive git dependencies work. Key changes ----------- - Add gix/tempfile as optional deps under `native-git` feature - Add `resolve_non_registry_dep` with dual cfg (enabled / noop error) - Route `is_non_registry_spec` edges through git resolver in BFS - Add `PackageSpec` enum and `parse_cli_spec` for typed spec parsing - Add shared `git_cache_path` helper to deduplicate SHA-prefix cache - Extract `format_save_spec` for clean version-to-write logic - Extract `resolve_cache_path` / `is_git_url` / `git_cache_lookup` so download_to_cache only handles registry tarballs - PM enables `utoo-ruborist/native-git` instead of depending on gix - `BuildDepsOptions<G, R>` stays at 2 generics; no git types leak to PM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7589869 commit 675dc69

File tree

21 files changed

+1822
-84
lines changed

21 files changed

+1822
-84
lines changed

Cargo.lock

Lines changed: 833 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ toml = { workspace = true }
5252
tracing = { workspace = true }
5353
tracing-appender = "0.2"
5454
tracing-subscriber = { workspace = true }
55-
utoo-ruborist = { path = "../ruborist" }
55+
utoo-ruborist = { path = "../ruborist", features = ["native-git"] }
5656

5757
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
5858
tokio = { workspace = true, features = ["full"] }

crates/pm/src/helper/lock.rs

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ use std::path::{Path, PathBuf};
55

66
use super::ruborist_context::Context;
77
use crate::helper::workspace::find_workspaces;
8+
use crate::util::git_resolver::{resolve_git_spec, resolve_github_spec};
89
use crate::util::json::{load_package_json_from_path, load_package_lock_json_from_path};
910
use crate::util::logger::{finish_progress_bar, start_progress_bar};
1011
use crate::util::save_type::{PackageAction, SaveType};
1112
use crate::util::user_config::get_legacy_peer_deps;
12-
use crate::util::{cloner::clone_package, downloader::download_to_cache};
13+
use crate::util::{cloner::clone_package, downloader::resolve_cache_path};
1314
use utoo_ruborist::lock::{LockPackage, PackageLock};
1415
use utoo_ruborist::manifest::PackageJson;
1516
use utoo_ruborist::registry::resolve_package;
16-
use utoo_ruborist::util::parse_package_spec;
17+
use utoo_ruborist::spec::{PackageSpec, parse_cli_spec};
1718

1819
use super::workspace::find_workspace_path;
1920

@@ -168,13 +169,10 @@ pub async fn update_package_json(
168169
for (name, version, version_spec) in package_specs {
169170
match action {
170171
PackageAction::Add => {
171-
let version_to_write = match version_spec {
172-
spec if spec.is_empty() || spec == "*" || spec == "latest" => {
173-
format!("^{version}")
174-
}
175-
spec => spec.to_string(),
176-
};
177-
deps_obj.insert(name, Value::String(version_to_write));
172+
deps_obj.insert(
173+
name,
174+
Value::String(format_save_spec(&version_spec, &version)),
175+
);
178176
}
179177
PackageAction::Remove => {
180178
deps_obj.remove(&name);
@@ -193,12 +191,44 @@ pub async fn update_package_json(
193191
Ok(())
194192
}
195193

194+
/// Format a version spec for writing into package.json.
195+
///
196+
/// Git/non-registry specs are written as-is (e.g. the resolved URL with pinned commit).
197+
/// Wildcard specs (`*`, `latest`, empty) are pinned to `^<resolved_version>`.
198+
/// Everything else (semver ranges, exact versions) passes through unchanged.
199+
fn format_save_spec(version_spec: &str, resolved_version: &str) -> String {
200+
if version_spec.starts_with("git+") || version_spec.starts_with("git://") {
201+
return version_spec.to_string();
202+
}
203+
match version_spec {
204+
"" | "*" | "latest" => format!("^{resolved_version}"),
205+
_ => version_spec.to_string(),
206+
}
207+
}
208+
196209
pub async fn resolve_package_spec(spec: &str) -> Result<(String, String, String)> {
197-
let (name, version_spec) = parse_package_spec(spec);
198-
let resolved = resolve_package(&Context::registry(), name, version_spec)
199-
.await
200-
.map_err(|e| anyhow!("{}", e))?;
201-
Ok((name.to_string(), resolved.version, version_spec.to_string()))
210+
let parsed = parse_cli_spec(spec);
211+
match parsed {
212+
PackageSpec::Registry { name, version_spec } => {
213+
let resolved = resolve_package(&Context::registry(), &name, &version_spec)
214+
.await
215+
.map_err(|e| anyhow!("{}", e))?;
216+
Ok((name, resolved.version, version_spec))
217+
}
218+
PackageSpec::Git { url, commit_ish } => {
219+
let resolved = resolve_git_spec(&url, commit_ish.as_deref()).await?;
220+
Ok((resolved.name, resolved.version, resolved.resolved_url))
221+
}
222+
PackageSpec::GitHub {
223+
owner,
224+
repo,
225+
commit_ish,
226+
} => {
227+
let resolved = resolve_github_spec(&owner, &repo, commit_ish.as_deref()).await?;
228+
Ok((resolved.name, resolved.version, resolved.resolved_url))
229+
}
230+
_ => Err(anyhow!("Unsupported package spec type: {}", spec)),
231+
}
202232
}
203233

204234
pub async fn prepare_global_package_json(npm_spec: &str, prefix: Option<&str>) -> Result<PathBuf> {
@@ -238,7 +268,7 @@ pub async fn prepare_global_package_json(npm_spec: &str, prefix: Option<&str>) -
238268
.ok_or_else(|| anyhow!("Failed to get tarball URL from manifest"))?;
239269

240270
// Download and extract package to cache
241-
let cache_path = download_to_cache(&name, &resolved.version, tarball_url)
271+
let cache_path = resolve_cache_path(&name, &resolved.version, tarball_url)
242272
.await
243273
.ok_or_else(|| anyhow!("Failed to download package {name}"))?;
244274

crates/pm/src/helper/ruborist_context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ impl Glob for TokioGlob {
3030
// Type aliases to hide concrete Glob type
3131
pub(crate) type GlobImpl = TokioGlob;
3232
pub(crate) type Registry = UnifiedRegistry;
33+
3334
/// Context for ruborist operations.
3435
/// Centralizes Glob and configuration to avoid spreading concrete types.
3536
pub(crate) struct Context;

crates/pm/src/service/pipeline/worker.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::path::PathBuf;
22

33
use crate::util::cloner::{clone_package_once, wait_clone_if_pending};
4-
use crate::util::downloader::download_to_cache;
4+
use crate::util::downloader::{download_to_cache, is_git_url};
55

66
use super::receiver::PipelineChannels;
77

@@ -27,6 +27,17 @@ pub fn start_workers(channels: PipelineChannels, cwd: PathBuf) -> PipelineHandle
2727
let Some(tarball_url) = info.tarball_url else {
2828
continue;
2929
};
30+
// Git packages are cloned & cached during BFS resolution (inside ruborist).
31+
// Skip the download pipeline — the clone worker will pick up the
32+
// pre-resolved cache path via resolve_cache_path.
33+
if is_git_url(&tarball_url) {
34+
tracing::debug!(
35+
"Skipping download for git package: {}@{}",
36+
info.name,
37+
info.version
38+
);
39+
continue;
40+
}
3041
let name = info.name;
3142
let version = info.version;
3243
tokio::spawn(async move {

crates/pm/src/util/cloner.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use once_cell::sync::Lazy;
33
use std::path::{Path, PathBuf};
44
use std::sync::atomic::{AtomicUsize, Ordering};
55

6-
use super::downloader::download_to_cache;
6+
use super::downloader::resolve_cache_path;
77
use super::json::load_package_json_from_path;
88
use super::oncemap::OnceMap;
99
use super::retry::create_retry_strategy;
@@ -48,7 +48,7 @@ pub async fn clone_package_once(
4848

4949
CLONE_CACHE
5050
.get_or_init(key, || async move {
51-
let cache_path = download_to_cache(&name, &version, &tarball_url).await?;
51+
let cache_path = resolve_cache_path(&name, &version, &tarball_url).await?;
5252
clone_package(&cache_path, &target_path, &name, &version)
5353
.await
5454
.inspect_err(|e| {

crates/pm/src/util/downloader.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,49 @@ pub fn download_count() -> usize {
3333
DOWNLOAD_COUNT.load(Ordering::Relaxed)
3434
}
3535

36-
/// Download a package tarball to the global cache directory, returning the cache path.
36+
/// Check whether a tarball URL refers to a git-resolved package.
37+
pub fn is_git_url(tarball_url: &str) -> bool {
38+
tarball_url.starts_with("git+") || tarball_url.starts_with("git://")
39+
}
40+
41+
/// Look up the cache path for a git-resolved package.
42+
///
43+
/// Git packages are cloned during BFS resolution (inside ruborist) and
44+
/// stored under `_git/<sha_prefix>/package/`. This function simply
45+
/// computes that path and verifies it exists on disk.
46+
pub async fn git_cache_lookup(name: &str, version: &str, tarball_url: &str) -> Option<PathBuf> {
47+
let cache_dir = get_cache_dir();
48+
if let Some(cache_path) = utoo_ruborist::git::git_cache_path(&cache_dir, tarball_url)
49+
&& crate::fs::try_exists(&cache_path).await.unwrap_or(false)
50+
{
51+
tracing::debug!("Git package cache hit: {}@{}", name, version);
52+
return Some(cache_path);
53+
}
54+
tracing::warn!(
55+
"Git package {}@{} not found in cache, expected pre-resolution",
56+
name,
57+
version
58+
);
59+
None
60+
}
61+
62+
/// Resolve the local cache path for a package, downloading if necessary.
63+
///
64+
/// Routes git URLs to [`git_cache_lookup`] and registry tarballs to
65+
/// [`download_to_cache`].
66+
pub async fn resolve_cache_path(name: &str, version: &str, tarball_url: &str) -> Option<PathBuf> {
67+
if is_git_url(tarball_url) {
68+
return git_cache_lookup(name, version, tarball_url).await;
69+
}
70+
download_to_cache(name, version, tarball_url).await
71+
}
72+
73+
/// Download a registry tarball to the global cache directory, returning the cache path.
3774
///
3875
/// Uses `OnceMap` to deduplicate: the same `name@version` is only downloaded once,
3976
/// even when called concurrently from multiple tasks (pipeline workers, install phase, etc.).
77+
///
78+
/// For git-resolved packages, use [`resolve_cache_path`] instead.
4079
pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> Option<PathBuf> {
4180
let key = format!("{}@{}", name, version);
4281
let cache_dir = get_cache_dir();

crates/pm/src/util/git_resolver.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Git package resolver.
2+
//!
3+
//! Thin wrappers around ruborist's git clone functionality,
4+
//! injecting the PM cache directory.
5+
6+
use anyhow::Result;
7+
8+
pub use utoo_ruborist::git::GitCloneResult;
9+
10+
use super::cache::get_cache_dir;
11+
12+
/// Resolve a git package spec by cloning the repo, checking out the ref,
13+
/// reading package.json, and caching the result.
14+
///
15+
/// # Arguments
16+
/// * `url` - Git URL, e.g. `git+https://github.com/user/repo.git`
17+
/// * `commit_ish` - Optional branch, tag, or commit to check out
18+
pub async fn resolve_git_spec(url: &str, commit_ish: Option<&str>) -> Result<GitCloneResult> {
19+
let cache_dir = get_cache_dir();
20+
utoo_ruborist::git::clone_repo(&cache_dir, url, commit_ish).await
21+
}
22+
23+
/// Convert a `github:owner/repo` shorthand to a git+ URL and resolve.
24+
pub async fn resolve_github_spec(
25+
owner: &str,
26+
repo: &str,
27+
commit_ish: Option<&str>,
28+
) -> Result<GitCloneResult> {
29+
let url = format!("git+https://github.com/{}/{}.git", owner, repo);
30+
resolve_git_spec(&url, commit_ish).await
31+
}
32+
33+
/// Check if a resolved URL is a git URL.
34+
#[allow(dead_code)]
35+
pub fn is_git_resolved(resolved: &str) -> bool {
36+
resolved.starts_with("git+") || resolved.starts_with("git://")
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::*;
42+
43+
#[test]
44+
fn test_is_git_resolved() {
45+
assert!(is_git_resolved(
46+
"git+https://github.com/user/repo.git#abc123"
47+
));
48+
assert!(is_git_resolved(
49+
"git+ssh://git@github.com/user/repo.git#abc123"
50+
));
51+
assert!(is_git_resolved("git://github.com/user/repo.git"));
52+
assert!(!is_git_resolved(
53+
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
54+
));
55+
assert!(!is_git_resolved(""));
56+
}
57+
}

crates/pm/src/util/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod config_file;
55
pub mod downloader;
66
pub mod extractor;
77
pub mod format_print;
8+
pub mod git_resolver;
89
pub mod json;
910
pub mod linker;
1011
pub mod logger;

crates/ruborist/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ tracing = { workspace = true }
1919
# HTTP client - works on both native and WASM
2020
reqwest = { version = "0.12", default-features = false, features = ["json"] }
2121

22+
# Git clone (optional, native-only)
23+
gix = { version = "0.72", default-features = false, features = [
24+
"blocking-http-transport-reqwest-rust-tls",
25+
"blocking-network-client",
26+
], optional = true }
27+
tempfile = { version = "3", optional = true }
28+
29+
[features]
30+
default = []
31+
native-git = ["gix", "tempfile"]
32+
2233
# Async runtime (works on both native and WASM with forked tokio)
2334
[dependencies.tokio]
2435
workspace = true

0 commit comments

Comments
 (0)