aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-vcs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-vcs
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-vcs')
-rw-r--r--crates/mozart-vcs/Cargo.toml21
-rw-r--r--crates/mozart-vcs/src/downloader/git.rs274
-rw-r--r--crates/mozart-vcs/src/downloader/hg.rs87
-rw-r--r--crates/mozart-vcs/src/downloader/mod.rs56
-rw-r--r--crates/mozart-vcs/src/downloader/svn.rs87
-rw-r--r--crates/mozart-vcs/src/driver/bitbucket.rs277
-rw-r--r--crates/mozart-vcs/src/driver/forgejo.rs285
-rw-r--r--crates/mozart-vcs/src/driver/git.rs278
-rw-r--r--crates/mozart-vcs/src/driver/github.rs315
-rw-r--r--crates/mozart-vcs/src/driver/gitlab.rs301
-rw-r--r--crates/mozart-vcs/src/driver/hg.rs205
-rw-r--r--crates/mozart-vcs/src/driver/mod.rs309
-rw-r--r--crates/mozart-vcs/src/driver/svn.rs217
-rw-r--r--crates/mozart-vcs/src/lib.rs6
-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.rs314
-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/src/version_guesser.rs605
-rw-r--r--crates/mozart-vcs/tests/git_driver_test.rs340
22 files changed, 0 insertions, 4449 deletions
diff --git a/crates/mozart-vcs/Cargo.toml b/crates/mozart-vcs/Cargo.toml
deleted file mode 100644
index 92b3e24..0000000
--- a/crates/mozart-vcs/Cargo.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[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
-indexmap.workspace = true
-regex.workspace = true
-reqwest.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-tokio.workspace = true
-tracing.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
deleted file mode 100644
index 814d67e..0000000
--- a/crates/mozart-vcs/src/downloader/git.rs
+++ /dev/null
@@ -1,274 +0,0 @@
-use std::path::Path;
-use std::sync::LazyLock;
-
-use anyhow::Result;
-use regex::Regex;
-
-use crate::process::ProcessExecutor;
-use crate::util::git::GitUtil;
-
-use super::VcsDownloader;
-
-/// Match `<hex> HEAD` lines in `git show-ref --head -d` output.
-static HEAD_REF_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap());
-
-/// 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 get_local_changes(&self, target: &Path) -> Result<Option<String>> {
- if !target.join(".git").exists() {
- return Ok(None);
- }
- let process = ProcessExecutor::new();
- let output = process.execute(
- &["git", "status", "--porcelain", "--untracked-files=no"],
- Some(target),
- )?;
- let trimmed = output.stdout.trim();
- if trimmed.is_empty() {
- Ok(None)
- } else {
- Ok(Some(trimmed.to_string()))
- }
- }
-
- fn vcs_reference(&self, target: &Path) -> Result<Option<String>> {
- if !target.join(".git").exists() {
- return Ok(None);
- }
- let process = ProcessExecutor::new();
- let output = process.execute(&["git", "rev-parse", "HEAD"], Some(target))?;
- if output.status != 0 {
- return Ok(None);
- }
- let trimmed = output.stdout.trim();
- if trimmed.is_empty() {
- Ok(None)
- } else {
- Ok(Some(trimmed.to_string()))
- }
- }
-
- fn unpushed_changes(&self, target: &Path) -> Result<Option<String>> {
- if !target.join(".git").exists() {
- return Ok(None);
- }
- let process = ProcessExecutor::new();
-
- let mut refs = match collect_show_ref(&process, target)? {
- Some(r) => r,
- None => return Ok(None),
- };
-
- let head_ref = match HEAD_REF_RE
- .captures(&refs)
- .and_then(|c| c.get(1))
- .map(|m| m.as_str().to_string())
- {
- Some(h) => h,
- None => return Ok(None),
- };
-
- let candidate_branches = collect_local_branches(&refs, &head_ref);
- if candidate_branches.is_empty() {
- // not on a branch (detached / tag) — skip
- return Ok(None);
- }
-
- let mut branch = candidate_branches[0].clone();
- let mut unpushed_changes: Option<String> = None;
- let mut branch_not_found_error = false;
-
- for i in 0..=1 {
- let mut remote_branches: Vec<String> = Vec::new();
-
- for candidate in &candidate_branches {
- let matches = collect_remote_branches(&refs, candidate);
- if !matches.is_empty() {
- branch = candidate.clone();
- remote_branches = matches;
- break;
- }
- }
-
- if remote_branches.is_empty() {
- unpushed_changes = Some(format!(
- "Branch {branch} could not be found on any remote and appears to be unpushed"
- ));
- branch_not_found_error = true;
- } else {
- if branch_not_found_error {
- unpushed_changes = None;
- }
- for remote_branch in &remote_branches {
- let range = format!("{remote_branch}...{branch}");
- let output = process.execute_checked(
- &["git", "diff", "--name-status", &range, "--"],
- Some(target),
- )?;
- let trimmed = output.stdout.trim().to_string();
- match unpushed_changes {
- None => unpushed_changes = Some(trimmed),
- Some(ref existing) if trimmed.len() < existing.len() => {
- unpushed_changes = Some(trimmed);
- }
- _ => {}
- }
- }
- }
-
- if unpushed_changes.as_deref().is_some_and(|s| !s.is_empty()) && i == 0 {
- let _ = process.execute(&["git", "fetch", "--all"], Some(target))?;
- refs = match collect_show_ref(&process, target)? {
- Some(r) => r,
- None => return Ok(unpushed_changes),
- };
- }
-
- if unpushed_changes.as_deref().is_none_or(str::is_empty) {
- break;
- }
- }
-
- Ok(unpushed_changes.filter(|s| !s.is_empty()))
- }
-
- 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)
- }
-
- fn is_change_report(&self) -> bool {
- true
- }
-
- fn is_vcs_capable_downloader(&self) -> bool {
- true
- }
-
- fn is_dvcs_downloader(&self) -> bool {
- true
- }
-}
-
-fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result<Option<String>> {
- let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?;
- if output.status != 0 {
- anyhow::bail!(
- "Failed to execute git show-ref --head -d\n\n{}",
- output.stderr.trim()
- );
- }
- Ok(Some(output.stdout.trim().to_string()))
-}
-
-fn collect_local_branches(refs: &str, head_ref: &str) -> Vec<String> {
- let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref));
- let re = match Regex::new(&pattern) {
- Ok(r) => r,
- Err(_) => return Vec::new(),
- };
- re.captures_iter(refs)
- .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
- .collect()
-}
-
-fn collect_remote_branches(refs: &str, candidate: &str) -> Vec<String> {
- let pattern = format!(
- r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$",
- regex::escape(candidate)
- );
- let re = match Regex::new(&pattern) {
- Ok(r) => r,
- Err(_) => return Vec::new(),
- };
- re.captures_iter(refs)
- .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
- .collect()
-}
diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs
deleted file mode 100644
index 3230404..0000000
--- a/crates/mozart-vcs/src/downloader/hg.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-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 get_local_changes(&self, target: &Path) -> Result<Option<String>> {
- if !target.join(".hg").is_dir() {
- return Ok(None);
- }
- let output = self.hg_util.execute(&["st"], Some(target))?;
- let trimmed = output.stdout.trim();
- if trimmed.is_empty() {
- Ok(None)
- } else {
- Ok(Some(trimmed.to_string()))
- }
- }
-
- 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)
- }
-
- fn is_change_report(&self) -> bool {
- true
- }
-
- fn is_vcs_capable_downloader(&self) -> bool {
- true
- }
-
- fn is_dvcs_downloader(&self) -> bool {
- false
- }
-}
diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs
deleted file mode 100644
index 352f330..0000000
--- a/crates/mozart-vcs/src/downloader/mod.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-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.
- /// Mirrors `Composer\Downloader\ChangeReportInterface::getLocalChanges`.
- fn get_local_changes(&self, target: &Path) -> Result<Option<String>>;
-
- /// Detect commits present locally but not on the tracking remote.
- /// Returns `None` if there are no unpushed commits or the concept does
- /// not apply (only `GitDownloader` implements this in Composer's
- /// `DvcsDownloaderInterface`).
- fn unpushed_changes(&self, _target: &Path) -> Result<Option<String>> {
- Ok(None)
- }
-
- /// Resolve the working copy's current VCS reference (e.g. commit hash).
- /// Returns `None` if no reference can be determined. Mirrors
- /// `Composer\Downloader\VcsCapableDownloaderInterface::getVcsReference`.
- fn vcs_reference(&self, _target: &Path) -> Result<Option<String>> {
- Ok(None)
- }
-
- /// Get commit log between two references.
- fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String>;
-
- /// instanceof ChangeReportInterface
- fn is_change_report(&self) -> bool;
-
- /// instanceof VcsCapableDownloaderInterface
- fn is_vcs_capable_downloader(&self) -> bool;
-
- /// instanceof DvcsDownloaderInterface
- fn is_dvcs_downloader(&self) -> bool;
-}
diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs
deleted file mode 100644
index 87b59da..0000000
--- a/crates/mozart-vcs/src/downloader/svn.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-use std::path::Path;
-use std::sync::LazyLock;
-
-use anyhow::Result;
-use regex::Regex;
-
-use crate::util::svn::SvnUtil;
-
-use super::VcsDownloader;
-
-/// Match any non-`X` status line (mirror of Composer's
-/// `{^ *[^X ] +}m`). Ignores externals (`X` prefix).
-static SVN_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^ *[^X ] +").unwrap());
-
-/// 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 get_local_changes(&self, target: &Path) -> Result<Option<String>> {
- if !target.join(".svn").is_dir() {
- return Ok(None);
- }
- let output = self
- .svn_util
- .execute(&["status", "--ignore-externals"], Some(target))?;
- if SVN_STATUS_RE.is_match(&output.stdout) {
- Ok(Some(output.stdout))
- } else {
- Ok(None)
- }
- }
-
- 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)
- }
-
- fn is_change_report(&self) -> bool {
- true
- }
-
- fn is_vcs_capable_downloader(&self) -> bool {
- true
- }
-
- fn is_dvcs_downloader(&self) -> bool {
- false
- }
-}
diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs
deleted file mode 100644
index 0e67bc8..0000000
--- a/crates/mozart-vcs/src/driver/bitbucket.rs
+++ /dev/null
@@ -1,277 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-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: IndexMap<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: IndexMap::new(),
- git_driver: None,
- http_client: mozart_core::http::default_client(),
- 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,
- )
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get(&self, path: &str) -> Result<serde_json::Value> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "Bitbucket API response");
- if !response.status().is_success() {
- bail!(
- "Bitbucket API request to {} failed: {}",
- url,
- response.status()
- );
- }
- Ok(response.json().await?)
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "Bitbucket API paginated response");
- if !response.status().is_success() {
- break;
- }
- let data: serde_json::Value = response.json().await?;
- 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)
- }
-
- async 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().await?;
- self.git_driver = Some(Box::new(driver));
- }
- Ok(self.git_driver.as_mut().unwrap())
- }
-}
-
-impl VcsDriver for BitbucketDriver {
- async fn initialize(&mut self) -> Result<()> {
- match self.api_get("").await {
- 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().await?;
- self.root_identifier = Some(driver.root_identifier().to_string());
- }
- }
- Ok(())
- }
-
- fn root_identifier(&self) -> &str {
- self.root_identifier.as_deref().unwrap_or("main")
- }
-
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.branches.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let branches = driver.branches().await?.clone();
- self.branches = Some(branches);
- } else {
- let items = self.api_get_paginated("/refs/branches?pagelen=100").await?;
- 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())
- }
-
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.tags.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let tags = driver.tags().await?.clone();
- self.tags = Some(tags);
- } else {
- let items = self.api_get_paginated("/refs/tags?pagelen=100").await?;
- 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())
- }
-
- async 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).await?;
- let value = content.and_then(|c| serde_json::from_str(&c).ok());
- self.info_cache
- .insert(identifier.to_string(), value.clone());
- Ok(value)
- }
-
- async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
- if self.api_failed {
- return Ok(None);
- }
- 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 = req.send().await?;
- if response.status().is_success() {
- Ok(Some(response.text().await?))
- } else {
- Ok(None)
- }
- }
-
- async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
- if self.api_failed {
- return Ok(None);
- }
- match self.api_get(&format!("/commit/{identifier}")).await {
- Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())),
- Err(_) => Ok(None),
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- if let Some(driver) = &mut self.git_driver {
- driver.cleanup().await?;
- }
- Ok(())
- }
-}
diff --git a/crates/mozart-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs
deleted file mode 100644
index 665c177..0000000
--- a/crates/mozart-vcs/src/driver/forgejo.rs
+++ /dev/null
@@ -1,285 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-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: IndexMap<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: IndexMap::new(),
- git_driver: None,
- http_client: mozart_core::http::default_client(),
- 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,
- )
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get(&self, path: &str) -> Result<serde_json::Value> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "Forgejo API response");
- if !response.status().is_success() {
- bail!(
- "Forgejo API request to {} failed: {}",
- url,
- response.status()
- );
- }
- Ok(response.json().await?)
- }
-
- #[tracing::instrument(skip(self))]
- async 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).await?;
- 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)
- }
-
- async 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().await?;
- self.git_driver = Some(Box::new(driver));
- }
- Ok(self.git_driver.as_mut().unwrap())
- }
-}
-
-impl VcsDriver for ForgejoDriver {
- async fn initialize(&mut self) -> Result<()> {
- match self.api_get("").await {
- 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().await?;
- self.root_identifier = Some(driver.root_identifier().to_string());
- }
- }
- Ok(())
- }
-
- fn root_identifier(&self) -> &str {
- self.root_identifier.as_deref().unwrap_or("main")
- }
-
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.branches.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let branches = driver.branches().await?.clone();
- self.branches = Some(branches);
- } else {
- let items = self.api_get_paginated("/branches").await?;
- 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())
- }
-
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.tags.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let tags = driver.tags().await?.clone();
- self.tags = Some(tags);
- } else {
- let items = self.api_get_paginated("/tags").await?;
- 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())
- }
-
- async 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).await?;
- let value = content.and_then(|c| serde_json::from_str(&c).ok());
- self.info_cache
- .insert(identifier.to_string(), value.clone());
- Ok(value)
- }
-
- async 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).await {
- 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),
- }
- }
-
- async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
- if self.api_failed {
- return Ok(None);
- }
- match self.api_get(&format!("/git/commits/{identifier}")).await {
- Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())),
- Err(_) => Ok(None),
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- if let Some(driver) = &mut self.git_driver {
- driver.cleanup().await?;
- }
- Ok(())
- }
-}
diff --git a/crates/mozart-vcs/src/driver/git.rs b/crates/mozart-vcs/src/driver/git.rs
deleted file mode 100644
index 090a5fa..0000000
--- a/crates/mozart-vcs/src/driver/git.rs
+++ /dev/null
@@ -1,278 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-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: IndexMap<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_vcs_dir.clone());
- 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: IndexMap::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 = IndexMap::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 {
- async 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")
- }
-
- async 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())
- }
-
- async 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())
- }
-
- async 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).await?;
- 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)
- }
-
- async 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)
- }
- }
-
- async 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)
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- Ok(())
- }
-}
diff --git a/crates/mozart-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs
deleted file mode 100644
index e968c3e..0000000
--- a/crates/mozart-vcs/src/driver/github.rs
+++ /dev/null
@@ -1,315 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-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: IndexMap<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: IndexMap::new(),
- git_driver: None,
- http_client: mozart_core::http::default_client(),
- 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
- )
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get(&self, path: &str) -> Result<serde_json::Value> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "GitHub API response");
- if !response.status().is_success() {
- bail!(
- "GitHub API request to {} failed with status {}",
- url,
- response.status()
- );
- }
- Ok(response.json().await?)
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "GitHub API paginated response");
- if !response.status().is_success() {
- bail!("GitHub API paginated request failed: {}", response.status());
- }
-
- let batch: Vec<serde_json::Value> = response.json().await?;
- if batch.is_empty() {
- break;
- }
- items.extend(batch);
- page += 1;
- // Safety: limit to 10 pages (1000 items)
- if page > 10 {
- break;
- }
- }
- Ok(items)
- }
-
- async 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().await?;
- self.git_driver = Some(Box::new(driver));
- }
- Ok(self.git_driver.as_mut().unwrap())
- }
-}
-
-impl VcsDriver for GitHubDriver {
- async fn initialize(&mut self) -> Result<()> {
- // Try to fetch repo data from API
- match self.api_get("").await {
- 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().await?;
- self.root_identifier = Some(driver.root_identifier().to_string());
- }
- }
- Ok(())
- }
-
- fn root_identifier(&self) -> &str {
- self.root_identifier.as_deref().unwrap_or("main")
- }
-
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.branches.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let branches = driver.branches().await?.clone();
- self.branches = Some(branches);
- } else {
- let items = self.api_get_paginated("/branches").await?;
- 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())
- }
-
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.tags.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let tags = driver.tags().await?.clone();
- self.tags = Some(tags);
- } else {
- let items = self.api_get_paginated("/tags").await?;
- 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())
- }
-
- async 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).await?;
- 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)
- }
-
- async 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).await {
- 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),
- }
- }
-
- async 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).await {
- Ok(data) => {
- let date = data["commit"]["committer"]["date"]
- .as_str()
- .map(|s| s.to_string());
- Ok(date)
- }
- Err(_) => Ok(None),
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- if let Some(driver) = &mut self.git_driver {
- driver.cleanup().await?;
- }
- 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
deleted file mode 100644
index 937251a..0000000
--- a/crates/mozart-vcs/src/driver/gitlab.rs
+++ /dev/null
@@ -1,301 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-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: IndexMap<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: IndexMap::new(),
- git_driver: None,
- http_client: mozart_core::http::default_client(),
- 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
- )
- }
-
- #[tracing::instrument(skip(self))]
- async fn api_get(&self, path: &str) -> Result<serde_json::Value> {
- 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 = req.send().await?;
- tracing::debug!(status = %response.status(), %url, "GitLab API response");
- if !response.status().is_success() {
- bail!(
- "GitLab API request to {} failed with status {}",
- url,
- response.status()
- );
- }
- Ok(response.json().await?)
- }
-
- #[tracing::instrument(skip(self))]
- async 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).await?;
- 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)
- }
-
- async 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().await?;
- self.git_driver = Some(Box::new(driver));
- }
- Ok(self.git_driver.as_mut().unwrap())
- }
-}
-
-impl VcsDriver for GitLabDriver {
- async fn initialize(&mut self) -> Result<()> {
- match self.api_get("").await {
- 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().await?;
- self.root_identifier = Some(driver.root_identifier().to_string());
- }
- }
- Ok(())
- }
-
- fn root_identifier(&self) -> &str {
- self.root_identifier.as_deref().unwrap_or("main")
- }
-
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.branches.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let branches = driver.branches().await?.clone();
- self.branches = Some(branches);
- } else {
- let items = self.api_get_paginated("/repository/branches").await?;
- 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())
- }
-
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- if self.tags.is_none() {
- if self.api_failed {
- let driver = self.use_git_fallback().await?;
- let tags = driver.tags().await?.clone();
- self.tags = Some(tags);
- } else {
- let items = self.api_get_paginated("/repository/tags").await?;
- 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())
- }
-
- async 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).await?;
- let value = content.and_then(|c| serde_json::from_str(&c).ok());
- self.info_cache
- .insert(identifier.to_string(), value.clone());
- Ok(value)
- }
-
- async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
- if self.api_failed {
- return Ok(None);
- }
- 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 = req.send().await?;
- if response.status().is_success() {
- Ok(Some(response.text().await?))
- } else {
- Ok(None)
- }
- }
-
- async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
- if self.api_failed {
- return Ok(None);
- }
- match self
- .api_get(&format!("/repository/commits/{identifier}"))
- .await
- {
- Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())),
- Err(_) => Ok(None),
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- if let Some(driver) = &mut self.git_driver {
- driver.cleanup().await?;
- }
- Ok(())
- }
-}
diff --git a/crates/mozart-vcs/src/driver/hg.rs b/crates/mozart-vcs/src/driver/hg.rs
deleted file mode 100644
index f476e6a..0000000
--- a/crates/mozart-vcs/src/driver/hg.rs
+++ /dev/null
@@ -1,205 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-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: IndexMap<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: IndexMap::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 {
- async fn initialize(&mut self) -> Result<()> {
- let cache_dir = &self.config.cache_vcs_dir;
- 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")
- }
-
- async 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())
- }
-
- async 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())
- }
-
- async 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).await?;
- let value = content.and_then(|c| serde_json::from_str(&c).ok());
- self.info_cache
- .insert(identifier.to_string(), value.clone());
- Ok(value)
- }
-
- async 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)
- }
- }
-
- async 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))
- }
- }
-
- async 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
- }
-
- async fn cleanup(&mut self) -> Result<()> {
- Ok(())
- }
-}
diff --git a/crates/mozart-vcs/src/driver/mod.rs b/crates/mozart-vcs/src/driver/mod.rs
deleted file mode 100644
index cfaf11e..0000000
--- a/crates/mozart-vcs/src/driver/mod.rs
+++ /dev/null
@@ -1,309 +0,0 @@
-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 {
- /// Composer's `cache-vcs-dir`: root for VCS mirrors, one
- /// subdirectory per sanitized repository URL.
- pub cache_vcs_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_vcs_dir: default_cache_vcs_dir(),
- 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()],
- }
- }
-}
-
-/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars.
-///
-/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` →
-/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`.
-fn default_cache_vcs_dir() -> PathBuf {
- if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") {
- return PathBuf::from(p);
- }
- let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") {
- PathBuf::from(p)
- } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
- PathBuf::from(xdg).join("mozart")
- } else if let Ok(home) = std::env::var("HOME") {
- PathBuf::from(home).join(".cache").join("mozart")
- } else {
- PathBuf::from("/tmp").join("mozart")
- };
- base.join("vcs")
-}
-
-/// 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`.
-trait VcsDriver {
- /// Initialize the driver (e.g., clone mirror, fetch API metadata).
- async fn initialize(&mut self) -> Result<()>;
-
- /// The root identifier (default branch/trunk).
- fn root_identifier(&self) -> &str;
-
- /// All branches as `name -> commit_hash`.
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>>;
-
- /// All tags as `name -> commit_hash`.
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>>;
-
- /// Get composer.json content parsed as JSON for a given identifier.
- async fn composer_information(&mut self, identifier: &str)
- -> Result<Option<serde_json::Value>>;
-
- /// Get raw file content at a given path and identifier.
- async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>;
-
- /// Get the change date for a given identifier (ISO 8601).
- async fn change_date(&self, identifier: &str) -> Result<Option<String>>;
-
- /// Get the dist reference for a given identifier.
- async 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.).
- async fn cleanup(&mut self) -> Result<()>;
-}
-
-/// Enum-dispatched VCS driver.
-///
-/// Wraps all concrete driver types to allow static dispatch with async trait methods.
-pub enum AnyVcsDriver {
- GitHub(github::GitHubDriver),
- GitLab(gitlab::GitLabDriver),
- Bitbucket(bitbucket::BitbucketDriver),
- Forgejo(forgejo::ForgejoDriver),
- Git(git::GitDriver),
- Svn(svn::SvnDriver),
- Hg(hg::HgDriver),
-}
-
-macro_rules! dispatch {
- ($self:expr, $method:ident $(, $arg:expr)*) => {
- match $self {
- AnyVcsDriver::GitHub(d) => d.$method($($arg),*),
- AnyVcsDriver::GitLab(d) => d.$method($($arg),*),
- AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*),
- AnyVcsDriver::Forgejo(d) => d.$method($($arg),*),
- AnyVcsDriver::Git(d) => d.$method($($arg),*),
- AnyVcsDriver::Svn(d) => d.$method($($arg),*),
- AnyVcsDriver::Hg(d) => d.$method($($arg),*),
- }
- };
-}
-
-macro_rules! dispatch_async {
- ($self:expr, $method:ident $(, $arg:expr)*) => {
- match $self {
- AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await,
- AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Git(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Svn(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Hg(d) => d.$method($($arg),*).await,
- }
- };
-}
-
-impl AnyVcsDriver {
- pub async fn initialize(&mut self) -> Result<()> {
- dispatch_async!(self, initialize)
- }
-
- pub fn root_identifier(&self) -> &str {
- dispatch!(self, root_identifier)
- }
-
- pub async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- dispatch_async!(self, branches)
- }
-
- pub async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- dispatch_async!(self, tags)
- }
-
- pub async fn composer_information(
- &mut self,
- identifier: &str,
- ) -> Result<Option<serde_json::Value>> {
- dispatch_async!(self, composer_information, identifier)
- }
-
- pub async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
- dispatch_async!(self, file_content, file, identifier)
- }
-
- pub async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
- dispatch_async!(self, change_date, identifier)
- }
-
- pub async fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
- dispatch_async!(self, dist, identifier)
- }
-
- pub fn source(&self, identifier: &str) -> SourceReference {
- dispatch!(self, source, identifier)
- }
-
- pub fn url(&self) -> &str {
- dispatch!(self, url)
- }
-
- pub async fn cleanup(&mut self) -> Result<()> {
- dispatch_async!(self, cleanup)
- }
-}
-
-/// 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) -> AnyVcsDriver {
- match driver_type {
- DriverType::GitHub => AnyVcsDriver::GitHub(github::GitHubDriver::new(url, config)),
- DriverType::GitLab => AnyVcsDriver::GitLab(gitlab::GitLabDriver::new(url, config)),
- DriverType::Bitbucket => {
- AnyVcsDriver::Bitbucket(bitbucket::BitbucketDriver::new(url, config))
- }
- DriverType::Forgejo => AnyVcsDriver::Forgejo(forgejo::ForgejoDriver::new(url, config)),
- DriverType::Git => AnyVcsDriver::Git(git::GitDriver::new(url, config)),
- DriverType::Svn => AnyVcsDriver::Svn(svn::SvnDriver::new(url, config)),
- DriverType::Hg => AnyVcsDriver::Hg(hg::HgDriver::new(url, config)),
- }
-}
diff --git a/crates/mozart-vcs/src/driver/svn.rs b/crates/mozart-vcs/src/driver/svn.rs
deleted file mode 100644
index 16363e1..0000000
--- a/crates/mozart-vcs/src/driver/svn.rs
+++ /dev/null
@@ -1,217 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-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: IndexMap<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: IndexMap::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 {
- async 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")
- }
-
- async 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())
- }
-
- async 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())
- }
-
- async 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).await?;
- let value = content.and_then(|c| serde_json::from_str(&c).ok());
- self.info_cache
- .insert(identifier.to_string(), value.clone());
- Ok(value)
- }
-
- async 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),
- }
- }
-
- async 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),
- }
- }
-
- async 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
- }
-
- async 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
deleted file mode 100644
index e7ca383..0000000
--- a/crates/mozart-vcs/src/lib.rs
+++ /dev/null
@@ -1,6 +0,0 @@
-pub mod downloader;
-pub mod driver;
-pub mod process;
-pub mod repository;
-pub mod util;
-pub mod version_guesser;
diff --git a/crates/mozart-vcs/src/process.rs b/crates/mozart-vcs/src/process.rs
deleted file mode 100644
index 8ccc11d..0000000
--- a/crates/mozart-vcs/src/process.rs
+++ /dev/null
@@ -1,142 +0,0 @@
-use indexmap::IndexMap;
-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: IndexMap<String, Option<String>>,
-}
-
-impl Default for ProcessExecutor {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl ProcessExecutor {
- pub fn new() -> Self {
- Self {
- timeout: None,
- env_overrides: IndexMap::new(),
- }
- }
-
- pub fn with_timeout(secs: u64) -> Self {
- Self {
- timeout: Some(Duration::from_secs(secs)),
- env_overrides: IndexMap::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
deleted file mode 100644
index b941eec..0000000
--- a/crates/mozart-vcs/src/repository.rs
+++ /dev/null
@@ -1,206 +0,0 @@
-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 async 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().await?;
-
- // Get package name from root composer.json
- let root_id = driver.root_identifier().to_string();
- let root_info = driver.composer_information(&root_id).await?;
- 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().await?.clone();
- for (tag_name, tag_hash) in &tags {
- if let Some(version) = self.tag_to_version(tag_name) {
- match driver.composer_information(tag_hash).await {
- Ok(Some(info)) => {
- let time = driver.change_date(tag_hash).await.unwrap_or(None);
- let source = driver.source(tag_hash);
- let dist = driver.dist(tag_hash).await.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().await?.clone();
- let default_branch = driver.root_identifier().to_string();
- for (branch_name, branch_hash) in &branches {
- match driver.composer_information(branch_hash).await {
- Ok(Some(info)) => {
- if info["name"].as_str() != Some(&package_name) {
- continue;
- }
-
- let time = driver.change_date(branch_hash).await.unwrap_or(None);
- let source = driver.source(branch_hash);
- let dist = driver.dist(branch_hash).await.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().await?;
- 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
deleted file mode 100644
index ab4366d..0000000
--- a/crates/mozart-vcs/src/util/git.rs
+++ /dev/null
@@ -1,314 +0,0 @@
-use std::path::{Path, PathBuf};
-use std::sync::LazyLock;
-
-use anyhow::{Result, bail};
-use regex::Regex;
-
-use crate::process::{ProcessExecutor, ProcessOutput};
-
-/// Modern GitHub token pattern (40+ hex chars, `ghp_…`, `github_pat_…`).
-///
-/// Mirrors `Composer\Util\GitHub::GITHUB_TOKEN_REGEX`.
-static GITHUB_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(r"^([a-fA-F0-9]{12,}|gh[a-zA-Z]_[a-zA-Z0-9_]+|github_pat_[a-zA-Z0-9_]+)$").unwrap()
-});
-
-/// `[?&]access_token=...` query parameter.
-static ACCESS_TOKEN_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"([&?]access_token=)[^&]+").unwrap());
-
-/// `<scheme>://user:password@` credential block.
-static CREDENTIALS_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(r"(?i)(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\s/]+)@").unwrap()
-});
-
-/// 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 cache directory name.
- ///
- /// Mirrors Composer's `Preg::replace('{[^a-z0-9.]}i', '-', Url::sanitize($url))`
- /// pattern (see `GitDriver::initialize` and `GitDownloader`): credentials and
- /// access tokens are first redacted, then every byte outside `[a-zA-Z0-9.]`
- /// is replaced with `-`. The redaction step keeps cache keys stable across
- /// URLs that differ only in their embedded token.
- pub fn sanitize_url(url: &str) -> String {
- let redacted = sanitize_url_credentials(url);
- redacted
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || c == '.' {
- c
- } else {
- '-'
- }
- })
- .collect()
- }
-
- /// 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
- }
-}
-
-/// Redact credentials and access tokens from `url`.
-///
-/// Mirrors Composer's `Util\Url::sanitize`. Two replacements are applied:
-/// 1. `[?&]access_token=…` query values → `***`
-/// 2. `<scheme>://user:password@` credentials → `***:***@` if `user` looks like
-/// a GitHub token, otherwise just `user:***@`
-fn sanitize_url_credentials(url: &str) -> String {
- let url = ACCESS_TOKEN_RE.replace_all(url, "${1}***");
- CREDENTIALS_RE
- .replace_all(&url, |caps: &regex::Captures<'_>| {
- let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or("");
- let user = &caps["user"];
- if GITHUB_TOKEN_RE.is_match(user) {
- format!("{prefix}***:***@")
- } else {
- format!("{prefix}{user}:***@")
- }
- })
- .into_owned()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn sanitize_url_replaces_special_chars_with_dash() {
- assert_eq!(
- GitUtil::sanitize_url("https://github.com/owner/repo.git"),
- "https---github.com-owner-repo.git"
- );
- }
-
- #[test]
- fn sanitize_url_preserves_dot() {
- // Dot must survive — it appears in hostnames and ".git" suffixes.
- let key = GitUtil::sanitize_url("git://example.org/foo.bar/baz.git");
- assert!(key.contains(".org"));
- assert!(key.ends_with(".git"));
- }
-
- #[test]
- fn sanitize_url_redacts_password_in_credentials() {
- let key = GitUtil::sanitize_url("https://alice:s3cret@example.com/repo.git");
- // Password is replaced with ***, then non-alphanumerics become '-'.
- assert!(key.contains("alice"));
- assert!(!key.contains("s3cret"));
- }
-
- #[test]
- fn sanitize_url_redacts_user_when_looks_like_github_token() {
- // 40-hex token in the user position triggers full redaction.
- let token = "abcdef0123456789abcdef0123456789abcdef01";
- let key = GitUtil::sanitize_url(&format!("https://{token}:x-oauth-basic@github.com/o/r"));
- assert!(!key.contains("abcdef"));
- }
-
- #[test]
- fn sanitize_url_redacts_modern_github_pat() {
- // ghp_xxx and github_pat_xxx forms.
- let key1 = GitUtil::sanitize_url("https://ghp_abc123XYZ:x@github.com/o/r");
- assert!(!key1.contains("ghp_"));
- let key2 = GitUtil::sanitize_url("https://github_pat_abc123:x@github.com/o/r");
- assert!(!key2.contains("github_pat_"));
- }
-
- #[test]
- fn sanitize_url_strips_access_token_query() {
- let key = GitUtil::sanitize_url("https://api.github.com/x?access_token=secrettoken");
- assert!(!key.contains("secrettoken"));
- }
-
- #[test]
- fn sanitize_url_token_variants_share_cache_key() {
- // Two pulls of the same repo with different access tokens should land
- // in the same cache subdirectory.
- let a = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenA");
- let b = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenB");
- assert_eq!(a, b);
- }
-}
diff --git a/crates/mozart-vcs/src/util/hg.rs b/crates/mozart-vcs/src/util/hg.rs
deleted file mode 100644
index 7f5abcc..0000000
--- a/crates/mozart-vcs/src/util/hg.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-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
deleted file mode 100644
index b2c35fc..0000000
--- a/crates/mozart-vcs/src/util/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index e9a6813..0000000
--- a/crates/mozart-vcs/src/util/svn.rs
+++ /dev/null
@@ -1,91 +0,0 @@
-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/src/version_guesser.rs b/crates/mozart-vcs/src/version_guesser.rs
deleted file mode 100644
index 038e332..0000000
--- a/crates/mozart-vcs/src/version_guesser.rs
+++ /dev/null
@@ -1,605 +0,0 @@
-//! `VersionGuesser` — derive a package's current version from the working
-//! copy, mirroring `Composer\Package\Version\VersionGuesser`.
-//!
-//! Differences from the PHP version:
-//! - Fossil is not supported (Mozart has no Fossil driver).
-//! - `Platform::isInputCompletionProcess()` short-circuit is omitted.
-//! - `guess_feature_version` runs candidate comparisons sequentially.
-//! Composer parallelises via `executeAsync`; ours is simpler at the
-//! cost of speed when many candidate branches exist.
-
-use std::path::Path;
-use std::sync::LazyLock;
-
-use regex::Regex;
-use serde_json::Value;
-
-use mozart_semver::{Version, normalize_branch};
-
-use crate::process::ProcessExecutor;
-
-const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
-
-/// Mirrors `Composer\Package\Version\VersionParser` (itself a thin wrapper
-/// around `Composer\Semver\VersionParser`). In Rust, semver parsing is
-/// handled by `mozart_semver` directly, so this type carries no state;
-/// it exists to keep `VersionGuesser::new` signature compatible with the
-/// PHP constructor.
-pub struct VersionParser;
-
-impl Default for VersionParser {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl VersionParser {
- pub fn new() -> Self {
- Self
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct GuessedVersion {
- pub version: String,
- pub commit: Option<String>,
- pub pretty_version: Option<String>,
- pub feature_version: Option<String>,
- pub feature_pretty_version: Option<String>,
-}
-
-pub struct VersionGuesser {
- process: ProcessExecutor,
-}
-
-impl Default for VersionGuesser {
- fn default() -> Self {
- Self::new(VersionParser::new())
- }
-}
-
-impl VersionGuesser {
- /// Mirrors `Composer\Package\Version\VersionGuesser::__construct`.
- /// `_version_parser` is accepted for API parity but unused — Rust relies
- /// on `mozart_semver` directly.
- pub fn new(_version_parser: VersionParser) -> Self {
- Self {
- process: ProcessExecutor::new(),
- }
- }
-
- /// `Composer\Package\Version\VersionGuesser::guessVersion`.
- pub fn guess_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> {
- if let Some(v) = self.guess_git_version(package_config, path) {
- return Some(postprocess(v));
- }
- if let Some(v) = self.guess_hg_version(package_config, path) {
- return Some(postprocess(v));
- }
- if let Some(v) = self.guess_svn_version(package_config, path) {
- return Some(postprocess(v));
- }
- None
- }
-
- fn guess_git_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> {
- let mut commit: Option<String> = None;
- let mut version: Option<String> = None;
- let mut pretty_version: Option<String> = None;
- let mut feature_version: Option<String> = None;
- let mut feature_pretty_version: Option<String> = None;
- let mut is_detached = false;
-
- let branch_out = self
- .process
- .execute(
- &["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"],
- Some(path),
- )
- .ok()?;
- if branch_out.status != 0 {
- return None;
- }
-
- let mut branches: Vec<String> = Vec::new();
- let mut is_feature_branch = false;
-
- for line in branch_out.stdout.lines() {
- if line.is_empty() {
- continue;
- }
- if let Some(caps) = CURRENT_BRANCH_RE.captures(line) {
- let name = caps.get(1).map_or("", |m| m.as_str());
- let hash = caps.get(2).map_or("", |m| m.as_str());
- if name == "(no branch)"
- || name.starts_with("(detached ")
- || name.starts_with("(HEAD detached at")
- {
- let v = format!("dev-{hash}");
- version = Some(v.clone());
- pretty_version = Some(v);
- is_feature_branch = true;
- is_detached = true;
- } else {
- version = Some(normalize_branch(name));
- pretty_version = Some(format!("dev-{name}"));
- is_feature_branch = is_feature_branch_name(package_config, name);
- }
- commit = Some(hash.to_string());
- }
-
- if !REMOTE_HEAD_RE.is_match(line)
- && let Some(caps) = ANY_BRANCH_RE.captures(line)
- && let Some(m) = caps.get(1)
- {
- branches.push(m.as_str().to_string());
- }
- }
-
- if is_feature_branch {
- feature_version = version.clone();
- feature_pretty_version = pretty_version.clone();
- let result = self.guess_feature_version(
- package_config,
- version.as_deref(),
- &branches,
- &["git", "rev-list", "%candidate%..%branch%"],
- path,
- );
- version = result.0;
- pretty_version = result.1;
- }
-
- if (version.is_none() || is_detached)
- && let Some((tag_v, tag_pretty)) = self.version_from_git_tags(path)
- {
- version = Some(tag_v);
- pretty_version = Some(tag_pretty);
- feature_version = None;
- feature_pretty_version = None;
- }
-
- if commit.is_none()
- && let Ok(out) = self
- .process
- .execute(&["git", "rev-parse", "HEAD"], Some(path))
- && out.status == 0
- {
- let trimmed = out.stdout.trim();
- if !trimmed.is_empty() {
- commit = Some(trimmed.to_string());
- }
- }
-
- version.as_ref()?;
- Some(GuessedVersion {
- version: version.unwrap(),
- commit,
- pretty_version,
- feature_version,
- feature_pretty_version,
- })
- }
-
- fn version_from_git_tags(&self, path: &Path) -> Option<(String, String)> {
- let out = self
- .process
- .execute(&["git", "describe", "--exact-match", "--tags"], Some(path))
- .ok()?;
- if out.status != 0 {
- return None;
- }
- let pretty = out.stdout.trim().to_string();
- if pretty.is_empty() {
- return None;
- }
- let normalized = Version::parse(&pretty).ok()?;
- Some((normalized.to_string(), pretty))
- }
-
- fn guess_hg_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> {
- let out = self.process.execute(&["hg", "branch"], Some(path)).ok()?;
- if out.status != 0 {
- return None;
- }
- let branch = out.stdout.trim().to_string();
- if branch.is_empty() {
- return None;
- }
- let version = normalize_branch(&branch);
- let is_feature = version.starts_with("dev-");
-
- if version == DEFAULT_BRANCH_ALIAS {
- return Some(GuessedVersion {
- version,
- commit: None,
- pretty_version: Some(format!("dev-{branch}")),
- feature_version: None,
- feature_pretty_version: None,
- });
- }
-
- if !is_feature {
- return Some(GuessedVersion {
- version: version.clone(),
- commit: None,
- pretty_version: Some(version),
- feature_version: None,
- feature_pretty_version: None,
- });
- }
-
- // List branches via `hg branches` (first whitespace-separated token per line).
- let branches_out = self.process.execute(&["hg", "branches"], Some(path)).ok()?;
- let branches: Vec<String> = if branches_out.status == 0 {
- branches_out
- .stdout
- .lines()
- .filter_map(|l| l.split_whitespace().next().map(str::to_string))
- .collect()
- } else {
- Vec::new()
- };
-
- let (out_version, out_pretty) = self.guess_feature_version(
- package_config,
- Some(&version),
- &branches,
- &[
- "hg",
- "log",
- "-r",
- "not ancestors('%candidate%') and ancestors('%branch%')",
- "--template",
- "\"{node}\\n\"",
- ],
- path,
- );
-
- Some(GuessedVersion {
- version: out_version.unwrap_or(version.clone()),
- commit: Some(String::new()),
- pretty_version: out_pretty,
- feature_version: Some(version.clone()),
- feature_pretty_version: Some(version),
- })
- }
-
- fn guess_svn_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> {
- let out = self
- .process
- .execute(&["svn", "info", "--xml"], Some(path))
- .ok()?;
- if out.status != 0 {
- return None;
- }
-
- let trunk = package_config
- .get("trunk-path")
- .and_then(Value::as_str)
- .unwrap_or("trunk");
- let branches = package_config
- .get("branches-path")
- .and_then(Value::as_str)
- .unwrap_or("branches");
- let tags = package_config
- .get("tags-path")
- .and_then(Value::as_str)
- .unwrap_or("tags");
-
- let pattern = format!(
- r"<url>.*/({trunk}|({branches}|{tags})/(.*))</url>",
- trunk = regex::escape(trunk),
- branches = regex::escape(branches),
- tags = regex::escape(tags),
- );
- let re = Regex::new(&pattern).ok()?;
- let caps = re.captures(&out.stdout)?;
-
- let kind = caps.get(2).map(|m| m.as_str().to_string());
- let inner = caps.get(3).map(|m| m.as_str().to_string());
-
- if let (Some(kind), Some(inner)) = (kind, inner)
- && (kind == branches || kind == tags)
- {
- let pretty = format!("dev-{inner}");
- return Some(GuessedVersion {
- version: normalize_branch(&inner),
- commit: Some(String::new()),
- pretty_version: Some(pretty),
- feature_version: None,
- feature_pretty_version: None,
- });
- }
-
- let trunk_match = caps.get(1)?;
- let pretty = trunk_match.as_str().trim().to_string();
- let version = if pretty == "trunk" {
- "dev-trunk".to_string()
- } else {
- Version::parse(&pretty).ok()?.to_string()
- };
- Some(GuessedVersion {
- version,
- commit: Some(String::new()),
- pretty_version: Some(pretty),
- feature_version: None,
- feature_pretty_version: None,
- })
- }
-
- /// Find the nearest non-feature branch by diff size. Sequential port of
- /// `guessFeatureVersion`; Composer runs candidates in parallel.
- fn guess_feature_version(
- &self,
- package_config: &Value,
- version: Option<&str>,
- branches: &[String],
- scm_cmdline: &[&str],
- path: &Path,
- ) -> (Option<String>, Option<String>) {
- let version = version.map(str::to_string);
- let pretty_version = version.clone();
-
- let Some(v) = version.clone() else {
- return (version, pretty_version);
- };
-
- // Skip if the branch has a non-self.version branch-alias OR self.version is referenced.
- let has_branch_alias = package_config
- .get("extra")
- .and_then(|e| e.get("branch-alias"))
- .and_then(|b| b.get(&v))
- .is_some();
- let uses_self_version = serde_json::to_string(package_config)
- .map(|s| s.contains("\"self.version\""))
- .unwrap_or(false);
- if has_branch_alias && !uses_self_version {
- return (Some(v), pretty_version);
- }
-
- // Composer also returns early if `self.version` is referenced — see L283.
- // The PHP precedence is: skip iff (no branch-alias) OR (json contains self.version).
- if uses_self_version {
- return (Some(v), pretty_version);
- }
-
- let branch = v.strip_prefix("dev-").unwrap_or(&v).to_string();
-
- if !is_feature_branch_name(package_config, &branch) {
- return (Some(v), pretty_version);
- }
-
- let mut sorted: Vec<String> = branches.to_vec();
- sorted.sort_by(|a, b| {
- let a_remote = a.starts_with("remotes/");
- let b_remote = b.starts_with("remotes/");
- if a_remote != b_remote {
- return if a_remote {
- std::cmp::Ordering::Greater
- } else {
- std::cmp::Ordering::Less
- };
- }
- // strnatcasecmp(b, a) — natural-sort, descending, case-insensitive.
- natural_cmp(&b.to_ascii_lowercase(), &a.to_ascii_lowercase())
- });
-
- let mut last_index: i64 = -1;
- let mut length: usize = usize::MAX;
- let mut version = Some(v);
- let mut pretty = pretty_version;
-
- for (index, candidate) in sorted.iter().enumerate() {
- let candidate_version = REMOTES_PREFIX_RE.replace(candidate, "").to_string();
- if candidate.as_str() == branch.as_str()
- || is_feature_branch_name(package_config, &candidate_version)
- {
- continue;
- }
- let cmd: Vec<String> = scm_cmdline
- .iter()
- .map(|c| {
- c.replace("%candidate%", candidate)
- .replace("%branch%", &branch)
- })
- .collect();
- let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
- let Ok(output) = self.process.execute(&cmd_refs, Some(path)) else {
- continue;
- };
- if output.status != 0 {
- continue;
- }
- let len = output.stdout.len();
- if len < length || (len == length && last_index < index as i64) {
- last_index = index as i64;
- length = len;
- version = Some(normalize_branch(&candidate_version));
- pretty = Some(format!("dev-{candidate_version}"));
- if length == 0 {
- break;
- }
- }
- }
-
- (version, pretty)
- }
-}
-
-fn postprocess(mut v: GuessedVersion) -> GuessedVersion {
- if v.feature_version.is_some()
- && v.feature_version == Some(v.version.clone())
- && v.feature_pretty_version == v.pretty_version
- {
- v.feature_version = None;
- v.feature_pretty_version = None;
- }
-
- if v.version.ends_with("-dev") && contains_long_nines(&v.version) {
- v.pretty_version = Some(replace_long_nines_with_x(&v.version));
- }
- if let Some(ref fv) = v.feature_version
- && fv.ends_with("-dev")
- && contains_long_nines(fv)
- {
- v.feature_pretty_version = Some(replace_long_nines_with_x(fv));
- }
- v
-}
-
-fn contains_long_nines(s: &str) -> bool {
- NINE_SEVEN_RE.is_match(s)
-}
-
-fn replace_long_nines_with_x(s: &str) -> String {
- NINE_SEVEN_GROUP_RE.replace_all(s, ".x").to_string()
-}
-
-fn is_feature_branch_name(package_config: &Value, branch_name: &str) -> bool {
- let mut non_feature = String::new();
- if let Some(arr) = package_config
- .get("non-feature-branches")
- .and_then(Value::as_array)
- {
- let parts: Vec<String> = arr
- .iter()
- .filter_map(|v| v.as_str().map(str::to_string))
- .collect();
- if !parts.is_empty() {
- non_feature = parts.join("|");
- }
- }
- let pattern = format!(
- r"^({non_feature}|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$"
- );
- let Ok(re) = Regex::new(&pattern) else {
- return true;
- };
- !re.is_match(branch_name)
-}
-
-/// Natural-order, case-insensitive string comparison (mirrors PHP `strnatcasecmp`).
-fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
- let mut ai = a.chars().peekable();
- let mut bi = b.chars().peekable();
- loop {
- match (ai.peek().copied(), bi.peek().copied()) {
- (None, None) => return std::cmp::Ordering::Equal,
- (None, _) => return std::cmp::Ordering::Less,
- (_, None) => return std::cmp::Ordering::Greater,
- (Some(ac), Some(bc)) => {
- if ac.is_ascii_digit() && bc.is_ascii_digit() {
- let mut na = String::new();
- let mut nb = String::new();
- while let Some(&c) = ai.peek() {
- if !c.is_ascii_digit() {
- break;
- }
- na.push(c);
- ai.next();
- }
- while let Some(&c) = bi.peek() {
- if !c.is_ascii_digit() {
- break;
- }
- nb.push(c);
- bi.next();
- }
- let na_v: u128 = na.parse().unwrap_or(0);
- let nb_v: u128 = nb.parse().unwrap_or(0);
- match na_v.cmp(&nb_v) {
- std::cmp::Ordering::Equal => continue,
- ord => return ord,
- }
- } else {
- match ac.cmp(&bc) {
- std::cmp::Ordering::Equal => {
- ai.next();
- bi.next();
- }
- ord => return ord,
- }
- }
- }
- }
- }
-}
-
-static CURRENT_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(
- r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$",
- )
- .unwrap()
-});
-
-static REMOTE_HEAD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap());
-
-static ANY_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap()
-});
-
-static REMOTES_PREFIX_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap());
-
-static NINE_SEVEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap());
-
-static NINE_SEVEN_GROUP_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\.9{7})+").unwrap());
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use serde_json::json;
-
- #[test]
- fn test_postprocess_strips_duplicate_feature() {
- let v = GuessedVersion {
- version: "1.0.0.0".into(),
- commit: None,
- pretty_version: Some("1.0.0".into()),
- feature_version: Some("1.0.0.0".into()),
- feature_pretty_version: Some("1.0.0".into()),
- };
- let p = postprocess(v);
- assert_eq!(p.feature_version, None);
- assert_eq!(p.feature_pretty_version, None);
- }
-
- #[test]
- fn test_postprocess_nine_seven_to_x() {
- let v = GuessedVersion {
- version: "1.9999999.9999999.9999999-dev".into(),
- commit: None,
- pretty_version: Some("dev-1.x".into()),
- feature_version: None,
- feature_pretty_version: None,
- };
- let p = postprocess(v);
- assert_eq!(p.pretty_version.as_deref(), Some("1.x-dev"));
- }
-
- #[test]
- fn test_is_feature_branch_known_mainlines() {
- let cfg = json!({});
- assert!(!is_feature_branch_name(&cfg, "master"));
- assert!(!is_feature_branch_name(&cfg, "main"));
- assert!(!is_feature_branch_name(&cfg, "develop"));
- assert!(!is_feature_branch_name(&cfg, "1.0"));
- assert!(is_feature_branch_name(&cfg, "feature/x"));
- }
-
- #[test]
- fn test_is_feature_branch_with_non_feature_list() {
- let cfg = json!({"non-feature-branches": ["staging", "release-.+"]});
- assert!(!is_feature_branch_name(&cfg, "staging"));
- assert!(!is_feature_branch_name(&cfg, "release-2"));
- assert!(is_feature_branch_name(&cfg, "wip-x"));
- }
-
- #[test]
- fn test_natural_cmp_orders_naturally() {
- assert_eq!(natural_cmp("1.10", "1.9"), std::cmp::Ordering::Greater);
- assert_eq!(natural_cmp("1.2", "1.10"), std::cmp::Ordering::Less);
- assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal);
- }
-}
diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs
deleted file mode 100644
index dd72ad6..0000000
--- a/crates/mozart-vcs/tests/git_driver_test.rs
+++ /dev/null
@@ -1,340 +0,0 @@
-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::{DriverConfig, DriverType, create_driver};
-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"]);
-}
-
-#[tokio::test]
-async 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_vcs_dir: cache_dir.path().to_path_buf(),
- ..DriverConfig::default()
- };
-
- let mut driver = create_driver(repo_dir.path().to_str().unwrap(), DriverType::Git, config);
-
- driver.initialize().await.unwrap();
- assert_eq!(driver.root_identifier(), "main");
-
- // Check tags
- let tags = driver.tags().await.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().await.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).await.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)
- .await
- .unwrap();
- assert!(content.is_some());
- assert!(content.unwrap().contains("test/package"));
-
- // Change date
- let date = driver.change_date(tag_hash).await.unwrap();
- assert!(date.is_some());
-
- // Source reference
- let source = driver.source(tag_hash);
- assert_eq!(source.source_type, "git");
-
- driver.cleanup().await.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.get_local_changes(&target).unwrap();
- assert!(changes.is_none(), "Expected no changes, got: {:?}", changes);
-
- // Untracked files alone must NOT count as local changes (matches
- // Composer's `git status --porcelain --untracked-files=no`).
- std::fs::write(target.join("untracked.txt"), "untracked").unwrap();
- let changes = downloader.get_local_changes(&target).unwrap();
- assert!(
- changes.is_none(),
- "Untracked files should be ignored, got: {:?}",
- changes
- );
-
- // Modifying a tracked file is a local change.
- std::fs::write(target.join("composer.json"), "{\"name\":\"changed\"}\n").unwrap();
- let changes = downloader.get_local_changes(&target).unwrap();
- assert!(changes.is_some());
- assert!(changes.unwrap().contains("composer.json"));
-
- // 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_git_downloader_unpushed_changes() {
- 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");
-
- downloader.download(url, "main", &target).unwrap();
- downloader.install(url, "main", &target).unwrap();
-
- // No commits added locally → no unpushed changes.
- let unpushed = downloader.unpushed_changes(&target).unwrap();
- assert!(
- unpushed.is_none(),
- "Expected no unpushed changes, got: {:?}",
- unpushed
- );
-
- // Commit a local change without pushing.
- let run = |args: &[&str]| {
- let output = Command::new(args[0])
- .args(&args[1..])
- .current_dir(&target)
- .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: {:?}", args);
- };
- std::fs::write(target.join("local-only.txt"), "local-only").unwrap();
- run(&["git", "add", "."]);
- run(&["git", "commit", "-m", "Local-only commit"]);
-
- let unpushed = downloader.unpushed_changes(&target).unwrap();
- assert!(unpushed.is_some(), "Expected unpushed changes");
- let body = unpushed.unwrap();
- assert!(
- body.contains("local-only.txt"),
- "Expected diff body to mention local-only.txt, got: {body}"
- );
-}
-
-#[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),
- );
-}
-
-#[tokio::test]
-async 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_vcs_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().await.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"
- );
-}