aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/vcs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-core/src/vcs')
-rw-r--r--crates/mozart-core/src/vcs/downloader/git.rs271
-rw-r--r--crates/mozart-core/src/vcs/downloader/hg.rs84
-rw-r--r--crates/mozart-core/src/vcs/downloader/mod.rs56
-rw-r--r--crates/mozart-core/src/vcs/downloader/svn.rs84
-rw-r--r--crates/mozart-core/src/vcs/driver/bitbucket.rs277
-rw-r--r--crates/mozart-core/src/vcs/driver/forgejo.rs285
-rw-r--r--crates/mozart-core/src/vcs/driver/git.rs275
-rw-r--r--crates/mozart-core/src/vcs/driver/github.rs315
-rw-r--r--crates/mozart-core/src/vcs/driver/gitlab.rs301
-rw-r--r--crates/mozart-core/src/vcs/driver/hg.rs202
-rw-r--r--crates/mozart-core/src/vcs/driver/mod.rs309
-rw-r--r--crates/mozart-core/src/vcs/driver/svn.rs214
-rw-r--r--crates/mozart-core/src/vcs/process.rs142
-rw-r--r--crates/mozart-core/src/vcs/repository.rs205
-rw-r--r--crates/mozart-core/src/vcs/util/git.rs312
-rw-r--r--crates/mozart-core/src/vcs/util/hg.rs28
-rw-r--r--crates/mozart-core/src/vcs/util/mod.rs3
-rw-r--r--crates/mozart-core/src/vcs/util/svn.rs89
-rw-r--r--crates/mozart-core/src/vcs/version_guesser.rs602
19 files changed, 4054 insertions, 0 deletions
diff --git a/crates/mozart-core/src/vcs/downloader/git.rs b/crates/mozart-core/src/vcs/downloader/git.rs
new file mode 100644
index 0000000..eb7a649
--- /dev/null
+++ b/crates/mozart-core/src/vcs/downloader/git.rs
@@ -0,0 +1,271 @@
+use super::super::process::ProcessExecutor;
+use super::super::util::git::GitUtil;
+use super::VcsDownloader;
+use anyhow::Result;
+use regex::Regex;
+use std::path::Path;
+use std::sync::LazyLock;
+
+/// 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-core/src/vcs/downloader/hg.rs b/crates/mozart-core/src/vcs/downloader/hg.rs
new file mode 100644
index 0000000..33650f8
--- /dev/null
+++ b/crates/mozart-core/src/vcs/downloader/hg.rs
@@ -0,0 +1,84 @@
+use super::super::util::hg::HgUtil;
+use super::VcsDownloader;
+use anyhow::Result;
+use std::path::Path;
+
+/// 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-core/src/vcs/downloader/mod.rs b/crates/mozart-core/src/vcs/downloader/mod.rs
new file mode 100644
index 0000000..352f330
--- /dev/null
+++ b/crates/mozart-core/src/vcs/downloader/mod.rs
@@ -0,0 +1,56 @@
+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-core/src/vcs/downloader/svn.rs b/crates/mozart-core/src/vcs/downloader/svn.rs
new file mode 100644
index 0000000..ea885ed
--- /dev/null
+++ b/crates/mozart-core/src/vcs/downloader/svn.rs
@@ -0,0 +1,84 @@
+use super::super::util::svn::SvnUtil;
+use super::VcsDownloader;
+use anyhow::Result;
+use regex::Regex;
+use std::path::Path;
+use std::sync::LazyLock;
+
+/// 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-core/src/vcs/driver/bitbucket.rs b/crates/mozart-core/src/vcs/driver/bitbucket.rs
new file mode 100644
index 0000000..2235e10
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/bitbucket.rs
@@ -0,0 +1,277 @@
+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: crate::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-core/src/vcs/driver/forgejo.rs b/crates/mozart-core/src/vcs/driver/forgejo.rs
new file mode 100644
index 0000000..8a290c0
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/forgejo.rs
@@ -0,0 +1,285 @@
+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: crate::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-core/src/vcs/driver/git.rs b/crates/mozart-core/src/vcs/driver/git.rs
new file mode 100644
index 0000000..7d6643f
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/git.rs
@@ -0,0 +1,275 @@
+use super::super::process::ProcessExecutor;
+use super::super::util::git::GitUtil;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+use anyhow::Result;
+use indexmap::IndexMap;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+/// 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-core/src/vcs/driver/github.rs b/crates/mozart-core/src/vcs/driver/github.rs
new file mode 100644
index 0000000..7772bbb
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/github.rs
@@ -0,0 +1,315 @@
+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: crate::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-core/src/vcs/driver/gitlab.rs b/crates/mozart-core/src/vcs/driver/gitlab.rs
new file mode 100644
index 0000000..f181e63
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/gitlab.rs
@@ -0,0 +1,301 @@
+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: crate::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-core/src/vcs/driver/hg.rs b/crates/mozart-core/src/vcs/driver/hg.rs
new file mode 100644
index 0000000..e2c3fcd
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/hg.rs
@@ -0,0 +1,202 @@
+use super::super::process::ProcessExecutor;
+use super::super::util::hg::HgUtil;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+use anyhow::Result;
+use indexmap::IndexMap;
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+/// 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(super::super::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-core/src/vcs/driver/mod.rs b/crates/mozart-core/src/vcs/driver/mod.rs
new file mode 100644
index 0000000..cfaf11e
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/mod.rs
@@ -0,0 +1,309 @@
+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-core/src/vcs/driver/svn.rs b/crates/mozart-core/src/vcs/driver/svn.rs
new file mode 100644
index 0000000..7ba9e86
--- /dev/null
+++ b/crates/mozart-core/src/vcs/driver/svn.rs
@@ -0,0 +1,214 @@
+use super::super::process::ProcessExecutor;
+use super::super::util::svn::SvnUtil;
+use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
+use anyhow::Result;
+use indexmap::IndexMap;
+use regex::Regex;
+use std::collections::BTreeMap;
+
+/// 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-core/src/vcs/process.rs b/crates/mozart-core/src/vcs/process.rs
new file mode 100644
index 0000000..8ccc11d
--- /dev/null
+++ b/crates/mozart-core/src/vcs/process.rs
@@ -0,0 +1,142 @@
+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-core/src/vcs/repository.rs b/crates/mozart-core/src/vcs/repository.rs
new file mode 100644
index 0000000..55f98f9
--- /dev/null
+++ b/crates/mozart-core/src/vcs/repository.rs
@@ -0,0 +1,205 @@
+use super::driver::{
+ DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver,
+};
+use anyhow::{Result, bail};
+
+/// 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-core/src/vcs/util/git.rs b/crates/mozart-core/src/vcs/util/git.rs
new file mode 100644
index 0000000..15bfa09
--- /dev/null
+++ b/crates/mozart-core/src/vcs/util/git.rs
@@ -0,0 +1,312 @@
+use super::super::process::{ProcessExecutor, ProcessOutput};
+use anyhow::{Result, bail};
+use regex::Regex;
+use std::path::{Path, PathBuf};
+use std::sync::LazyLock;
+
+/// 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-core/src/vcs/util/hg.rs b/crates/mozart-core/src/vcs/util/hg.rs
new file mode 100644
index 0000000..73051b7
--- /dev/null
+++ b/crates/mozart-core/src/vcs/util/hg.rs
@@ -0,0 +1,28 @@
+use super::super::process::{ProcessExecutor, ProcessOutput};
+use anyhow::Result;
+use std::path::Path;
+
+/// 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-core/src/vcs/util/mod.rs b/crates/mozart-core/src/vcs/util/mod.rs
new file mode 100644
index 0000000..b2c35fc
--- /dev/null
+++ b/crates/mozart-core/src/vcs/util/mod.rs
@@ -0,0 +1,3 @@
+pub mod git;
+pub mod hg;
+pub mod svn;
diff --git a/crates/mozart-core/src/vcs/util/svn.rs b/crates/mozart-core/src/vcs/util/svn.rs
new file mode 100644
index 0000000..d989fc8
--- /dev/null
+++ b/crates/mozart-core/src/vcs/util/svn.rs
@@ -0,0 +1,89 @@
+use super::super::process::{ProcessExecutor, ProcessOutput};
+use anyhow::Result;
+use std::path::Path;
+
+/// 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-core/src/vcs/version_guesser.rs b/crates/mozart-core/src/vcs/version_guesser.rs
new file mode 100644
index 0000000..58b758e
--- /dev/null
+++ b/crates/mozart-core/src/vcs/version_guesser.rs
@@ -0,0 +1,602 @@
+//! `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 super::process::ProcessExecutor;
+use mozart_semver::{Version, normalize_branch};
+use regex::Regex;
+use serde_json::Value;
+use std::path::Path;
+use std::sync::LazyLock;
+
+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);
+ }
+}