aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-23 11:38:42 +0900
committernsfisis <nsfisis@gmail.com>2026-02-23 11:38:42 +0900
commit0080efea9386d46f65d1862fcb90eb44999d9761 (patch)
treee9f7e17b3f12ff9b09b3df0848fd55e91003cd23 /crates
parenteb1e21c059d83f0af9786e4d3cace80afe8456a2 (diff)
downloadphp-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.tar.gz
php-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.tar.zst
php-mozart-0080efea9386d46f65d1862fcb90eb44999d9761.zip
feat(vcs): add mozart-vcs crate for VCS repository support
Implement VCS driver/downloader infrastructure mirroring Composer's VCS subsystem. Includes drivers for GitHub, GitLab, Bitbucket, Forgejo, Git, Hg, and SVN with API-based metadata resolution, plus source downloaders for Git/Hg/SVN. Integrates into mozart-registry via vcs_bridge module to scan VCS repositories and feed discovered packages into the SAT resolver. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/Cargo.toml1
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/lockfile.rs1
-rw-r--r--crates/mozart-registry/src/resolver.rs38
-rw-r--r--crates/mozart-registry/src/vcs_bridge.rs204
-rw-r--r--crates/mozart-vcs/Cargo.toml20
-rw-r--r--crates/mozart-vcs/src/downloader/git.rs112
-rw-r--r--crates/mozart-vcs/src/downloader/hg.rs71
-rw-r--r--crates/mozart-vcs/src/downloader/mod.rs31
-rw-r--r--crates/mozart-vcs/src/downloader/svn.rs64
-rw-r--r--crates/mozart-vcs/src/driver/bitbucket.rs272
-rw-r--r--crates/mozart-vcs/src/driver/forgejo.rs279
-rw-r--r--crates/mozart-vcs/src/driver/git.rs274
-rw-r--r--crates/mozart-vcs/src/driver/github.rs309
-rw-r--r--crates/mozart-vcs/src/driver/gitlab.rs293
-rw-r--r--crates/mozart-vcs/src/driver/hg.rs201
-rw-r--r--crates/mozart-vcs/src/driver/mod.rs199
-rw-r--r--crates/mozart-vcs/src/driver/svn.rs213
-rw-r--r--crates/mozart-vcs/src/lib.rs5
-rw-r--r--crates/mozart-vcs/src/process.rs142
-rw-r--r--crates/mozart-vcs/src/repository.rs206
-rw-r--r--crates/mozart-vcs/src/util/git.rs202
-rw-r--r--crates/mozart-vcs/src/util/hg.rs30
-rw-r--r--crates/mozart-vcs/src/util/mod.rs3
-rw-r--r--crates/mozart-vcs/src/util/svn.rs91
-rw-r--r--crates/mozart-vcs/tests/git_driver_test.rs272
-rw-r--r--crates/mozart/Cargo.toml1
-rw-r--r--crates/mozart/src/commands/create_project.rs2
-rw-r--r--crates/mozart/src/commands/install.rs99
-rw-r--r--crates/mozart/src/commands/remove.rs6
-rw-r--r--crates/mozart/src/commands/require.rs4
-rw-r--r--crates/mozart/src/commands/update.rs10
32 files changed, 3623 insertions, 33 deletions
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml
index 9d089e5..87d3c69 100644
--- a/crates/mozart-registry/Cargo.toml
+++ b/crates/mozart-registry/Cargo.toml
@@ -8,6 +8,7 @@ mozart-core.workspace = true
mozart-metadata-minifier.workspace = true
mozart-sat-resolver.workspace = true
mozart-semver.workspace = true
+mozart-vcs.workspace = true
anyhow.workspace = true
filetime.workspace = true
flate2.workspace = true
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
index 9fd9aff..4c26c1e 100644
--- a/crates/mozart-registry/src/lib.rs
+++ b/crates/mozart-registry/src/lib.rs
@@ -4,4 +4,5 @@ pub mod installed;
pub mod lockfile;
pub mod packagist;
pub mod resolver;
+pub mod vcs_bridge;
pub mod version;
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
index bfae4ee..8f27fbf 100644
--- a/crates/mozart-registry/src/lockfile.rs
+++ b/crates/mozart-registry/src/lockfile.rs
@@ -1032,6 +1032,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved = resolve(&resolve_request)
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index 898a91c..4930b3a 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -9,7 +9,8 @@ use std::fmt;
use crate::cache::Cache;
use crate::packagist;
-use mozart_core::package::Stability;
+use crate::vcs_bridge;
+use mozart_core::package::{RawRepository, Stability};
use mozart_sat_resolver::{
DefaultPolicy, PoolBuilder, PoolPackageInput, RuleSetGenerator, Solver, make_pool_links,
};
@@ -20,7 +21,7 @@ use mozart_semver::Version;
// ─────────────────────────────────────────────────────────────────────────────
/// Determine the `Stability` of a `Version` from its pre_release string.
-fn version_stability(v: &Version) -> Stability {
+pub(crate) fn version_stability(v: &Version) -> Stability {
match &v.pre_release {
None => Stability::Stable,
Some(pre) => {
@@ -43,7 +44,7 @@ fn version_stability(v: &Version) -> Stability {
/// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1".
/// Returns `None` for dev branches (dev-master, dev-*, *.x-dev).
-fn parse_normalized(normalized: &str) -> Option<Version> {
+pub(crate) fn parse_normalized(normalized: &str) -> Option<Version> {
let s = normalized.trim();
// Reject dev branches
@@ -348,6 +349,9 @@ pub struct ResolveRequest {
/// Temporary version constraint overrides (from --with flag).
/// Maps package name (lowercase) to constraint string.
pub temporary_constraints: HashMap<String, String>,
+ /// VCS repositories from composer.json "repositories" section.
+ /// Used to fetch packages from VCS before falling back to Packagist.
+ pub repositories: Vec<RawRepository>,
}
/// A single package in the resolution output.
@@ -413,6 +417,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
let prefer_lowest = request.prefer_lowest;
let ignore_platform_reqs = request.ignore_platform_reqs;
let ignore_platform_req_list = request.ignore_platform_req_list.clone();
+ let vcs_repositories = request.repositories.clone();
// 2. Build pool, generate rules, and solve on a blocking thread
tokio::task::spawn_blocking(move || -> Result<Vec<ResolvedPackage>, ResolveError> {
@@ -447,12 +452,33 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
builder.add_package(input);
}
+ // Scan VCS repositories and collect packages from them
+ let vcs_repos = &vcs_repositories;
+ let vcs_packages = vcs_bridge::scan_vcs_repositories(vcs_repos);
+ let mut vcs_package_names: HashSet<String> = HashSet::new();
+ for vpkg in &vcs_packages {
+ vcs_package_names.insert(vpkg.name.clone());
+ }
+
+ // Add VCS packages to the pool
+ for vpkg in &vcs_packages {
+ let inputs = vcs_bridge::vcs_to_pool_inputs(vpkg, minimum_stability, &stability_flags);
+ for input in inputs {
+ builder.add_package(input);
+ }
+ }
+
// Seed the builder with packages for root requirements
for name in root_requires.keys() {
if PackageName(name.clone()).is_platform() {
continue; // platform packages already added
}
+ // Skip packages already provided by VCS repositories
+ if vcs_package_names.contains(name) {
+ continue;
+ }
+
// Fetch available versions from Packagist
let versions = handle
.block_on(packagist::fetch_package_versions(name, repo_cache.as_ref()))
@@ -476,6 +502,11 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
continue;
}
+ // Skip packages already provided by VCS repositories
+ if vcs_package_names.contains(&name) {
+ continue;
+ }
+
let versions = match handle.block_on(packagist::fetch_package_versions(
&name,
repo_cache.as_ref(),
@@ -938,6 +969,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let result = resolve(&request).await;
diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs
new file mode 100644
index 0000000..d81cae8
--- /dev/null
+++ b/crates/mozart-registry/src/vcs_bridge.rs
@@ -0,0 +1,204 @@
+//! Bridge between `mozart-vcs` and `mozart-registry`.
+//!
+//! Scans VCS repositories defined in composer.json and converts
+//! discovered package versions into pool inputs for the SAT resolver.
+
+use std::collections::{BTreeMap, HashMap};
+
+use mozart_core::package::{RawRepository, Stability};
+use mozart_sat_resolver::{PoolPackageInput, make_pool_links};
+use mozart_vcs::driver::DriverConfig;
+use mozart_vcs::repository::{VcsPackageVersion, VcsRepository};
+
+use crate::packagist::PackagistVersion;
+use crate::resolver::{parse_normalized, version_stability};
+
+/// Scan all VCS-type repositories and collect package versions.
+///
+/// Non-VCS repos (e.g. "composer", "package") are silently skipped.
+pub fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec<VcsPackageVersion> {
+ let config = DriverConfig::default();
+ let mut all_versions = Vec::new();
+
+ for repo in repositories {
+ let repo_type = repo.repo_type.as_str();
+ match repo_type {
+ "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {}
+ _ => continue,
+ }
+
+ let forced_type = match repo_type {
+ "vcs" => None,
+ other => Some(other),
+ };
+
+ let vcs_repo = VcsRepository::new(repo.url.clone(), forced_type, config.clone());
+
+ match vcs_repo.scan() {
+ Ok(versions) => {
+ all_versions.extend(versions);
+ }
+ Err(e) => {
+ eprintln!("Warning: Failed to scan VCS repository {}: {}", repo.url, e,);
+ }
+ }
+ }
+
+ all_versions
+}
+
+/// Convert a VCS package version to SAT pool inputs.
+pub fn vcs_to_pool_inputs(
+ vpkg: &VcsPackageVersion,
+ minimum_stability: Stability,
+ stability_flags: &HashMap<String, Stability>,
+) -> Vec<PoolPackageInput> {
+ let mut results = Vec::new();
+
+ // Extract dependency links from composer.json
+ let require = extract_dep_map(&vpkg.composer_json, "require");
+ let replace = extract_dep_map(&vpkg.composer_json, "replace");
+ let provide = extract_dep_map(&vpkg.composer_json, "provide");
+ let conflict = extract_dep_map(&vpkg.composer_json, "conflict");
+
+ let input = PoolPackageInput {
+ name: vpkg.name.clone(),
+ version: vpkg.version_normalized.clone(),
+ pretty_version: vpkg.version.clone(),
+ requires: make_pool_links(
+ &vpkg.name,
+ &require
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ replaces: make_pool_links(
+ &vpkg.name,
+ &replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ provides: make_pool_links(
+ &vpkg.name,
+ &provide
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ conflicts: make_pool_links(
+ &vpkg.name,
+ &conflict
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ is_fixed: false,
+ };
+
+ // Apply stability filtering
+ if let Some(v) = parse_normalized(&vpkg.version_normalized) {
+ if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) {
+ results.push(input);
+ }
+ } else {
+ // Dev version: always include (dev stability)
+ let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase());
+ let allowed = pkg_flag.copied().unwrap_or(minimum_stability);
+ if allowed >= Stability::Dev {
+ results.push(input);
+ }
+ }
+
+ results
+}
+
+/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation.
+pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion {
+ PackagistVersion {
+ version: vpkg.version.clone(),
+ version_normalized: vpkg.version_normalized.clone(),
+ require: extract_dep_map(&vpkg.composer_json, "require"),
+ replace: extract_dep_map(&vpkg.composer_json, "replace"),
+ provide: extract_dep_map(&vpkg.composer_json, "provide"),
+ conflict: extract_dep_map(&vpkg.composer_json, "conflict"),
+ dist: vpkg.dist.as_ref().map(|d| crate::packagist::PackagistDist {
+ dist_type: d.dist_type.clone(),
+ url: d.url.clone(),
+ reference: Some(d.reference.clone()),
+ shasum: d.shasum.clone(),
+ }),
+ source: Some(crate::packagist::PackagistSource {
+ source_type: vpkg.source.source_type.clone(),
+ url: vpkg.source.url.clone(),
+ reference: Some(vpkg.source.reference.clone()),
+ }),
+ require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"),
+ suggest: vpkg
+ .composer_json
+ .get("suggest")
+ .and_then(|v| serde_json::from_value(v.clone()).ok()),
+ package_type: vpkg
+ .composer_json
+ .get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string()),
+ autoload: vpkg.composer_json.get("autoload").cloned(),
+ autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(),
+ license: vpkg
+ .composer_json
+ .get("license")
+ .and_then(|v| serde_json::from_value(v.clone()).ok()),
+ description: vpkg
+ .composer_json
+ .get("description")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string()),
+ homepage: vpkg
+ .composer_json
+ .get("homepage")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string()),
+ keywords: vpkg
+ .composer_json
+ .get("keywords")
+ .and_then(|v| serde_json::from_value(v.clone()).ok()),
+ authors: vpkg
+ .composer_json
+ .get("authors")
+ .and_then(|v| serde_json::from_value(v.clone()).ok()),
+ support: vpkg.composer_json.get("support").cloned(),
+ funding: vpkg
+ .composer_json
+ .get("funding")
+ .and_then(|v| serde_json::from_value(v.clone()).ok()),
+ time: vpkg.time.clone(),
+ extra: vpkg.composer_json.get("extra").cloned(),
+ notification_url: None,
+ }
+}
+
+/// Extract a dependency map from composer.json JSON.
+fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap<String, String> {
+ json.get(key)
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ obj.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Stability filter for VCS packages (mirrors resolver logic).
+fn passes_vcs_stability_filter(
+ package_name: &str,
+ version: &mozart_semver::Version,
+ minimum_stability: Stability,
+ stability_flags: &HashMap<String, Stability>,
+) -> bool {
+ let stability = version_stability(version);
+ let pkg_flag = stability_flags.get(&package_name.to_lowercase());
+ let allowed = pkg_flag.copied().unwrap_or(minimum_stability);
+ stability <= allowed
+}
diff --git a/crates/mozart-vcs/Cargo.toml b/crates/mozart-vcs/Cargo.toml
new file mode 100644
index 0000000..61fd57e
--- /dev/null
+++ b/crates/mozart-vcs/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "mozart-vcs"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+mozart-core.workspace = true
+mozart-semver.workspace = true
+anyhow.workspace = true
+base64.workspace = true
+regex.workspace = true
+reqwest.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+sha1.workspace = true
+tokio.workspace = true
+url.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs
new file mode 100644
index 0000000..3bdb9ca
--- /dev/null
+++ b/crates/mozart-vcs/src/downloader/git.rs
@@ -0,0 +1,112 @@
+use std::path::Path;
+
+use anyhow::Result;
+
+use crate::process::ProcessExecutor;
+use crate::util::git::GitUtil;
+
+use super::VcsDownloader;
+
+/// Git downloader using clone/checkout with optional mirror cache.
+///
+/// Corresponds to Composer's `Downloader\GitDownloader`.
+pub struct GitDownloader {
+ git_util: GitUtil,
+}
+
+impl GitDownloader {
+ pub fn new(git_util: GitUtil) -> Self {
+ Self { git_util }
+ }
+}
+
+impl VcsDownloader for GitDownloader {
+ fn download(&self, url: &str, _reference: &str, _target: &Path) -> Result<()> {
+ // Pre-sync the mirror so install can use --reference
+ self.git_util.sync_mirror(url)?;
+ Ok(())
+ }
+
+ fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> {
+ let target_str = target.to_string_lossy();
+ let mirror_path = self.git_util.mirror_path(url);
+
+ if mirror_path.join("HEAD").exists() {
+ // Clone with mirror reference for efficiency
+ let mirror_str = mirror_path.to_string_lossy().to_string();
+ self.git_util.run_command(
+ &[
+ "git",
+ "clone",
+ "--no-checkout",
+ "--dissociate",
+ "--reference",
+ &mirror_str,
+ "--",
+ url,
+ &target_str,
+ ],
+ url,
+ None,
+ )?;
+ } else {
+ self.git_util.run_command(
+ &["git", "clone", "--no-checkout", "--", url, &target_str],
+ url,
+ None,
+ )?;
+ }
+
+ // Checkout the specific reference
+ let process = ProcessExecutor::new();
+ process.execute_checked(&["git", "checkout", reference, "--force"], Some(target))?;
+
+ Ok(())
+ }
+
+ fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> {
+ let process = ProcessExecutor::new();
+
+ // Update remote URL
+ process.execute_checked(
+ &["git", "remote", "set-url", "origin", "--", url],
+ Some(target),
+ )?;
+
+ // Fetch latest
+ self.git_util
+ .run_command(&["git", "fetch", "origin"], url, Some(target))?;
+
+ // Checkout new reference
+ process.execute_checked(&["git", "checkout", new_ref, "--force"], Some(target))?;
+
+ Ok(())
+ }
+
+ fn remove(&self, target: &Path) -> Result<()> {
+ if target.exists() {
+ std::fs::remove_dir_all(target)?;
+ }
+ Ok(())
+ }
+
+ fn local_changes(&self, target: &Path) -> Result<Option<String>> {
+ let process = ProcessExecutor::new();
+ let output = process.execute(&["git", "status", "--porcelain"], Some(target))?;
+ if output.stdout.trim().is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(output.stdout))
+ }
+ }
+
+ fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> {
+ let process = ProcessExecutor::new();
+ let range = format!("{from}..{to}");
+ let output = process.execute(
+ &["git", "log", &range, "--oneline", "--no-decorate"],
+ Some(target),
+ )?;
+ Ok(output.stdout)
+ }
+}
diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs
new file mode 100644
index 0000000..bfffa07
--- /dev/null
+++ b/crates/mozart-vcs/src/downloader/hg.rs
@@ -0,0 +1,71 @@
+use std::path::Path;
+
+use anyhow::Result;
+
+use crate::util::hg::HgUtil;
+
+use super::VcsDownloader;
+
+/// Mercurial downloader using clone/pull/update.
+pub struct HgDownloader {
+ hg_util: HgUtil,
+}
+
+impl HgDownloader {
+ pub fn new(hg_util: HgUtil) -> Self {
+ Self { hg_util }
+ }
+}
+
+impl VcsDownloader for HgDownloader {
+ fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> {
+ Ok(())
+ }
+
+ fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> {
+ let target_str = target.to_string_lossy().to_string();
+ self.hg_util
+ .execute(&["clone", "--", url, &target_str], None)?;
+ self.hg_util
+ .execute(&["update", "-r", reference], Some(target))?;
+ Ok(())
+ }
+
+ fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> {
+ self.hg_util.execute(&["pull", url], Some(target))?;
+ self.hg_util
+ .execute(&["update", "-r", new_ref], Some(target))?;
+ Ok(())
+ }
+
+ fn remove(&self, target: &Path) -> Result<()> {
+ if target.exists() {
+ std::fs::remove_dir_all(target)?;
+ }
+ Ok(())
+ }
+
+ fn local_changes(&self, target: &Path) -> Result<Option<String>> {
+ let output = self.hg_util.execute(&["st"], Some(target))?;
+ if output.stdout.trim().is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(output.stdout))
+ }
+ }
+
+ fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> {
+ let range = format!("{from}:{to}");
+ let output = self.hg_util.execute(
+ &[
+ "log",
+ "-r",
+ &range,
+ "--template",
+ "{rev}:{node|short} {desc|firstline}\\n",
+ ],
+ Some(target),
+ )?;
+ Ok(output.stdout)
+ }
+}
diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs
new file mode 100644
index 0000000..7186348
--- /dev/null
+++ b/crates/mozart-vcs/src/downloader/mod.rs
@@ -0,0 +1,31 @@
+pub mod git;
+pub mod hg;
+pub mod svn;
+
+use std::path::Path;
+
+use anyhow::Result;
+
+/// The VCS downloader interface.
+///
+/// Corresponds to Composer's `VcsDownloader` hierarchy.
+pub trait VcsDownloader {
+ /// Prepare for installation (e.g., sync mirror cache).
+ fn download(&self, url: &str, reference: &str, target: &Path) -> Result<()>;
+
+ /// Install (clone/checkout) the source to the target directory.
+ fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()>;
+
+ /// Update the source at target to a new reference.
+ fn update(&self, url: &str, old_ref: &str, new_ref: &str, target: &Path) -> Result<()>;
+
+ /// Remove the source from the target directory.
+ fn remove(&self, target: &Path) -> Result<()>;
+
+ /// Detect local changes in the working copy.
+ /// Returns `None` if clean, `Some(diff)` if modified.
+ fn local_changes(&self, target: &Path) -> Result<Option<String>>;
+
+ /// Get commit log between two references.
+ fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String>;
+}
diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs
new file mode 100644
index 0000000..5222b06
--- /dev/null
+++ b/crates/mozart-vcs/src/downloader/svn.rs
@@ -0,0 +1,64 @@
+use std::path::Path;
+
+use anyhow::Result;
+
+use crate::util::svn::SvnUtil;
+
+use super::VcsDownloader;
+
+/// SVN downloader using checkout/switch.
+pub struct SvnDownloader {
+ svn_util: SvnUtil,
+}
+
+impl SvnDownloader {
+ pub fn new(svn_util: SvnUtil) -> Self {
+ Self { svn_util }
+ }
+}
+
+impl VcsDownloader for SvnDownloader {
+ fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> {
+ // SVN doesn't need a pre-download step
+ Ok(())
+ }
+
+ fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> {
+ let target_str = target.to_string_lossy().to_string();
+ let svn_url = format!("{url}@{reference}");
+ self.svn_util
+ .execute(&["checkout", &svn_url, &target_str], None)?;
+ Ok(())
+ }
+
+ fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> {
+ let svn_url = format!("{url}@{new_ref}");
+ self.svn_util
+ .execute(&["switch", "--ignore-ancestry", &svn_url], Some(target))?;
+ Ok(())
+ }
+
+ fn remove(&self, target: &Path) -> Result<()> {
+ if target.exists() {
+ std::fs::remove_dir_all(target)?;
+ }
+ Ok(())
+ }
+
+ fn local_changes(&self, target: &Path) -> Result<Option<String>> {
+ let output = self.svn_util.execute(&["status"], Some(target))?;
+ if output.stdout.trim().is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(output.stdout))
+ }
+ }
+
+ fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> {
+ let range = format!("{from}:{to}");
+ let output = self
+ .svn_util
+ .execute(&["log", "-r", &range], Some(target))?;
+ Ok(output.stdout)
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs
new file mode 100644
index 0000000..9a0fc15
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/bitbucket.rs
@@ -0,0 +1,272 @@
+use std::collections::{BTreeMap, HashMap};
+
+use anyhow::{Result, bail};
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+
+use super::git::GitDriver;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// Bitbucket VCS driver using the REST API 2.0.
+pub struct BitbucketDriver {
+ owner: String,
+ repo: String,
+ url: String,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ http_client: Client,
+ config: DriverConfig,
+ api_failed: bool,
+ vcs_type: String, // "git" or "hg"
+}
+
+impl BitbucketDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let (owner, repo) = Self::parse_url(url).unwrap_or_default();
+ Self {
+ owner,
+ repo,
+ url: url.to_string(),
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ git_driver: None,
+ http_client: Client::new(),
+ config,
+ api_failed: false,
+ vcs_type: "git".to_string(),
+ }
+ }
+
+ pub fn supports(url: &str) -> bool {
+ let url_lower = url.to_lowercase();
+ url_lower.contains("bitbucket.org")
+ }
+
+ fn parse_url(url: &str) -> Option<(String, String)> {
+ let re =
+ Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?;
+ let caps = re.captures(url)?;
+ Some((caps[1].to_string(), caps[2].to_string()))
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ format!(
+ "https://api.bitbucket.org/2.0/repositories/{}/{}{}",
+ self.owner, self.repo, path,
+ )
+ }
+
+ fn api_get(&self, path: &str) -> Result<serde_json::Value> {
+ let handle = tokio::runtime::Handle::current();
+ let url = self.api_url(path);
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/json");
+
+ if let Some((key, secret)) = &self.config.bitbucket_oauth {
+ let credentials = format!("{key}:{secret}");
+ req = req.header(AUTHORIZATION, format!("Basic {credentials}"));
+ }
+
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ bail!(
+ "Bitbucket API request to {} failed: {}",
+ url,
+ response.status()
+ );
+ }
+ Ok(handle.block_on(response.json())?)
+ }
+
+ fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
+ let handle = tokio::runtime::Handle::current();
+ let mut items = Vec::new();
+ let mut next_url = Some(self.api_url(path));
+ let mut pages = 0;
+
+ while let Some(url) = next_url {
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/json");
+ if let Some((key, secret)) = &self.config.bitbucket_oauth {
+ req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}"));
+ }
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ break;
+ }
+ let data: serde_json::Value = handle.block_on(response.json())?;
+ if let Some(values) = data["values"].as_array() {
+ items.extend(values.iter().cloned());
+ }
+ next_url = data["next"].as_str().map(|s: &str| s.to_string());
+ pages += 1;
+ if pages > 10 {
+ break;
+ }
+ }
+ Ok(items)
+ }
+
+ fn use_git_fallback(&mut self) -> Result<&mut GitDriver> {
+ if self.git_driver.is_none() {
+ let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo);
+ let mut driver = GitDriver::new(&git_url, self.config.clone());
+ driver.initialize()?;
+ self.git_driver = Some(Box::new(driver));
+ }
+ Ok(self.git_driver.as_mut().unwrap())
+ }
+}
+
+impl VcsDriver for BitbucketDriver {
+ fn initialize(&mut self) -> Result<()> {
+ match self.api_get("") {
+ Ok(data) => {
+ if let Some(scm) = data["scm"].as_str() {
+ self.vcs_type = scm.to_string();
+ }
+ let default_branch = data["mainbranch"]["name"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback()?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let branches = driver.branches()?.clone();
+ self.branches = Some(branches);
+ } else {
+ let items = self.api_get_paginated("/refs/branches?pagelen=100")?;
+ let mut branches = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["target"]["hash"].as_str())
+ {
+ branches.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.branches = Some(branches);
+ }
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let tags = driver.tags()?.clone();
+ self.tags = Some(tags);
+ } else {
+ let items = self.api_get_paginated("/refs/tags?pagelen=100")?;
+ let mut tags = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["target"]["hash"].as_str())
+ {
+ tags.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.tags = Some(tags);
+ }
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+ let content = self.file_content("composer.json", identifier)?;
+ let value = content.and_then(|c| serde_json::from_str(&c).ok());
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ let handle = tokio::runtime::Handle::current();
+ let url = self.api_url(&format!("/src/{identifier}/{file}"));
+ let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1");
+ if let Some((key, secret)) = &self.config.bitbucket_oauth {
+ req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}"));
+ }
+ let response = handle.block_on(req.send())?;
+ if response.status().is_success() {
+ Ok(Some(handle.block_on(response.text())?))
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ match self.api_get(&format!("/commit/{identifier}")) {
+ Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())),
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
+ Ok(Some(DistReference {
+ dist_type: "zip".to_string(),
+ url: format!(
+ "https://bitbucket.org/{}/{}/get/{}.zip",
+ self.owner, self.repo, identifier,
+ ),
+ reference: identifier.to_string(),
+ shasum: None,
+ }))
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: self.vcs_type.clone(),
+ url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ if let Some(driver) = &mut self.git_driver {
+ driver.cleanup()?;
+ }
+ Ok(())
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs
new file mode 100644
index 0000000..f35f9df
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/forgejo.rs
@@ -0,0 +1,279 @@
+use std::collections::{BTreeMap, HashMap};
+
+use anyhow::{Result, bail};
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+
+use super::git::GitDriver;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// Forgejo/Gitea VCS driver using the REST API v1.
+///
+/// Supports self-hosted instances (Codeberg, etc.).
+pub struct ForgejoDriver {
+ owner: String,
+ repo: String,
+ host: String,
+ scheme: String,
+ url: String,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ http_client: Client,
+ config: DriverConfig,
+ api_failed: bool,
+}
+
+impl ForgejoDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default();
+ Self {
+ owner,
+ repo,
+ host,
+ scheme,
+ url: url.to_string(),
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ git_driver: None,
+ http_client: Client::new(),
+ config,
+ api_failed: false,
+ }
+ }
+
+ pub fn supports(url: &str, forgejo_domains: &[String]) -> bool {
+ let url_lower = url.to_lowercase();
+ for domain in forgejo_domains {
+ if url_lower.contains(domain) {
+ return true;
+ }
+ }
+ false
+ }
+
+ fn parse_url(url: &str) -> Option<(String, String, String, String)> {
+ let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$")
+ .ok()?;
+ let caps = re.captures(url)?;
+ Some((
+ caps[2].to_string(),
+ caps[1].to_string(),
+ caps[3].to_string(),
+ caps[4].to_string(),
+ ))
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ format!(
+ "{}://{}/api/v1/repos/{}/{}{}",
+ self.scheme, self.host, self.owner, self.repo, path,
+ )
+ }
+
+ fn api_get(&self, path: &str) -> Result<serde_json::Value> {
+ let handle = tokio::runtime::Handle::current();
+ let url = self.api_url(path);
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/json");
+ if let Some(token) = &self.config.forgejo_token {
+ req = req.header(AUTHORIZATION, format!("token {token}"));
+ }
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ bail!(
+ "Forgejo API request to {} failed: {}",
+ url,
+ response.status()
+ );
+ }
+ Ok(handle.block_on(response.json())?)
+ }
+
+ fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
+ let mut items = Vec::new();
+ let mut page = 1;
+ loop {
+ let sep = if path.contains('?') { "&" } else { "?" };
+ let paged_path = format!("{path}{sep}limit=50&page={page}");
+ let data = self.api_get(&paged_path)?;
+ let batch: Vec<serde_json::Value> = match data {
+ serde_json::Value::Array(arr) => arr,
+ _ => break,
+ };
+ if batch.is_empty() {
+ break;
+ }
+ items.extend(batch);
+ page += 1;
+ if page > 20 {
+ break;
+ }
+ }
+ Ok(items)
+ }
+
+ fn use_git_fallback(&mut self) -> Result<&mut GitDriver> {
+ if self.git_driver.is_none() {
+ let git_url = format!(
+ "{}://{}/{}/{}.git",
+ self.scheme, self.host, self.owner, self.repo
+ );
+ let mut driver = GitDriver::new(&git_url, self.config.clone());
+ driver.initialize()?;
+ self.git_driver = Some(Box::new(driver));
+ }
+ Ok(self.git_driver.as_mut().unwrap())
+ }
+}
+
+impl VcsDriver for ForgejoDriver {
+ fn initialize(&mut self) -> Result<()> {
+ match self.api_get("") {
+ Ok(data) => {
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback()?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let branches = driver.branches()?.clone();
+ self.branches = Some(branches);
+ } else {
+ let items = self.api_get_paginated("/branches")?;
+ let mut branches = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["commit"]["id"].as_str())
+ {
+ branches.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.branches = Some(branches);
+ }
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let tags = driver.tags()?.clone();
+ self.tags = Some(tags);
+ } else {
+ let items = self.api_get_paginated("/tags")?;
+ let mut tags = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) = (
+ item["name"].as_str(),
+ item["id"].as_str().or(item["commit"]["sha"].as_str()),
+ ) {
+ tags.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.tags = Some(tags);
+ }
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+ let content = self.file_content("composer.json", identifier)?;
+ let value = content.and_then(|c| serde_json::from_str(&c).ok());
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ let path = format!("/contents/{}?ref={}", file, identifier);
+ match self.api_get(&path) {
+ Ok(data) => {
+ if let Some(content) = data["content"].as_str() {
+ // Forgejo returns base64-encoded content
+ let decoded = super::github::base64_decode_content(content)?;
+ Ok(Some(decoded))
+ } else {
+ Ok(None)
+ }
+ }
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ match self.api_get(&format!("/git/commits/{identifier}")) {
+ Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())),
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
+ Ok(Some(DistReference {
+ dist_type: "zip".to_string(),
+ url: format!(
+ "{}://{}/{}/{}/archive/{}.zip",
+ self.scheme, self.host, self.owner, self.repo, identifier,
+ ),
+ reference: identifier.to_string(),
+ shasum: None,
+ }))
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "git".to_string(),
+ url: format!(
+ "{}://{}/{}/{}.git",
+ self.scheme, self.host, self.owner, self.repo
+ ),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ if let Some(driver) = &mut self.git_driver {
+ driver.cleanup()?;
+ }
+ Ok(())
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/git.rs b/crates/mozart-vcs/src/driver/git.rs
new file mode 100644
index 0000000..13fc243
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/git.rs
@@ -0,0 +1,274 @@
+use std::collections::{BTreeMap, HashMap};
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+
+use crate::process::ProcessExecutor;
+use crate::util::git::GitUtil;
+
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// Git VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\GitDriver`.
+pub struct GitDriver {
+ url: String,
+ repo_dir: Option<PathBuf>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ git_util: GitUtil,
+ is_local: bool,
+}
+
+impl GitDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let is_local = Self::is_local_path(url);
+ let process = ProcessExecutor::new();
+ let git_util = GitUtil::new(process, config.cache_dir.join("git"));
+ Self {
+ url: url.to_string(),
+ repo_dir: if is_local {
+ Some(PathBuf::from(url))
+ } else {
+ None
+ },
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ git_util,
+ is_local,
+ }
+ }
+
+ /// Check if a URL is supported by the Git driver.
+ pub fn supports(url: &str) -> bool {
+ if Self::is_local_path(url) {
+ return Path::new(url).join(".git").is_dir() || url.ends_with(".git");
+ }
+ url.starts_with("git://")
+ || url.starts_with("git@")
+ || url.ends_with(".git")
+ || url.contains("git.")
+ }
+
+ fn is_local_path(url: &str) -> bool {
+ !url.contains("://") && !url.starts_with("git@") && Path::new(url).exists()
+ }
+
+ fn get_repo_dir(&self) -> Result<&Path> {
+ self.repo_dir
+ .as_deref()
+ .ok_or_else(|| anyhow::anyhow!("GitDriver not initialized"))
+ }
+
+ fn parse_branches(output: &str) -> BTreeMap<String, String> {
+ let mut branches = BTreeMap::new();
+ for line in output.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.contains("HEAD detached") || line.contains("->") {
+ continue;
+ }
+ // Remove leading "* " for current branch
+ let line = line.strip_prefix("* ").unwrap_or(line);
+ // Format: "branch_name commit_hash ..."
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() >= 2 {
+ branches.insert(parts[0].to_string(), parts[1].to_string());
+ }
+ }
+ branches
+ }
+
+ fn parse_tags(output: &str) -> BTreeMap<String, String> {
+ let mut tags = BTreeMap::new();
+ // First pass: collect dereferenced tags (^{})
+ let mut dereferenced = HashMap::new();
+ for line in output.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ // Format: "commit_hash refs/tags/tag_name" or "commit_hash refs/tags/tag_name^{}"
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() >= 2 {
+ let hash = parts[0];
+ let refname = parts[1];
+ if let Some(tag_name) = refname.strip_prefix("refs/tags/")
+ && let Some(tag_name) = tag_name.strip_suffix("^{}")
+ {
+ // Dereferenced tag - this is the actual commit
+ dereferenced.insert(tag_name.to_string(), hash.to_string());
+ }
+ }
+ }
+ // Second pass: collect all tags, preferring dereferenced values
+ for line in output.lines() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() >= 2 {
+ let hash = parts[0];
+ let refname = parts[1];
+ if let Some(tag_name) = refname.strip_prefix("refs/tags/") {
+ if tag_name.ends_with("^{}") {
+ continue; // Skip dereferenced entries themselves
+ }
+ let resolved = dereferenced
+ .get(tag_name)
+ .cloned()
+ .unwrap_or_else(|| hash.to_string());
+ tags.insert(tag_name.to_string(), resolved);
+ }
+ }
+ }
+ tags
+ }
+}
+
+impl VcsDriver for GitDriver {
+ fn initialize(&mut self) -> Result<()> {
+ if self.is_local {
+ // Local repo: use directly (or its .git subdir)
+ let path = Path::new(&self.url);
+ if path.join(".git").is_dir() {
+ self.repo_dir = Some(path.join(".git"));
+ } else {
+ self.repo_dir = Some(path.to_path_buf());
+ }
+ } else {
+ // Remote repo: sync mirror
+ let mirror_dir = self.git_util.sync_mirror(&self.url)?;
+ self.repo_dir = Some(mirror_dir);
+ }
+
+ // Determine root identifier (default branch)
+ let repo_dir = self.repo_dir.clone().unwrap();
+ if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) {
+ self.root_identifier = Some(branch);
+ } else {
+ // Fallback: try common branch names
+ let process = ProcessExecutor::new();
+ for name in &["main", "master"] {
+ let output =
+ process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?;
+ if output.status == 0 {
+ self.root_identifier = Some(name.to_string());
+ break;
+ }
+ }
+ }
+
+ if self.root_identifier.is_none() {
+ self.root_identifier = Some("master".to_string());
+ }
+
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("master")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ let repo_dir = self.get_repo_dir()?.to_path_buf();
+ let process = ProcessExecutor::new();
+ let output = process.execute_checked(
+ &["git", "branch", "--no-color", "--no-abbrev", "-v"],
+ Some(&repo_dir),
+ )?;
+ self.branches = Some(Self::parse_branches(&output.stdout));
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ let repo_dir = self.get_repo_dir()?.to_path_buf();
+ let process = ProcessExecutor::new();
+ let output = process.execute(
+ &["git", "show-ref", "--tags", "--dereference"],
+ Some(&repo_dir),
+ )?;
+ self.tags = Some(if output.status == 0 {
+ Self::parse_tags(&output.stdout)
+ } else {
+ BTreeMap::new()
+ });
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+
+ let content = self.file_content("composer.json", identifier)?;
+ let value = match content {
+ Some(c) => serde_json::from_str(&c).ok(),
+ None => None,
+ };
+
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ let repo_dir = self.get_repo_dir()?;
+ let process = ProcessExecutor::new();
+ let resource = format!("{identifier}:{file}");
+ let output = process.execute(&["git", "show", &resource], Some(repo_dir))?;
+ if output.status == 0 {
+ Ok(Some(output.stdout))
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ let repo_dir = self.get_repo_dir()?;
+ let process = ProcessExecutor::new();
+ let output = process.execute(
+ &["git", "log", "-1", "--format=%aI", identifier],
+ Some(repo_dir),
+ )?;
+ if output.status == 0 {
+ let date = output.stdout.trim().to_string();
+ if date.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(date))
+ }
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn dist(&self, _identifier: &str) -> Result<Option<DistReference>> {
+ // Plain git repos don't provide dist archives
+ Ok(None)
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "git".to_string(),
+ url: self.url.clone(),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs
new file mode 100644
index 0000000..23eaa8a
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/github.rs
@@ -0,0 +1,309 @@
+use std::collections::{BTreeMap, HashMap};
+
+use anyhow::{Result, bail};
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+
+use super::git::GitDriver;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// GitHub VCS driver using the REST API v3.
+///
+/// Falls back to `GitDriver` when API access fails.
+pub struct GitHubDriver {
+ owner: String,
+ repo: String,
+ url: String,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ repo_data: Option<serde_json::Value>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ http_client: Client,
+ config: DriverConfig,
+ api_failed: bool,
+}
+
+impl GitHubDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let (owner, repo) = Self::parse_url(url).unwrap_or_default();
+ Self {
+ owner,
+ repo,
+ url: url.to_string(),
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ repo_data: None,
+ info_cache: HashMap::new(),
+ git_driver: None,
+ http_client: Client::new(),
+ config,
+ api_failed: false,
+ }
+ }
+
+ /// Check if a URL points to GitHub.
+ pub fn supports(url: &str) -> bool {
+ let url_lower = url.to_lowercase();
+ url_lower.contains("github.com")
+ && (url_lower.contains("github.com/") || url_lower.contains("github.com:"))
+ }
+
+ fn parse_url(url: &str) -> Option<(String, String)> {
+ let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?;
+ let caps = re.captures(url)?;
+ Some((caps[1].to_string(), caps[2].to_string()))
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ format!(
+ "https://api.github.com/repos/{}/{}{}",
+ self.owner, self.repo, path
+ )
+ }
+
+ fn api_get(&self, path: &str) -> Result<serde_json::Value> {
+ let handle = tokio::runtime::Handle::current();
+ let url = self.api_url(path);
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/vnd.github.v3+json");
+
+ if let Some(token) = &self.config.github_token {
+ req = req.header(AUTHORIZATION, format!("token {token}"));
+ }
+
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ bail!(
+ "GitHub API request to {} failed with status {}",
+ url,
+ response.status()
+ );
+ }
+ Ok(handle.block_on(response.json())?)
+ }
+
+ fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
+ let handle = tokio::runtime::Handle::current();
+ let mut items = Vec::new();
+ let mut page = 1;
+ loop {
+ let separator = if path.contains('?') { "&" } else { "?" };
+ let url = format!(
+ "https://api.github.com/repos/{}/{}{}{}per_page=100&page={}",
+ self.owner, self.repo, path, separator, page,
+ );
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/vnd.github.v3+json");
+ if let Some(token) = &self.config.github_token {
+ req = req.header(AUTHORIZATION, format!("token {token}"));
+ }
+
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ bail!("GitHub API paginated request failed: {}", response.status());
+ }
+
+ let batch: Vec<serde_json::Value> = handle.block_on(response.json())?;
+ if batch.is_empty() {
+ break;
+ }
+ items.extend(batch);
+ page += 1;
+ // Safety: limit to 10 pages (1000 items)
+ if page > 10 {
+ break;
+ }
+ }
+ Ok(items)
+ }
+
+ fn use_git_fallback(&mut self) -> Result<&mut GitDriver> {
+ if self.git_driver.is_none() {
+ let git_url = format!("https://github.com/{}/{}.git", self.owner, self.repo);
+ let mut driver = GitDriver::new(&git_url, self.config.clone());
+ driver.initialize()?;
+ self.git_driver = Some(Box::new(driver));
+ }
+ Ok(self.git_driver.as_mut().unwrap())
+ }
+}
+
+impl VcsDriver for GitHubDriver {
+ fn initialize(&mut self) -> Result<()> {
+ // Try to fetch repo data from API
+ match self.api_get("") {
+ Ok(data) => {
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ self.repo_data = Some(data);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback()?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let branches = driver.branches()?.clone();
+ self.branches = Some(branches);
+ } else {
+ let items = self.api_get_paginated("/branches")?;
+ let mut branches = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["commit"]["sha"].as_str())
+ {
+ branches.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.branches = Some(branches);
+ }
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let tags = driver.tags()?.clone();
+ self.tags = Some(tags);
+ } else {
+ let items = self.api_get_paginated("/tags")?;
+ let mut tags = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["commit"]["sha"].as_str())
+ {
+ tags.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.tags = Some(tags);
+ }
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+
+ let content = self.file_content("composer.json", identifier)?;
+ let value = match content {
+ Some(c) => serde_json::from_str(&c).ok(),
+ None => None,
+ };
+
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ // Can't use API, would need git fallback
+ // For simplicity, return None (git_driver is mutable)
+ return Ok(None);
+ }
+
+ let path = format!("/contents/{}?ref={}", file, identifier);
+ match self.api_get(&path) {
+ Ok(data) => {
+ if let Some(content) = data["content"].as_str() {
+ // GitHub returns base64-encoded content
+ let decoded = base64_decode_content(content)?;
+ Ok(Some(decoded))
+ } else {
+ Ok(None)
+ }
+ }
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+
+ let path = format!("/commits/{}", identifier);
+ match self.api_get(&path) {
+ Ok(data) => {
+ let date = data["commit"]["committer"]["date"]
+ .as_str()
+ .map(|s| s.to_string());
+ Ok(date)
+ }
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
+ Ok(Some(DistReference {
+ dist_type: "zip".to_string(),
+ url: format!(
+ "https://api.github.com/repos/{}/{}/zipball/{}",
+ self.owner, self.repo, identifier,
+ ),
+ reference: identifier.to_string(),
+ shasum: None,
+ }))
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "git".to_string(),
+ url: format!("https://github.com/{}/{}.git", self.owner, self.repo),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ if let Some(driver) = &mut self.git_driver {
+ driver.cleanup()?;
+ }
+ Ok(())
+ }
+}
+
+/// Decode base64-encoded content from API responses.
+/// Also used by Forgejo driver as `base64_decode_content`.
+pub fn base64_decode_content(input: &str) -> Result<String> {
+ use base64::Engine;
+ let cleaned: Vec<u8> = input
+ .bytes()
+ .filter(|&b| b != b'\n' && b != b'\r')
+ .collect();
+ let decoded = base64::engine::general_purpose::STANDARD
+ .decode(&cleaned)
+ .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?;
+ String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}"))
+}
diff --git a/crates/mozart-vcs/src/driver/gitlab.rs b/crates/mozart-vcs/src/driver/gitlab.rs
new file mode 100644
index 0000000..ed88f27
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/gitlab.rs
@@ -0,0 +1,293 @@
+use std::collections::{BTreeMap, HashMap};
+
+use anyhow::{Result, bail};
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, USER_AGENT};
+
+use super::git::GitDriver;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// GitLab VCS driver using the REST API v4.
+///
+/// Supports self-hosted GitLab instances.
+pub struct GitLabDriver {
+ owner: String,
+ repo: String,
+ host: String,
+ scheme: String,
+ url: String,
+ project_id: Option<String>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ http_client: Client,
+ config: DriverConfig,
+ api_failed: bool,
+}
+
+impl GitLabDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default();
+ Self {
+ owner,
+ repo,
+ host,
+ scheme,
+ url: url.to_string(),
+ project_id: None,
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ git_driver: None,
+ http_client: Client::new(),
+ config,
+ api_failed: false,
+ }
+ }
+
+ pub fn supports(url: &str, gitlab_domains: &[String]) -> bool {
+ let url_lower = url.to_lowercase();
+ for domain in gitlab_domains {
+ if url_lower.contains(domain) {
+ return true;
+ }
+ }
+ false
+ }
+
+ fn parse_url(url: &str) -> Option<(String, String, String, String)> {
+ let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$")
+ .ok()?;
+ let caps = re.captures(url)?;
+ Some((
+ caps[2].to_string(),
+ caps[1].to_string(),
+ caps[3].to_string(),
+ caps[4].to_string(),
+ ))
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ let project_path = format!("{}%2F{}", self.owner, self.repo);
+ let id = self.project_id.as_deref().unwrap_or(&project_path);
+ format!(
+ "{}://{}/api/v4/projects/{}{}",
+ self.scheme, self.host, id, path
+ )
+ }
+
+ fn api_get(&self, path: &str) -> Result<serde_json::Value> {
+ let handle = tokio::runtime::Handle::current();
+ let url = self.api_url(path);
+ let mut req = self
+ .http_client
+ .get(&url)
+ .header(USER_AGENT, "mozart/0.1")
+ .header(ACCEPT, "application/json");
+
+ if let Some(token) = &self.config.gitlab_token {
+ req = req.header("PRIVATE-TOKEN", token.as_str());
+ }
+
+ let response = handle.block_on(req.send())?;
+ if !response.status().is_success() {
+ bail!(
+ "GitLab API request to {} failed with status {}",
+ url,
+ response.status()
+ );
+ }
+ Ok(handle.block_on(response.json())?)
+ }
+
+ fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
+ let mut items = Vec::new();
+ let mut page = 1;
+ loop {
+ let sep = if path.contains('?') { "&" } else { "?" };
+ let paged_path = format!("{path}{sep}per_page=100&page={page}");
+ let data = self.api_get(&paged_path)?;
+ let batch: Vec<serde_json::Value> = match data {
+ serde_json::Value::Array(arr) => arr,
+ _ => break,
+ };
+ if batch.is_empty() {
+ break;
+ }
+ items.extend(batch);
+ page += 1;
+ if page > 10 {
+ break;
+ }
+ }
+ Ok(items)
+ }
+
+ fn use_git_fallback(&mut self) -> Result<&mut GitDriver> {
+ if self.git_driver.is_none() {
+ let git_url = format!(
+ "{}://{}/{}/{}.git",
+ self.scheme, self.host, self.owner, self.repo
+ );
+ let mut driver = GitDriver::new(&git_url, self.config.clone());
+ driver.initialize()?;
+ self.git_driver = Some(Box::new(driver));
+ }
+ Ok(self.git_driver.as_mut().unwrap())
+ }
+}
+
+impl VcsDriver for GitLabDriver {
+ fn initialize(&mut self) -> Result<()> {
+ match self.api_get("") {
+ Ok(data) => {
+ if let Some(id) = data["id"].as_u64() {
+ self.project_id = Some(id.to_string());
+ }
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback()?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let branches = driver.branches()?.clone();
+ self.branches = Some(branches);
+ } else {
+ let items = self.api_get_paginated("/repository/branches")?;
+ let mut branches = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["commit"]["id"].as_str())
+ {
+ branches.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.branches = Some(branches);
+ }
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ if self.api_failed {
+ let driver = self.use_git_fallback()?;
+ let tags = driver.tags()?.clone();
+ self.tags = Some(tags);
+ } else {
+ let items = self.api_get_paginated("/repository/tags")?;
+ let mut tags = BTreeMap::new();
+ for item in items {
+ if let (Some(name), Some(sha)) =
+ (item["name"].as_str(), item["commit"]["id"].as_str())
+ {
+ tags.insert(name.to_string(), sha.to_string());
+ }
+ }
+ self.tags = Some(tags);
+ }
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+ let content = self.file_content("composer.json", identifier)?;
+ let value = content.and_then(|c| serde_json::from_str(&c).ok());
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ let handle = tokio::runtime::Handle::current();
+ let encoded_file = file.replace('/', "%2F");
+ let path = format!("/repository/files/{}/raw?ref={}", encoded_file, identifier);
+ let url = self.api_url(&path);
+ let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1");
+ if let Some(token) = &self.config.gitlab_token {
+ req = req.header("PRIVATE-TOKEN", token.as_str());
+ }
+ let response = handle.block_on(req.send())?;
+ if response.status().is_success() {
+ Ok(Some(handle.block_on(response.text())?))
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ if self.api_failed {
+ return Ok(None);
+ }
+ match self.api_get(&format!("/repository/commits/{identifier}")) {
+ Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())),
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
+ Ok(Some(DistReference {
+ dist_type: "zip".to_string(),
+ url: format!(
+ "{}://{}/api/v4/projects/{}/repository/archive.zip?sha={}",
+ self.scheme,
+ self.host,
+ self.project_id
+ .as_deref()
+ .unwrap_or(&format!("{}%2F{}", self.owner, self.repo)),
+ identifier,
+ ),
+ reference: identifier.to_string(),
+ shasum: None,
+ }))
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "git".to_string(),
+ url: format!(
+ "{}://{}/{}/{}.git",
+ self.scheme, self.host, self.owner, self.repo
+ ),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ if let Some(driver) = &mut self.git_driver {
+ driver.cleanup()?;
+ }
+ Ok(())
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/hg.rs b/crates/mozart-vcs/src/driver/hg.rs
new file mode 100644
index 0000000..7bfb07e
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/hg.rs
@@ -0,0 +1,201 @@
+use std::collections::{BTreeMap, HashMap};
+use std::path::PathBuf;
+
+use anyhow::Result;
+
+use crate::process::ProcessExecutor;
+use crate::util::hg::HgUtil;
+
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// Mercurial VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\HgDriver`.
+pub struct HgDriver {
+ url: String,
+ repo_dir: Option<PathBuf>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ hg_util: HgUtil,
+ config: DriverConfig,
+}
+
+impl HgDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let process = ProcessExecutor::new();
+ Self {
+ url: url.to_string(),
+ repo_dir: None,
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ hg_util: HgUtil::new(process),
+ config,
+ }
+ }
+
+ pub fn supports(url: &str) -> bool {
+ url.starts_with("hg://") || url.contains("hg.") || url.ends_with(".hg")
+ }
+
+ fn get_repo_dir(&self) -> Result<&PathBuf> {
+ self.repo_dir
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("HgDriver not initialized"))
+ }
+}
+
+impl VcsDriver for HgDriver {
+ fn initialize(&mut self) -> Result<()> {
+ let cache_dir = self.config.cache_dir.join("hg");
+ std::fs::create_dir_all(&cache_dir)?;
+ let repo_dir = cache_dir.join(crate::util::git::GitUtil::sanitize_url(&self.url));
+
+ if repo_dir.join(".hg").is_dir() {
+ // Update existing clone
+ self.hg_util.execute(&["pull"], Some(&repo_dir))?;
+ } else {
+ // Clone without checkout
+ let dir_str = repo_dir.to_string_lossy().to_string();
+ self.hg_util
+ .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?;
+ }
+
+ self.repo_dir = Some(repo_dir.clone());
+
+ // Get default branch
+ let output = self.hg_util.execute(
+ &["log", "-r", "default", "--template", "{node|short}"],
+ Some(&repo_dir),
+ );
+ self.root_identifier = match output {
+ Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()),
+ _ => Some("tip".to_string()),
+ };
+
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("default")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ let repo_dir = self.get_repo_dir()?.clone();
+ let mut branches = BTreeMap::new();
+
+ // Named branches
+ let output = self.hg_util.execute(&["branches", "-q"], Some(&repo_dir))?;
+ for name in ProcessExecutor::split_lines(&output.stdout) {
+ let name = name.trim();
+ let rev_output = self.hg_util.execute(
+ &["log", "-r", name, "--template", "{node}"],
+ Some(&repo_dir),
+ )?;
+ branches.insert(name.to_string(), rev_output.stdout.trim().to_string());
+ }
+
+ // Bookmarks
+ let output = self
+ .hg_util
+ .execute_unchecked(&["bookmarks", "-q"], Some(&repo_dir))?;
+ if output.status == 0 {
+ for name in ProcessExecutor::split_lines(&output.stdout) {
+ let name = name.trim();
+ if !branches.contains_key(name) {
+ let rev_output = self.hg_util.execute(
+ &["log", "-r", name, "--template", "{node}"],
+ Some(&repo_dir),
+ )?;
+ branches.insert(name.to_string(), rev_output.stdout.trim().to_string());
+ }
+ }
+ }
+
+ self.branches = Some(branches);
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ let repo_dir = self.get_repo_dir()?.clone();
+ let output = self.hg_util.execute(&["tags", "-q"], Some(&repo_dir))?;
+ let mut tags = BTreeMap::new();
+ for name in ProcessExecutor::split_lines(&output.stdout) {
+ let name = name.trim();
+ if name == "tip" {
+ continue; // Skip the "tip" pseudo-tag
+ }
+ let rev_output = self.hg_util.execute(
+ &["log", "-r", name, "--template", "{node}"],
+ Some(&repo_dir),
+ )?;
+ tags.insert(name.to_string(), rev_output.stdout.trim().to_string());
+ }
+ self.tags = Some(tags);
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+ let content = self.file_content("composer.json", identifier)?;
+ let value = content.and_then(|c| serde_json::from_str(&c).ok());
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ let repo_dir = self.get_repo_dir()?;
+ let output = self
+ .hg_util
+ .execute_unchecked(&["cat", "-r", identifier, "--", file], Some(repo_dir))?;
+ if output.status == 0 {
+ Ok(Some(output.stdout))
+ } else {
+ Ok(None)
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ let repo_dir = self.get_repo_dir()?;
+ let output = self.hg_util.execute(
+ &["log", "-r", identifier, "--template", "{date|isodatesec}"],
+ Some(repo_dir),
+ )?;
+ let date = output.stdout.trim().to_string();
+ if date.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(date))
+ }
+ }
+
+ fn dist(&self, _identifier: &str) -> Result<Option<DistReference>> {
+ Ok(None)
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "hg".to_string(),
+ url: self.url.clone(),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/mod.rs b/crates/mozart-vcs/src/driver/mod.rs
new file mode 100644
index 0000000..f8e26ef
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/mod.rs
@@ -0,0 +1,199 @@
+pub mod bitbucket;
+pub mod forgejo;
+pub mod git;
+pub mod github;
+pub mod gitlab;
+pub mod hg;
+pub mod svn;
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+
+/// Reference to a source distribution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SourceReference {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: String,
+}
+
+/// Reference to a dist (archive) distribution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DistReference {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: String,
+ pub shasum: Option<String>,
+}
+
+/// Configuration passed to VCS drivers.
+#[derive(Debug, Clone)]
+pub struct DriverConfig {
+ /// Path for caching VCS mirrors.
+ pub cache_dir: PathBuf,
+ /// GitHub OAuth token (from `GITHUB_TOKEN` or config).
+ pub github_token: Option<String>,
+ /// GitLab OAuth token.
+ pub gitlab_token: Option<String>,
+ /// Bitbucket OAuth consumer key/secret.
+ pub bitbucket_oauth: Option<(String, String)>,
+ /// Forgejo token.
+ pub forgejo_token: Option<String>,
+ /// Custom GitLab domains (for self-hosted).
+ pub gitlab_domains: Vec<String>,
+ /// Custom Forgejo domains (for self-hosted).
+ pub forgejo_domains: Vec<String>,
+}
+
+impl Default for DriverConfig {
+ fn default() -> Self {
+ Self {
+ cache_dir: PathBuf::from(".cache/mozart/vcs"),
+ github_token: None,
+ gitlab_token: None,
+ bitbucket_oauth: None,
+ forgejo_token: None,
+ gitlab_domains: vec!["gitlab.com".to_string()],
+ forgejo_domains: vec!["codeberg.org".to_string()],
+ }
+ }
+}
+
+/// Type of VCS driver.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DriverType {
+ GitHub,
+ GitLab,
+ Bitbucket,
+ Forgejo,
+ Git,
+ Svn,
+ Hg,
+}
+
+/// The VCS driver interface.
+///
+/// Corresponds to Composer's `VcsDriverInterface`.
+pub trait VcsDriver {
+ /// Initialize the driver (e.g., clone mirror, fetch API metadata).
+ fn initialize(&mut self) -> Result<()>;
+
+ /// The root identifier (default branch/trunk).
+ fn root_identifier(&self) -> &str;
+
+ /// All branches as `name -> commit_hash`.
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>>;
+
+ /// All tags as `name -> commit_hash`.
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>>;
+
+ /// Get composer.json content parsed as JSON for a given identifier.
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>>;
+
+ /// Get raw file content at a given path and identifier.
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>;
+
+ /// Get the change date for a given identifier (ISO 8601).
+ fn change_date(&self, identifier: &str) -> Result<Option<String>>;
+
+ /// Get the dist reference for a given identifier.
+ fn dist(&self, identifier: &str) -> Result<Option<DistReference>>;
+
+ /// Get the source reference for a given identifier.
+ fn source(&self, identifier: &str) -> SourceReference;
+
+ /// The canonical URL of this repository.
+ fn url(&self) -> &str;
+
+ /// Clean up resources (temp dirs, etc.).
+ fn cleanup(&mut self) -> Result<()>;
+}
+
+/// Detect which driver type should handle a given URL.
+///
+/// Priority order matches Composer:
+/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN
+pub fn detect_driver(
+ url: &str,
+ forced_type: Option<&str>,
+ config: &DriverConfig,
+) -> Option<DriverType> {
+ if let Some(t) = forced_type {
+ return match t {
+ "github" => Some(DriverType::GitHub),
+ "gitlab" => Some(DriverType::GitLab),
+ "bitbucket" => Some(DriverType::Bitbucket),
+ "forgejo" => Some(DriverType::Forgejo),
+ "git" => Some(DriverType::Git),
+ "svn" => Some(DriverType::Svn),
+ "hg" | "mercurial" => Some(DriverType::Hg),
+ _ => None,
+ };
+ }
+
+ let url_lower = url.to_lowercase();
+
+ // GitHub
+ if github::GitHubDriver::supports(url) {
+ return Some(DriverType::GitHub);
+ }
+
+ // GitLab
+ if gitlab::GitLabDriver::supports(url, &config.gitlab_domains) {
+ return Some(DriverType::GitLab);
+ }
+
+ // Bitbucket
+ if bitbucket::BitbucketDriver::supports(url) {
+ return Some(DriverType::Bitbucket);
+ }
+
+ // Forgejo
+ if forgejo::ForgejoDriver::supports(url, &config.forgejo_domains) {
+ return Some(DriverType::Forgejo);
+ }
+
+ // Git
+ if git::GitDriver::supports(url) {
+ return Some(DriverType::Git);
+ }
+
+ // Hg
+ if hg::HgDriver::supports(url) {
+ return Some(DriverType::Hg);
+ }
+
+ // SVN
+ if url_lower.contains("svn") || svn::SvnDriver::supports(url) {
+ return Some(DriverType::Svn);
+ }
+
+ // Default to git for generic URLs
+ if url.starts_with("http://") || url.starts_with("https://") {
+ return Some(DriverType::Git);
+ }
+
+ None
+}
+
+/// Create a driver instance for the given URL and type.
+pub fn create_driver(
+ url: &str,
+ driver_type: DriverType,
+ config: DriverConfig,
+) -> Box<dyn VcsDriver> {
+ match driver_type {
+ DriverType::GitHub => Box::new(github::GitHubDriver::new(url, config)),
+ DriverType::GitLab => Box::new(gitlab::GitLabDriver::new(url, config)),
+ DriverType::Bitbucket => Box::new(bitbucket::BitbucketDriver::new(url, config)),
+ DriverType::Forgejo => Box::new(forgejo::ForgejoDriver::new(url, config)),
+ DriverType::Git => Box::new(git::GitDriver::new(url, config)),
+ DriverType::Svn => Box::new(svn::SvnDriver::new(url, config)),
+ DriverType::Hg => Box::new(hg::HgDriver::new(url, config)),
+ }
+}
diff --git a/crates/mozart-vcs/src/driver/svn.rs b/crates/mozart-vcs/src/driver/svn.rs
new file mode 100644
index 0000000..8b47f75
--- /dev/null
+++ b/crates/mozart-vcs/src/driver/svn.rs
@@ -0,0 +1,213 @@
+use std::collections::{BTreeMap, HashMap};
+
+use anyhow::Result;
+use regex::Regex;
+
+use crate::process::ProcessExecutor;
+use crate::util::svn::SvnUtil;
+
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+
+/// SVN VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\SvnDriver`.
+pub struct SvnDriver {
+ url: String,
+ base_url: String,
+ trunk_path: String,
+ branches_path: String,
+ tags_path: String,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: HashMap<String, Option<serde_json::Value>>,
+ svn_util: SvnUtil,
+}
+
+impl SvnDriver {
+ pub fn new(url: &str, _config: DriverConfig) -> Self {
+ let process = ProcessExecutor::new();
+ Self {
+ url: url.to_string(),
+ base_url: url.to_string(),
+ trunk_path: "trunk".to_string(),
+ branches_path: "branches".to_string(),
+ tags_path: "tags".to_string(),
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: HashMap::new(),
+ svn_util: SvnUtil::new(process),
+ }
+ }
+
+ pub fn supports(url: &str) -> bool {
+ url.starts_with("svn://") || url.starts_with("svn+ssh://")
+ }
+
+ fn svn_info(&self, url: &str) -> Result<serde_json::Value> {
+ let output = self.svn_util.execute(&["info", "--xml", url], None)?;
+ // Parse minimal info from XML output
+ let stdout = &output.stdout;
+ let mut info = serde_json::Map::new();
+
+ if let Some(rev) = extract_xml_attr(stdout, "entry", "revision") {
+ info.insert("revision".to_string(), serde_json::Value::String(rev));
+ }
+ if let Some(url_val) = extract_xml_content(stdout, "url") {
+ info.insert("url".to_string(), serde_json::Value::String(url_val));
+ }
+ if let Some(date) = extract_xml_content(stdout, "date") {
+ info.insert("date".to_string(), serde_json::Value::String(date));
+ }
+
+ Ok(serde_json::Value::Object(info))
+ }
+
+ fn svn_ls(&self, url: &str) -> Result<Vec<String>> {
+ let output = self.svn_util.execute(&["ls", url], None)?;
+ Ok(ProcessExecutor::split_lines(&output.stdout)
+ .into_iter()
+ .map(|s| s.trim_end_matches('/').to_string())
+ .collect())
+ }
+}
+
+impl VcsDriver for SvnDriver {
+ fn initialize(&mut self) -> Result<()> {
+ let info = self.svn_info(&self.url)?;
+ if let Some(url) = info["url"].as_str() {
+ self.base_url = url.to_string();
+ }
+ self.root_identifier = info["revision"].as_str().map(|s| s.to_string());
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("HEAD")
+ }
+
+ fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.branches.is_none() {
+ let mut branches = BTreeMap::new();
+
+ // Add trunk
+ let trunk_url = format!("{}/{}", self.base_url, self.trunk_path);
+ if let Ok(info) = self.svn_info(&trunk_url)
+ && let Some(rev) = info["revision"].as_str()
+ {
+ branches.insert("trunk".to_string(), rev.to_string());
+ }
+
+ // List branches directory
+ let branches_url = format!("{}/{}", self.base_url, self.branches_path);
+ if let Ok(items) = self.svn_ls(&branches_url) {
+ for name in items {
+ let branch_url = format!("{}/{}", branches_url, name);
+ if let Ok(info) = self.svn_info(&branch_url)
+ && let Some(rev) = info["revision"].as_str()
+ {
+ branches.insert(name, rev.to_string());
+ }
+ }
+ }
+
+ self.branches = Some(branches);
+ }
+ Ok(self.branches.as_ref().unwrap())
+ }
+
+ fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ if self.tags.is_none() {
+ let mut tags = BTreeMap::new();
+ let tags_url = format!("{}/{}", self.base_url, self.tags_path);
+ if let Ok(items) = self.svn_ls(&tags_url) {
+ for name in items {
+ let tag_url = format!("{}/{}", tags_url, name);
+ if let Ok(info) = self.svn_info(&tag_url)
+ && let Some(rev) = info["revision"].as_str()
+ {
+ tags.insert(name, rev.to_string());
+ }
+ }
+ }
+ self.tags = Some(tags);
+ }
+ Ok(self.tags.as_ref().unwrap())
+ }
+
+ fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>> {
+ if let Some(cached) = self.info_cache.get(identifier) {
+ return Ok(cached.clone());
+ }
+ let content = self.file_content("composer.json", identifier)?;
+ let value = content.and_then(|c| serde_json::from_str(&c).ok());
+ self.info_cache
+ .insert(identifier.to_string(), value.clone());
+ Ok(value)
+ }
+
+ fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ // identifier is either a path (trunk, branches/x, tags/y) or a revision number
+ let url = if identifier.contains('/') || identifier == "trunk" {
+ format!("{}/{}/{}", self.base_url, identifier, file)
+ } else {
+ format!(
+ "{}/{}/{}@{}",
+ self.base_url, self.trunk_path, file, identifier
+ )
+ };
+ let output = self.svn_util.execute(&["cat", &url], None);
+ match output {
+ Ok(o) if !o.stdout.is_empty() => Ok(Some(o.stdout)),
+ _ => Ok(None),
+ }
+ }
+
+ fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ let url = if identifier.contains('/') || identifier == "trunk" {
+ format!("{}/{}", self.base_url, identifier)
+ } else {
+ format!("{}@{}", self.base_url, identifier)
+ };
+ match self.svn_info(&url) {
+ Ok(info) => Ok(info["date"].as_str().map(|s| s.to_string())),
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn dist(&self, _identifier: &str) -> Result<Option<DistReference>> {
+ // SVN doesn't provide dist archives
+ Ok(None)
+ }
+
+ fn source(&self, identifier: &str) -> SourceReference {
+ SourceReference {
+ source_type: "svn".to_string(),
+ url: self.base_url.clone(),
+ reference: identifier.to_string(),
+ }
+ }
+
+ fn url(&self) -> &str {
+ &self.url
+ }
+
+ fn cleanup(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
+
+/// Extract an XML attribute value from a simple XML string.
+fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option<String> {
+ let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#);
+ let re = Regex::new(&pattern).ok()?;
+ re.captures(xml).map(|c| c[1].to_string())
+}
+
+/// Extract text content between XML tags.
+fn extract_xml_content(xml: &str, tag: &str) -> Option<String> {
+ let pattern = format!(r"<{tag}>([^<]*)</{tag}>");
+ let re = Regex::new(&pattern).ok()?;
+ re.captures(xml).map(|c| c[1].to_string())
+}
diff --git a/crates/mozart-vcs/src/lib.rs b/crates/mozart-vcs/src/lib.rs
new file mode 100644
index 0000000..11db58d
--- /dev/null
+++ b/crates/mozart-vcs/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod downloader;
+pub mod driver;
+pub mod process;
+pub mod repository;
+pub mod util;
diff --git a/crates/mozart-vcs/src/process.rs b/crates/mozart-vcs/src/process.rs
new file mode 100644
index 0000000..91741a8
--- /dev/null
+++ b/crates/mozart-vcs/src/process.rs
@@ -0,0 +1,142 @@
+use std::collections::HashMap;
+use std::path::Path;
+use std::process::Command;
+use std::time::{Duration, Instant};
+
+use anyhow::{Result, bail};
+
+/// Output from a process execution.
+#[derive(Debug, Clone)]
+pub struct ProcessOutput {
+ pub status: i32,
+ pub stdout: String,
+ pub stderr: String,
+}
+
+/// Wrapper around `std::process::Command` for executing external programs.
+///
+/// Corresponds to Composer's `ProcessExecutor`.
+pub struct ProcessExecutor {
+ timeout: Option<Duration>,
+ env_overrides: HashMap<String, Option<String>>,
+}
+
+impl Default for ProcessExecutor {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ProcessExecutor {
+ pub fn new() -> Self {
+ Self {
+ timeout: None,
+ env_overrides: HashMap::new(),
+ }
+ }
+
+ pub fn with_timeout(secs: u64) -> Self {
+ Self {
+ timeout: Some(Duration::from_secs(secs)),
+ env_overrides: HashMap::new(),
+ }
+ }
+
+ /// Set an environment variable override for all subsequent executions.
+ pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) {
+ self.env_overrides.insert(key.into(), Some(value.into()));
+ }
+
+ /// Remove an environment variable for all subsequent executions.
+ pub fn remove_env(&mut self, key: impl Into<String>) {
+ self.env_overrides.insert(key.into(), None);
+ }
+
+ /// Execute a command. Does not error on non-zero exit status.
+ pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> {
+ if args.is_empty() {
+ bail!("No command specified");
+ }
+
+ let mut cmd = Command::new(args[0]);
+ if args.len() > 1 {
+ cmd.args(&args[1..]);
+ }
+ if let Some(dir) = cwd {
+ cmd.current_dir(dir);
+ }
+
+ for (key, value) in &self.env_overrides {
+ match value {
+ Some(v) => {
+ cmd.env(key, v);
+ }
+ None => {
+ cmd.env_remove(key);
+ }
+ }
+ }
+
+ if let Some(timeout) = self.timeout {
+ let mut child = cmd
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn()?;
+
+ let start = Instant::now();
+ loop {
+ match child.try_wait()? {
+ Some(status) => {
+ let mut stdout = String::new();
+ let mut stderr = String::new();
+ if let Some(ref mut out) = child.stdout {
+ std::io::Read::read_to_string(out, &mut stdout)?;
+ }
+ if let Some(ref mut err) = child.stderr {
+ std::io::Read::read_to_string(err, &mut stderr)?;
+ }
+ return Ok(ProcessOutput {
+ status: status.code().unwrap_or(-1),
+ stdout,
+ stderr,
+ });
+ }
+ None => {
+ if start.elapsed() > timeout {
+ let _ = child.kill();
+ bail!("Process timed out after {} seconds", timeout.as_secs());
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ }
+ }
+ } else {
+ let output = cmd.output()?;
+ Ok(ProcessOutput {
+ status: output.status.code().unwrap_or(-1),
+ stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+ })
+ }
+ }
+
+ /// Execute a command, returning an error if the exit status is non-zero.
+ pub fn execute_checked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> {
+ let output = self.execute(args, cwd)?;
+ if output.status != 0 {
+ bail!(
+ "Command `{}` failed with exit code {}\nstdout: {}\nstderr: {}",
+ args.join(" "),
+ output.status,
+ output.stdout.trim(),
+ output.stderr.trim(),
+ );
+ }
+ Ok(output)
+ }
+
+ /// Split output into non-empty lines.
+ pub fn split_lines(output: &str) -> Vec<&str> {
+ output.lines().filter(|l| !l.is_empty()).collect()
+ }
+}
diff --git a/crates/mozart-vcs/src/repository.rs b/crates/mozart-vcs/src/repository.rs
new file mode 100644
index 0000000..14f2ceb
--- /dev/null
+++ b/crates/mozart-vcs/src/repository.rs
@@ -0,0 +1,206 @@
+use anyhow::{Result, bail};
+
+use crate::driver::{
+ DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver,
+};
+
+/// A single package version discovered from a VCS repository.
+#[derive(Debug, Clone)]
+pub struct VcsPackageVersion {
+ /// Package name (from composer.json).
+ pub name: String,
+ /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches).
+ pub version: String,
+ /// Normalized version for comparison.
+ pub version_normalized: String,
+ /// Full composer.json data as JSON.
+ pub composer_json: serde_json::Value,
+ /// Source reference (VCS checkout info).
+ pub source: SourceReference,
+ /// Dist reference (archive download, if available).
+ pub dist: Option<DistReference>,
+ /// Whether this is the default branch version.
+ pub is_default_branch: bool,
+ /// Release date (ISO 8601).
+ pub time: Option<String>,
+}
+
+/// Repository that scans a VCS URL for package versions.
+///
+/// Corresponds to Composer's `Repository\VcsRepository`.
+pub struct VcsRepository {
+ url: String,
+ driver_type: Option<DriverType>,
+ config: DriverConfig,
+}
+
+impl VcsRepository {
+ pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self {
+ let driver_type = detect_driver(&url, repo_type, &config);
+ Self {
+ url,
+ driver_type,
+ config,
+ }
+ }
+
+ /// Scan the VCS repository for all package versions.
+ ///
+ /// 1. Detects the driver type and initializes it
+ /// 2. Reads composer.json from the root to get the package name
+ /// 3. Scans tags → version releases
+ /// 4. Scans branches → dev versions
+ pub fn scan(&self) -> Result<Vec<VcsPackageVersion>> {
+ let driver_type = self
+ .driver_type
+ .ok_or_else(|| anyhow::anyhow!("No suitable VCS driver found for URL: {}", self.url))?;
+
+ let mut driver = create_driver(&self.url, driver_type, self.config.clone());
+ driver.initialize()?;
+
+ // Get package name from root composer.json
+ let root_id = driver.root_identifier().to_string();
+ let root_info = driver.composer_information(&root_id)?;
+ let package_name = match &root_info {
+ Some(info) => info["name"]
+ .as_str()
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "composer.json at root of {} does not contain a 'name' field",
+ self.url,
+ )
+ })?
+ .to_string(),
+ None => bail!(
+ "No composer.json found at root of {} (ref: {})",
+ self.url,
+ root_id,
+ ),
+ };
+
+ let mut versions = Vec::new();
+
+ // Scan tags
+ let tags = driver.tags()?.clone();
+ for (tag_name, tag_hash) in &tags {
+ if let Some(version) = self.tag_to_version(tag_name) {
+ match driver.composer_information(tag_hash) {
+ Ok(Some(info)) => {
+ let time = driver.change_date(tag_hash).unwrap_or(None);
+ let source = driver.source(tag_hash);
+ let dist = driver.dist(tag_hash).unwrap_or(None);
+
+ // Ensure name matches root package
+ if info["name"].as_str() != Some(&package_name) {
+ continue;
+ }
+
+ let normalized = self.normalize_version(&version);
+
+ versions.push(VcsPackageVersion {
+ name: package_name.clone(),
+ version: version.clone(),
+ version_normalized: normalized,
+ composer_json: info,
+ source,
+ dist,
+ is_default_branch: false,
+ time,
+ });
+ }
+ Ok(None) | Err(_) => continue,
+ }
+ }
+ }
+
+ // Scan branches
+ let branches = driver.branches()?.clone();
+ let default_branch = driver.root_identifier().to_string();
+ for (branch_name, branch_hash) in &branches {
+ match driver.composer_information(branch_hash) {
+ Ok(Some(info)) => {
+ if info["name"].as_str() != Some(&package_name) {
+ continue;
+ }
+
+ let time = driver.change_date(branch_hash).unwrap_or(None);
+ let source = driver.source(branch_hash);
+ let dist = driver.dist(branch_hash).unwrap_or(None);
+ let is_default = branch_name == &default_branch;
+
+ let version = self.branch_to_version(branch_name);
+ let normalized = self.normalize_version(&version);
+
+ // Check for branch-alias
+ let aliased_version = info
+ .get("extra")
+ .and_then(|e| e.get("branch-alias"))
+ .and_then(|ba| ba.get(format!("dev-{branch_name}")))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ versions.push(VcsPackageVersion {
+ name: package_name.clone(),
+ version: aliased_version.unwrap_or(version),
+ version_normalized: normalized,
+ composer_json: info,
+ source,
+ dist,
+ is_default_branch: is_default,
+ time,
+ });
+ }
+ Ok(None) | Err(_) => continue,
+ }
+ }
+
+ driver.cleanup()?;
+ Ok(versions)
+ }
+
+ /// Convert a tag name to a version string.
+ /// Returns `None` if the tag doesn't look like a version.
+ fn tag_to_version(&self, tag: &str) -> Option<String> {
+ // Strip common prefixes
+ let version = tag
+ .strip_prefix('v')
+ .or_else(|| tag.strip_prefix("V"))
+ .or_else(|| tag.strip_prefix("release-"))
+ .or_else(|| tag.strip_prefix("release/"))
+ .unwrap_or(tag);
+
+ // Basic semver-ish check
+ if version.is_empty() {
+ return None;
+ }
+ if version.chars().next()?.is_ascii_digit() {
+ Some(version.to_string())
+ } else {
+ None
+ }
+ }
+
+ /// Convert a branch name to a dev version string.
+ fn branch_to_version(&self, branch: &str) -> String {
+ // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev"
+ if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+ let version = if branch.ends_with(".x") || branch.ends_with(".*") {
+ branch.to_string()
+ } else {
+ format!("{branch}.x")
+ };
+ format!("{version}-dev")
+ } else {
+ format!("dev-{branch}")
+ }
+ }
+
+ /// Normalize a version string.
+ fn normalize_version(&self, version: &str) -> String {
+ // Use mozart-semver for proper normalization if available,
+ // otherwise do a simple normalization
+ mozart_semver::Version::parse(version)
+ .map(|v| v.to_string())
+ .unwrap_or_else(|_| version.to_string())
+ }
+}
diff --git a/crates/mozart-vcs/src/util/git.rs b/crates/mozart-vcs/src/util/git.rs
new file mode 100644
index 0000000..dce13b3
--- /dev/null
+++ b/crates/mozart-vcs/src/util/git.rs
@@ -0,0 +1,202 @@
+use std::path::{Path, PathBuf};
+
+use anyhow::{Result, bail};
+use sha1::{Digest, Sha1};
+
+use crate::process::{ProcessExecutor, ProcessOutput};
+
+/// Git utility for mirror management and protocol fallback.
+///
+/// Corresponds to Composer's `Util\Git`.
+pub struct GitUtil {
+ process: ProcessExecutor,
+ cache_dir: PathBuf,
+}
+
+impl GitUtil {
+ pub fn new(process: ProcessExecutor, cache_dir: PathBuf) -> Self {
+ Self { process, cache_dir }
+ }
+
+ /// Returns environment variable overrides to clean Git state.
+ /// Removes `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` to avoid
+ /// interference from the calling process's Git context.
+ pub fn clean_env() -> Vec<(&'static str, Option<&'static str>)> {
+ vec![
+ ("GIT_DIR", None),
+ ("GIT_WORK_TREE", None),
+ ("GIT_INDEX_FILE", None),
+ ("GIT_TERMINAL_PROMPT", Some("0")),
+ ]
+ }
+
+ /// Synchronize a bare mirror in the cache directory.
+ ///
+ /// On first call, clones a bare mirror. On subsequent calls, updates it.
+ /// Returns the path to the mirror directory.
+ pub fn sync_mirror(&self, url: &str) -> Result<PathBuf> {
+ let mirror_dir = self.mirror_path(url);
+
+ if mirror_dir.join("HEAD").exists() {
+ // Update existing mirror
+ self.run_command(
+ &["git", "remote", "set-url", "origin", "--", url],
+ url,
+ Some(&mirror_dir),
+ )?;
+ self.run_command(
+ &["git", "remote", "update", "--prune", "origin"],
+ url,
+ Some(&mirror_dir),
+ )?;
+ } else {
+ // Create new mirror
+ std::fs::create_dir_all(&mirror_dir)?;
+ self.run_command(
+ &[
+ "git",
+ "clone",
+ "--mirror",
+ "--",
+ url,
+ mirror_dir.to_str().unwrap_or(""),
+ ],
+ url,
+ None,
+ )?;
+ }
+
+ Ok(mirror_dir)
+ }
+
+ /// Fetch a specific refspec from the mirror.
+ pub fn fetch_ref(&self, mirror_dir: &Path, refspec: &str) -> Result<bool> {
+ let output = self
+ .process
+ .execute(&["git", "fetch", "origin", refspec], Some(mirror_dir))?;
+ Ok(output.status == 0)
+ }
+
+ /// Get the default branch of a repository.
+ pub fn get_default_branch(&self, mirror_dir: &Path) -> Result<Option<String>> {
+ let output = self
+ .process
+ .execute(&["git", "remote", "show", "origin"], Some(mirror_dir))?;
+ if output.status != 0 {
+ return Ok(None);
+ }
+ for line in output.stdout.lines() {
+ let trimmed = line.trim();
+ if let Some(branch) = trimmed.strip_prefix("HEAD branch:") {
+ let branch = branch.trim();
+ if branch != "(unknown)" {
+ return Ok(Some(branch.to_string()));
+ }
+ }
+ }
+ Ok(None)
+ }
+
+ /// Execute a git command with protocol fallback.
+ ///
+ /// Tries the URL as-is first, then falls back through protocol variations
+ /// (ssh → https → git://) if the command fails.
+ pub fn run_command(
+ &self,
+ args: &[&str],
+ url: &str,
+ cwd: Option<&Path>,
+ ) -> Result<ProcessOutput> {
+ let mut executor = ProcessExecutor::new();
+ for (key, value) in Self::clean_env() {
+ match value {
+ Some(v) => executor.set_env(key, v),
+ None => executor.remove_env(key),
+ }
+ }
+
+ // Try the command as-is first
+ let output = executor.execute(args, cwd)?;
+ if output.status == 0 {
+ return Ok(output);
+ }
+
+ // Try protocol fallback for remote URLs
+ let fallback_urls = Self::get_fallback_urls(url);
+ for fallback_url in &fallback_urls {
+ let new_args: Vec<&str> = args
+ .iter()
+ .map(|&a| if a == url { fallback_url.as_str() } else { a })
+ .collect();
+ let fallback_output = executor.execute(&new_args, cwd)?;
+ if fallback_output.status == 0 {
+ return Ok(fallback_output);
+ }
+ }
+
+ // Return the original error
+ if output.status != 0 {
+ bail!(
+ "Git command `{}` failed with exit code {}\nstdout: {}\nstderr: {}",
+ args.join(" "),
+ output.status,
+ output.stdout.trim(),
+ output.stderr.trim(),
+ );
+ }
+ Ok(output)
+ }
+
+ /// Get the Git version string.
+ pub fn get_version(&self) -> Option<String> {
+ let output = self.process.execute(&["git", "--version"], None).ok()?;
+ if output.status != 0 {
+ return None;
+ }
+ // "git version 2.39.2" -> "2.39.2"
+ output
+ .stdout
+ .trim()
+ .strip_prefix("git version ")
+ .map(|s| s.to_string())
+ }
+
+ /// Sanitize a URL for use as a directory name.
+ pub fn sanitize_url(url: &str) -> String {
+ let mut hasher = Sha1::new();
+ hasher.update(url.as_bytes());
+ let hash = hasher.finalize();
+ format!("{:x}", hash)
+ }
+
+ /// Get the cache mirror path for a URL.
+ pub fn mirror_path(&self, url: &str) -> PathBuf {
+ self.cache_dir.join(Self::sanitize_url(url))
+ }
+
+ /// Generate fallback URLs for protocol switching.
+ fn get_fallback_urls(url: &str) -> Vec<String> {
+ let mut urls = Vec::new();
+
+ // ssh -> https fallback
+ if url.starts_with("git@") {
+ // git@github.com:owner/repo.git -> https://github.com/owner/repo.git
+ if let Some(rest) = url.strip_prefix("git@") {
+ let converted = rest.replacen(':', "/", 1);
+ urls.push(format!("https://{converted}"));
+ }
+ }
+
+ // git:// -> https:// fallback
+ if let Some(rest) = url.strip_prefix("git://") {
+ urls.push(format!("https://{rest}"));
+ }
+
+ // https -> git:// fallback
+ if let Some(rest) = url.strip_prefix("https://") {
+ urls.push(format!("git://{rest}"));
+ }
+
+ urls
+ }
+}
diff --git a/crates/mozart-vcs/src/util/hg.rs b/crates/mozart-vcs/src/util/hg.rs
new file mode 100644
index 0000000..7f5abcc
--- /dev/null
+++ b/crates/mozart-vcs/src/util/hg.rs
@@ -0,0 +1,30 @@
+use std::path::Path;
+
+use anyhow::Result;
+
+use crate::process::{ProcessExecutor, ProcessOutput};
+
+/// Mercurial utility for command execution.
+pub struct HgUtil {
+ process: ProcessExecutor,
+}
+
+impl HgUtil {
+ pub fn new(process: ProcessExecutor) -> Self {
+ Self { process }
+ }
+
+ /// Execute a Mercurial command.
+ pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> {
+ let mut full_args = vec!["hg"];
+ full_args.extend_from_slice(args);
+ self.process.execute_checked(&full_args, cwd)
+ }
+
+ /// Execute a Mercurial command, not erroring on non-zero exit.
+ pub fn execute_unchecked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> {
+ let mut full_args = vec!["hg"];
+ full_args.extend_from_slice(args);
+ self.process.execute(&full_args, cwd)
+ }
+}
diff --git a/crates/mozart-vcs/src/util/mod.rs b/crates/mozart-vcs/src/util/mod.rs
new file mode 100644
index 0000000..b2c35fc
--- /dev/null
+++ b/crates/mozart-vcs/src/util/mod.rs
@@ -0,0 +1,3 @@
+pub mod git;
+pub mod hg;
+pub mod svn;
diff --git a/crates/mozart-vcs/src/util/svn.rs b/crates/mozart-vcs/src/util/svn.rs
new file mode 100644
index 0000000..e9a6813
--- /dev/null
+++ b/crates/mozart-vcs/src/util/svn.rs
@@ -0,0 +1,91 @@
+use std::path::Path;
+
+use anyhow::Result;
+
+use crate::process::{ProcessExecutor, ProcessOutput};
+
+/// SVN credentials for authenticated operations.
+#[derive(Debug, Clone)]
+pub struct SvnCredentials {
+ pub username: String,
+ pub password: String,
+}
+
+/// SVN utility for command execution with credential handling.
+pub struct SvnUtil {
+ process: ProcessExecutor,
+}
+
+impl SvnUtil {
+ pub fn new(process: ProcessExecutor) -> Self {
+ Self { process }
+ }
+
+ /// Execute an SVN command with `--non-interactive`.
+ pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> {
+ let mut full_args = vec!["svn"];
+ full_args.extend_from_slice(args);
+ full_args.push("--non-interactive");
+ self.process.execute_checked(&full_args, cwd)
+ }
+
+ /// Execute an SVN command with optional credentials, retrying on auth failure.
+ pub fn execute_with_credentials(
+ &self,
+ args: &[&str],
+ creds: Option<&SvnCredentials>,
+ cwd: Option<&Path>,
+ ) -> Result<ProcessOutput> {
+ let mut full_args = vec!["svn"];
+ full_args.extend_from_slice(args);
+ full_args.push("--non-interactive");
+
+ let cred_args: Vec<String>;
+ if let Some(c) = creds {
+ cred_args = vec![
+ "--username".to_string(),
+ c.username.clone(),
+ "--password".to_string(),
+ c.password.clone(),
+ ];
+ for arg in &cred_args {
+ full_args.push(arg);
+ }
+ }
+
+ let full_args_refs: Vec<&str> = full_args.iter().map(|s| &**s).collect();
+
+ // Retry up to 5 times on auth failure
+ let max_retries = 5;
+ let mut last_output = None;
+ for _ in 0..max_retries {
+ let output = self.process.execute(&full_args_refs, cwd)?;
+ if output.status == 0 {
+ return Ok(output);
+ }
+ // Check if it's an auth error (SVN exit code or stderr hint)
+ if !output.stderr.contains("authorization failed")
+ && !output.stderr.contains("Could not authenticate")
+ && !output.stderr.contains("Authentication failed")
+ {
+ // Not an auth error, return immediately
+ last_output = Some(output);
+ break;
+ }
+ last_output = Some(output);
+ }
+
+ match last_output {
+ Some(output) if output.status != 0 => {
+ anyhow::bail!(
+ "SVN command `{}` failed with exit code {}\nstderr: {}",
+ full_args_refs.join(" "),
+ output.status,
+ output.stderr.trim(),
+ );
+ }
+ Some(output) => Ok(output),
+ None => anyhow::bail!("SVN command failed with no output"),
+ }
+ }
+}
diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs
new file mode 100644
index 0000000..1fafc7c
--- /dev/null
+++ b/crates/mozart-vcs/tests/git_driver_test.rs
@@ -0,0 +1,272 @@
+use std::path::Path;
+use std::process::Command;
+
+use tempfile::TempDir;
+
+use mozart_vcs::downloader::VcsDownloader;
+use mozart_vcs::downloader::git::GitDownloader;
+use mozart_vcs::driver::git::GitDriver;
+use mozart_vcs::driver::{DriverConfig, VcsDriver};
+use mozart_vcs::process::ProcessExecutor;
+use mozart_vcs::util::git::GitUtil;
+
+fn has_git() -> bool {
+ Command::new("git").arg("--version").output().is_ok()
+}
+
+fn create_test_repo(dir: &Path) {
+ let run = |args: &[&str]| {
+ let output = Command::new(args[0])
+ .args(&args[1..])
+ .current_dir(dir)
+ .env("GIT_AUTHOR_NAME", "Test")
+ .env("GIT_AUTHOR_EMAIL", "test@test.com")
+ .env("GIT_COMMITTER_NAME", "Test")
+ .env("GIT_COMMITTER_EMAIL", "test@test.com")
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "Command failed: {:?}\nstderr: {}",
+ args,
+ String::from_utf8_lossy(&output.stderr)
+ );
+ };
+
+ run(&["git", "init", "-b", "main"]);
+ run(&["git", "config", "user.email", "test@test.com"]);
+ run(&["git", "config", "user.name", "Test"]);
+
+ // Create composer.json
+ std::fs::write(
+ dir.join("composer.json"),
+ r#"{"name": "test/package", "description": "Test package"}"#,
+ )
+ .unwrap();
+
+ run(&["git", "add", "."]);
+ run(&["git", "commit", "-m", "Initial commit"]);
+
+ // Create a tag
+ run(&["git", "tag", "v1.0.0"]);
+
+ // Create another commit on main
+ std::fs::write(dir.join("README.md"), "# Test").unwrap();
+ run(&["git", "add", "."]);
+ run(&["git", "commit", "-m", "Add readme"]);
+
+ // Create a second tag
+ run(&["git", "tag", "v1.1.0"]);
+
+ // Create a feature branch
+ run(&["git", "checkout", "-b", "feature/test"]);
+ std::fs::write(dir.join("feature.txt"), "feature").unwrap();
+ run(&["git", "add", "."]);
+ run(&["git", "commit", "-m", "Feature commit"]);
+ run(&["git", "checkout", "main"]);
+}
+
+#[test]
+fn test_git_driver_local_repo() {
+ if !has_git() {
+ eprintln!("Skipping test: git not available");
+ return;
+ }
+
+ let repo_dir = TempDir::new().unwrap();
+ let cache_dir = TempDir::new().unwrap();
+ create_test_repo(repo_dir.path());
+
+ let config = DriverConfig {
+ cache_dir: cache_dir.path().to_path_buf(),
+ ..DriverConfig::default()
+ };
+
+ let mut driver = GitDriver::new(repo_dir.path().to_str().unwrap(), config);
+
+ driver.initialize().unwrap();
+ assert_eq!(driver.root_identifier(), "main");
+
+ // Check tags
+ let tags = driver.tags().unwrap().clone();
+ assert!(
+ tags.contains_key("v1.0.0"),
+ "Missing tag v1.0.0: {:?}",
+ tags
+ );
+ assert!(
+ tags.contains_key("v1.1.0"),
+ "Missing tag v1.1.0: {:?}",
+ tags
+ );
+
+ // Check branches
+ let branches = driver.branches().unwrap().clone();
+ assert!(
+ branches.contains_key("main"),
+ "Missing branch main: {:?}",
+ branches
+ );
+ assert!(
+ branches.contains_key("feature/test"),
+ "Missing branch feature/test: {:?}",
+ branches,
+ );
+
+ // Read composer.json
+ let tag_hash = &tags["v1.0.0"];
+ let info = driver.composer_information(tag_hash).unwrap();
+ assert!(info.is_some());
+ let info = info.unwrap();
+ assert_eq!(info["name"].as_str(), Some("test/package"));
+
+ // Read file content
+ let content = driver.file_content("composer.json", tag_hash).unwrap();
+ assert!(content.is_some());
+ assert!(content.unwrap().contains("test/package"));
+
+ // Change date
+ let date = driver.change_date(tag_hash).unwrap();
+ assert!(date.is_some());
+
+ // Source reference
+ let source = driver.source(tag_hash);
+ assert_eq!(source.source_type, "git");
+
+ driver.cleanup().unwrap();
+}
+
+#[test]
+fn test_git_downloader() {
+ if !has_git() {
+ eprintln!("Skipping test: git not available");
+ return;
+ }
+
+ let repo_dir = TempDir::new().unwrap();
+ let cache_dir = TempDir::new().unwrap();
+ let install_dir = TempDir::new().unwrap();
+ create_test_repo(repo_dir.path());
+
+ let process = ProcessExecutor::new();
+ let git_util = GitUtil::new(process, cache_dir.path().join("git"));
+ let downloader = GitDownloader::new(git_util);
+
+ let url = repo_dir.path().to_str().unwrap();
+ let target = install_dir.path().join("test-package");
+
+ // Download (sync mirror)
+ downloader.download(url, "v1.0.0", &target).unwrap();
+
+ // Install
+ downloader.install(url, "v1.0.0", &target).unwrap();
+ assert!(target.join("composer.json").exists());
+
+ // Check no local changes
+ let changes = downloader.local_changes(&target).unwrap();
+ assert!(changes.is_none(), "Expected no changes, got: {:?}", changes);
+
+ // Make a local change and detect it
+ std::fs::write(target.join("local_change.txt"), "change").unwrap();
+ let changes = downloader.local_changes(&target).unwrap();
+ assert!(changes.is_some());
+ assert!(changes.unwrap().contains("local_change.txt"));
+
+ // Commit logs
+ let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap();
+ assert!(logs.contains("Add readme"));
+
+ // Remove
+ downloader.remove(&target).unwrap();
+ assert!(!target.exists());
+}
+
+#[test]
+fn test_detect_driver() {
+ use mozart_vcs::driver::{DriverType, detect_driver};
+
+ let config = DriverConfig::default();
+
+ assert_eq!(
+ detect_driver("https://github.com/owner/repo", None, &config),
+ Some(DriverType::GitHub),
+ );
+ assert_eq!(
+ detect_driver("git@github.com:owner/repo.git", None, &config),
+ Some(DriverType::GitHub),
+ );
+ assert_eq!(
+ detect_driver("https://gitlab.com/owner/repo", None, &config),
+ Some(DriverType::GitLab),
+ );
+ assert_eq!(
+ detect_driver("https://bitbucket.org/owner/repo", None, &config),
+ Some(DriverType::Bitbucket),
+ );
+ assert_eq!(
+ detect_driver("https://codeberg.org/owner/repo", None, &config),
+ Some(DriverType::Forgejo),
+ );
+ assert_eq!(
+ detect_driver("git://example.com/repo.git", None, &config),
+ Some(DriverType::Git),
+ );
+ assert_eq!(
+ detect_driver("svn://example.com/repo", None, &config),
+ Some(DriverType::Svn),
+ );
+
+ // Forced type
+ assert_eq!(
+ detect_driver("https://example.com/repo", Some("git"), &config),
+ Some(DriverType::Git),
+ );
+}
+
+#[test]
+fn test_vcs_repository_scan() {
+ if !has_git() {
+ eprintln!("Skipping test: git not available");
+ return;
+ }
+
+ let repo_dir = TempDir::new().unwrap();
+ let cache_dir = TempDir::new().unwrap();
+ create_test_repo(repo_dir.path());
+
+ let config = DriverConfig {
+ cache_dir: cache_dir.path().to_path_buf(),
+ ..DriverConfig::default()
+ };
+
+ let repo = mozart_vcs::repository::VcsRepository::new(
+ repo_dir.path().to_str().unwrap().to_string(),
+ None,
+ config,
+ );
+
+ let versions = repo.scan().unwrap();
+ assert!(!versions.is_empty(), "No versions found");
+
+ // Should find tag versions
+ let tag_versions: Vec<_> = versions
+ .iter()
+ .filter(|v| !v.version.starts_with("dev-"))
+ .collect();
+ assert!(!tag_versions.is_empty(), "No tag versions found");
+
+ // Should find branch versions
+ let dev_versions: Vec<_> = versions
+ .iter()
+ .filter(|v| v.version.starts_with("dev-"))
+ .collect();
+ assert!(!dev_versions.is_empty(), "No dev versions found");
+
+ // Check default branch flag
+ let default_versions: Vec<_> = versions.iter().filter(|v| v.is_default_branch).collect();
+ assert_eq!(
+ default_versions.len(),
+ 1,
+ "Expected exactly one default branch version"
+ );
+}
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml
index 915273b..3f1cd33 100644
--- a/crates/mozart/Cargo.toml
+++ b/crates/mozart/Cargo.toml
@@ -9,6 +9,7 @@ mozart-autoload.workspace = true
mozart-core.workspace = true
mozart-registry.workspace = true
mozart-semver.workspace = true
+mozart-vcs.workspace = true
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs
index 6a5b815..2a8ce4f 100644
--- a/crates/mozart/src/commands/create_project.rs
+++ b/crates/mozart/src/commands/create_project.rs
@@ -411,6 +411,7 @@ pub async fn execute(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: raw.repositories.clone(),
};
console.info("Resolving dependencies...");
@@ -502,6 +503,7 @@ pub async fn execute(
apcu_autoloader,
apcu_autoloader_prefix: None,
download_only: false,
+ prefer_source: args.prefer_source,
},
)
.await?;
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index 24c79b3..bf35788 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -117,6 +117,8 @@ pub struct InstallConfig {
pub apcu_autoloader_prefix: Option<String>,
/// Only download packages, skip autoloader generation and installed.json write.
pub download_only: bool,
+ /// Prefer installing from VCS source rather than dist archives.
+ pub prefer_source: bool,
}
impl Default for InstallConfig {
@@ -133,6 +135,7 @@ impl Default for InstallConfig {
apcu_autoloader: false,
apcu_autoloader_prefix: None,
download_only: false,
+ prefer_source: false,
}
}
}
@@ -300,20 +303,51 @@ fn make_progress(show: bool, pkg_name: &str, version: &str) -> downloader::Downl
downloader::DownloadProgress::new(show, format!("{pkg_name} ({version})"))
}
-/// Install packages from a lock file into vendor/.
-///
-/// Used by both the `install` and `update` commands.
-///
-/// This function:
-/// 1. Determines which packages to install (prod + optionally dev)
-/// 2. Warns about platform requirements (unless ignored)
-/// 3. Reads currently installed packages
-/// 4. Computes install/update/skip/removal operations
-/// 5. Prints a summary
-/// 6. Executes downloads with optional progress bars (unless dry_run)
-/// 7. Writes vendor/composer/installed.json
-/// 8. Cleans up empty vendor directories
-/// 9. Generates the autoloader (unless no_autoloader)
+/// Install a package from VCS source (git/svn/hg).
+fn install_from_source(
+ source_type: &str,
+ url: &str,
+ reference: &str,
+ vendor_dir: &Path,
+ package_name: &str,
+) -> anyhow::Result<()> {
+ let target = vendor_dir.join(package_name);
+ if target.exists() {
+ std::fs::remove_dir_all(&target)?;
+ }
+
+ match source_type {
+ "git" => {
+ let process = mozart_vcs::process::ProcessExecutor::new();
+ let git_util =
+ mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git"));
+ let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util);
+ use mozart_vcs::downloader::VcsDownloader;
+ downloader.download(url, reference, &target)?;
+ downloader.install(url, reference, &target)?;
+ }
+ "svn" => {
+ let process = mozart_vcs::process::ProcessExecutor::new();
+ let svn_util = mozart_vcs::util::svn::SvnUtil::new(process);
+ let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util);
+ use mozart_vcs::downloader::VcsDownloader;
+ downloader.install(url, reference, &target)?;
+ }
+ "hg" => {
+ let process = mozart_vcs::process::ProcessExecutor::new();
+ let hg_util = mozart_vcs::util::hg::HgUtil::new(process);
+ let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util);
+ use mozart_vcs::downloader::VcsDownloader;
+ downloader.install(url, reference, &target)?;
+ }
+ _ => {
+ anyhow::bail!("Unsupported source type for VCS install: {}", source_type);
+ }
+ }
+
+ Ok(())
+}
+
pub async fn install_from_lock(
lock: &lockfile::LockFile,
working_dir: &Path,
@@ -405,11 +439,32 @@ pub async fn install_from_lock(
}
}
+ // Try source install if --prefer-source and source info is available
+ if config.prefer_source
+ && let Some(source) = &pkg.source
+ {
+ install_from_source(
+ &source.source_type,
+ &source.url,
+ source.reference.as_deref().unwrap_or("HEAD"),
+ vendor_dir,
+ &pkg.name,
+ )?;
+ continue;
+ }
+
let dist = pkg.dist.as_ref().ok_or_else(|| {
- anyhow::anyhow!(
- "Package {} has no dist information — source installs are not yet supported",
- pkg.name
- )
+ if pkg.source.is_some() {
+ anyhow::anyhow!(
+ "Package {} has no dist information. Use --prefer-source to install from VCS.",
+ pkg.name,
+ )
+ } else {
+ anyhow::anyhow!(
+ "Package {} has no dist or source information",
+ pkg.name,
+ )
+ }
})?;
let mut progress = make_progress(!config.no_progress, &pkg.name, &pkg.version);
@@ -604,18 +659,13 @@ pub async fn execute(
}
}
- // Step 5: Warn about prefer-source (not yet supported)
+ // Step 5: Determine if prefer-source is enabled
let prefer_source = args.prefer_source
|| args
.prefer_install
.as_deref()
.map(|s| s.eq_ignore_ascii_case("source"))
.unwrap_or(false);
- if prefer_source {
- console.info(&console_format!(
- "<warning>Warning: Source installs are not yet supported. Falling back to dist.</warning>"
- ));
- }
// Step 6: Determine dev mode and vendor directory
let dev_mode = !args.no_dev;
@@ -638,6 +688,7 @@ pub async fn execute(
apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(),
apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(),
download_only: args.download_only,
+ prefer_source,
},
)
.await
diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs
index 88fa4da..6d3b5e2 100644
--- a/crates/mozart/src/commands/remove.rs
+++ b/crates/mozart/src/commands/remove.rs
@@ -251,6 +251,7 @@ pub async fn execute(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: raw.repositories.clone(),
};
// Print header messages
@@ -438,6 +439,7 @@ pub async fn execute(
apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(),
apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(),
download_only: false,
+ prefer_source: false,
},
)
.await?;
@@ -497,6 +499,7 @@ async fn remove_unused(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: raw.repositories.clone(),
};
console.info("Resolving dependencies to detect unused packages...");
@@ -577,6 +580,7 @@ async fn remove_unused(
apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(),
apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(),
download_only: false,
+ prefer_source: false,
},
)
.await?;
@@ -826,6 +830,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved = resolve(&request)
.await
@@ -862,6 +867,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved2 = resolve(&request2)
.await
diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs
index 4ea739d..15b5f1c 100644
--- a/crates/mozart/src/commands/require.rs
+++ b/crates/mozart/src/commands/require.rs
@@ -656,6 +656,7 @@ pub async fn execute(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: raw.repositories.clone(),
};
// Print header messages
@@ -870,6 +871,7 @@ pub async fn execute(
|| config_apcu,
apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(),
download_only: false,
+ prefer_source: args.prefer_source,
},
)
.await?;
@@ -1028,6 +1030,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved = resolver::resolve(&request)
@@ -1081,6 +1084,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved = resolver::resolve(&request)
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index 06e6b22..c1901cd 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -835,6 +835,7 @@ pub async fn execute(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repo_cache: None,
temporary_constraints,
+ repositories: composer_json.repositories.clone(),
};
// Step 6: Print header and run resolver
@@ -1164,18 +1165,13 @@ pub async fn execute(
// Step 12: Install packages (unless --no-install or --dry-run)
if !args.no_install && !args.dry_run {
- // Warn about prefer-source (not yet supported)
+ // Determine if prefer-source is enabled
let prefer_source = args.prefer_source
|| args
.prefer_install
.as_deref()
.map(|s| s.eq_ignore_ascii_case("source"))
.unwrap_or(false);
- if prefer_source {
- console.info(&console_format!(
- "<warning>Warning: Source installs are not yet supported. Falling back to dist.</warning>"
- ));
- }
super::install::install_from_lock(
&new_lock,
@@ -1193,6 +1189,7 @@ pub async fn execute(
apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(),
apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(),
download_only: false,
+ prefer_source,
},
)
.await?;
@@ -1909,6 +1906,7 @@ mod tests {
ignore_platform_req_list: vec![],
repo_cache: None,
temporary_constraints: HashMap::new(),
+ repositories: vec![],
};
let resolved = resolve(&request).await.expect("Resolution should succeed");