aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/vcs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 20:01:21 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 20:01:21 +0900
commit24bb31c109332ae982b7091ffcd5183442ce6f6f (patch)
treece590a34053bd48506310c0a0c26395605973d45 /crates/mozart-core/src/vcs
parentdd13f29a3535bf15bb2494da4c67b5e2c61bbda5 (diff)
downloadphp-mozart-24bb31c109332ae982b7091ffcd5183442ce6f6f.tar.gz
php-mozart-24bb31c109332ae982b7091ffcd5183442ce6f6f.tar.zst
php-mozart-24bb31c109332ae982b7091ffcd5183442ce6f6f.zip
refactor(vcs): move VCS drivers under repository module
Mirror Composer's structure where VCS drivers live under Repository/Vcs/. Rename VcsDriver trait to VcsDriverInterface and BitbucketDriver to GitBitbucketDriver to match Composer naming, and add stubs for FossilDriver, PerforceDriver, and the VcsDriver base.
Diffstat (limited to 'crates/mozart-core/src/vcs')
-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/repository.rs2
9 files changed, 1 insertions, 2179 deletions
diff --git a/crates/mozart-core/src/vcs/driver/bitbucket.rs b/crates/mozart-core/src/vcs/driver/bitbucket.rs
deleted file mode 100644
index 2235e10..0000000
--- a/crates/mozart-core/src/vcs/driver/bitbucket.rs
+++ /dev/null
@@ -1,277 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-use anyhow::{Result, bail};
-use regex::Regex;
-use reqwest::Client;
-use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
-
-use super::git::GitDriver;
-use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
-
-/// Bitbucket VCS driver using the REST API 2.0.
-pub struct BitbucketDriver {
- owner: String,
- repo: String,
- url: String,
- root_identifier: Option<String>,
- tags: Option<BTreeMap<String, String>>,
- branches: Option<BTreeMap<String, String>>,
- info_cache: IndexMap<String, Option<serde_json::Value>>,
- git_driver: Option<Box<GitDriver>>,
- http_client: Client,
- config: DriverConfig,
- api_failed: bool,
- vcs_type: String, // "git" or "hg"
-}
-
-impl BitbucketDriver {
- pub fn new(url: &str, config: DriverConfig) -> Self {
- let (owner, repo) = Self::parse_url(url).unwrap_or_default();
- Self {
- owner,
- repo,
- url: url.to_string(),
- root_identifier: None,
- tags: None,
- branches: None,
- info_cache: IndexMap::new(),
- git_driver: None,
- http_client: 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
deleted file mode 100644
index 8a290c0..0000000
--- a/crates/mozart-core/src/vcs/driver/forgejo.rs
+++ /dev/null
@@ -1,285 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-use anyhow::{Result, bail};
-use regex::Regex;
-use reqwest::Client;
-use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
-
-use super::git::GitDriver;
-use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
-
-/// Forgejo/Gitea VCS driver using the REST API v1.
-///
-/// Supports self-hosted instances (Codeberg, etc.).
-pub struct ForgejoDriver {
- owner: String,
- repo: String,
- host: String,
- scheme: String,
- url: String,
- root_identifier: Option<String>,
- tags: Option<BTreeMap<String, String>>,
- branches: Option<BTreeMap<String, String>>,
- info_cache: IndexMap<String, Option<serde_json::Value>>,
- git_driver: Option<Box<GitDriver>>,
- http_client: Client,
- config: DriverConfig,
- api_failed: bool,
-}
-
-impl ForgejoDriver {
- pub fn new(url: &str, config: DriverConfig) -> Self {
- let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default();
- Self {
- owner,
- repo,
- host,
- scheme,
- url: url.to_string(),
- root_identifier: None,
- tags: None,
- branches: None,
- info_cache: IndexMap::new(),
- git_driver: None,
- http_client: 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
deleted file mode 100644
index 7d6643f..0000000
--- a/crates/mozart-core/src/vcs/driver/git.rs
+++ /dev/null
@@ -1,275 +0,0 @@
-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
deleted file mode 100644
index 4df2c1c..0000000
--- a/crates/mozart-core/src/vcs/driver/github.rs
+++ /dev/null
@@ -1,315 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-use anyhow::{Result, bail};
-use regex::Regex;
-use reqwest::Client;
-use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
-
-use super::git::GitDriver;
-use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
-
-/// GitHub VCS driver using the REST API v3.
-///
-/// Falls back to `GitDriver` when API access fails.
-pub struct GitHubDriver {
- owner: String,
- repo: String,
- url: String,
- root_identifier: Option<String>,
- tags: Option<BTreeMap<String, String>>,
- branches: Option<BTreeMap<String, String>>,
- repo_data: Option<serde_json::Value>,
- info_cache: IndexMap<String, Option<serde_json::Value>>,
- git_driver: Option<Box<GitDriver>>,
- http_client: Client,
- config: DriverConfig,
- api_failed: bool,
-}
-
-impl GitHubDriver {
- pub fn new(url: &str, config: DriverConfig) -> Self {
- let (owner, repo) = Self::parse_url(url).unwrap_or_default();
- Self {
- owner,
- repo,
- url: url.to_string(),
- root_identifier: None,
- tags: None,
- branches: None,
- repo_data: None,
- info_cache: IndexMap::new(),
- git_driver: None,
- http_client: 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 as _;
- 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
deleted file mode 100644
index f181e63..0000000
--- a/crates/mozart-core/src/vcs/driver/gitlab.rs
+++ /dev/null
@@ -1,301 +0,0 @@
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-use anyhow::{Result, bail};
-use regex::Regex;
-use reqwest::Client;
-use reqwest::header::{ACCEPT, USER_AGENT};
-
-use super::git::GitDriver;
-use super::{DistReference, DriverConfig, SourceReference, VcsDriver};
-
-/// GitLab VCS driver using the REST API v4.
-///
-/// Supports self-hosted GitLab instances.
-pub struct GitLabDriver {
- owner: String,
- repo: String,
- host: String,
- scheme: String,
- url: String,
- project_id: Option<String>,
- root_identifier: Option<String>,
- tags: Option<BTreeMap<String, String>>,
- branches: Option<BTreeMap<String, String>>,
- info_cache: IndexMap<String, Option<serde_json::Value>>,
- git_driver: Option<Box<GitDriver>>,
- http_client: Client,
- config: DriverConfig,
- api_failed: bool,
-}
-
-impl GitLabDriver {
- pub fn new(url: &str, config: DriverConfig) -> Self {
- let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default();
- Self {
- owner,
- repo,
- host,
- scheme,
- url: url.to_string(),
- project_id: None,
- root_identifier: None,
- tags: None,
- branches: None,
- info_cache: IndexMap::new(),
- git_driver: None,
- http_client: 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
deleted file mode 100644
index e2c3fcd..0000000
--- a/crates/mozart-core/src/vcs/driver/hg.rs
+++ /dev/null
@@ -1,202 +0,0 @@
-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
deleted file mode 100644
index cfaf11e..0000000
--- a/crates/mozart-core/src/vcs/driver/mod.rs
+++ /dev/null
@@ -1,309 +0,0 @@
-pub mod bitbucket;
-pub mod forgejo;
-pub mod git;
-pub mod github;
-pub mod gitlab;
-pub mod hg;
-pub mod svn;
-
-use std::collections::BTreeMap;
-use std::path::PathBuf;
-
-use anyhow::Result;
-use serde::{Deserialize, Serialize};
-
-/// Reference to a source distribution.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SourceReference {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: String,
-}
-
-/// Reference to a dist (archive) distribution.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct DistReference {
- #[serde(rename = "type")]
- pub dist_type: String,
- pub url: String,
- pub reference: String,
- pub shasum: Option<String>,
-}
-
-/// Configuration passed to VCS drivers.
-#[derive(Debug, Clone)]
-pub struct DriverConfig {
- /// Composer's `cache-vcs-dir`: root for VCS mirrors, one
- /// subdirectory per sanitized repository URL.
- pub cache_vcs_dir: PathBuf,
- /// GitHub OAuth token (from `GITHUB_TOKEN` or config).
- pub github_token: Option<String>,
- /// GitLab OAuth token.
- pub gitlab_token: Option<String>,
- /// Bitbucket OAuth consumer key/secret.
- pub bitbucket_oauth: Option<(String, String)>,
- /// Forgejo token.
- pub forgejo_token: Option<String>,
- /// Custom GitLab domains (for self-hosted).
- pub gitlab_domains: Vec<String>,
- /// Custom Forgejo domains (for self-hosted).
- pub forgejo_domains: Vec<String>,
-}
-
-impl Default for DriverConfig {
- fn default() -> Self {
- Self {
- cache_vcs_dir: default_cache_vcs_dir(),
- github_token: None,
- gitlab_token: None,
- bitbucket_oauth: None,
- forgejo_token: None,
- gitlab_domains: vec!["gitlab.com".to_string()],
- forgejo_domains: vec!["codeberg.org".to_string()],
- }
- }
-}
-
-/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars.
-///
-/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` →
-/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`.
-fn default_cache_vcs_dir() -> PathBuf {
- if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") {
- return PathBuf::from(p);
- }
- let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") {
- PathBuf::from(p)
- } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
- PathBuf::from(xdg).join("mozart")
- } else if let Ok(home) = std::env::var("HOME") {
- PathBuf::from(home).join(".cache").join("mozart")
- } else {
- PathBuf::from("/tmp").join("mozart")
- };
- base.join("vcs")
-}
-
-/// Type of VCS driver.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum DriverType {
- GitHub,
- GitLab,
- Bitbucket,
- Forgejo,
- Git,
- Svn,
- Hg,
-}
-
-/// The VCS driver interface.
-///
-/// Corresponds to Composer's `VcsDriverInterface`.
-trait VcsDriver {
- /// Initialize the driver (e.g., clone mirror, fetch API metadata).
- async fn initialize(&mut self) -> Result<()>;
-
- /// The root identifier (default branch/trunk).
- fn root_identifier(&self) -> &str;
-
- /// All branches as `name -> commit_hash`.
- async fn branches(&mut self) -> Result<&BTreeMap<String, String>>;
-
- /// All tags as `name -> commit_hash`.
- async fn tags(&mut self) -> Result<&BTreeMap<String, String>>;
-
- /// Get composer.json content parsed as JSON for a given identifier.
- async fn composer_information(&mut self, identifier: &str)
- -> Result<Option<serde_json::Value>>;
-
- /// Get raw file content at a given path and identifier.
- async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>;
-
- /// Get the change date for a given identifier (ISO 8601).
- async fn change_date(&self, identifier: &str) -> Result<Option<String>>;
-
- /// Get the dist reference for a given identifier.
- async fn dist(&self, identifier: &str) -> Result<Option<DistReference>>;
-
- /// Get the source reference for a given identifier.
- fn source(&self, identifier: &str) -> SourceReference;
-
- /// The canonical URL of this repository.
- fn url(&self) -> &str;
-
- /// Clean up resources (temp dirs, etc.).
- async fn cleanup(&mut self) -> Result<()>;
-}
-
-/// Enum-dispatched VCS driver.
-///
-/// Wraps all concrete driver types to allow static dispatch with async trait methods.
-pub enum AnyVcsDriver {
- GitHub(github::GitHubDriver),
- GitLab(gitlab::GitLabDriver),
- Bitbucket(bitbucket::BitbucketDriver),
- Forgejo(forgejo::ForgejoDriver),
- Git(git::GitDriver),
- Svn(svn::SvnDriver),
- Hg(hg::HgDriver),
-}
-
-macro_rules! dispatch {
- ($self:expr, $method:ident $(, $arg:expr)*) => {
- match $self {
- AnyVcsDriver::GitHub(d) => d.$method($($arg),*),
- AnyVcsDriver::GitLab(d) => d.$method($($arg),*),
- AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*),
- AnyVcsDriver::Forgejo(d) => d.$method($($arg),*),
- AnyVcsDriver::Git(d) => d.$method($($arg),*),
- AnyVcsDriver::Svn(d) => d.$method($($arg),*),
- AnyVcsDriver::Hg(d) => d.$method($($arg),*),
- }
- };
-}
-
-macro_rules! dispatch_async {
- ($self:expr, $method:ident $(, $arg:expr)*) => {
- match $self {
- AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await,
- AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Git(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Svn(d) => d.$method($($arg),*).await,
- AnyVcsDriver::Hg(d) => d.$method($($arg),*).await,
- }
- };
-}
-
-impl AnyVcsDriver {
- pub async fn initialize(&mut self) -> Result<()> {
- dispatch_async!(self, initialize)
- }
-
- pub fn root_identifier(&self) -> &str {
- dispatch!(self, root_identifier)
- }
-
- pub async fn branches(&mut self) -> Result<&BTreeMap<String, String>> {
- dispatch_async!(self, branches)
- }
-
- pub async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
- dispatch_async!(self, tags)
- }
-
- pub async fn composer_information(
- &mut self,
- identifier: &str,
- ) -> Result<Option<serde_json::Value>> {
- dispatch_async!(self, composer_information, identifier)
- }
-
- pub async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
- dispatch_async!(self, file_content, file, identifier)
- }
-
- pub async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
- dispatch_async!(self, change_date, identifier)
- }
-
- pub async fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
- dispatch_async!(self, dist, identifier)
- }
-
- pub fn source(&self, identifier: &str) -> SourceReference {
- dispatch!(self, source, identifier)
- }
-
- pub fn url(&self) -> &str {
- dispatch!(self, url)
- }
-
- pub async fn cleanup(&mut self) -> Result<()> {
- dispatch_async!(self, cleanup)
- }
-}
-
-/// Detect which driver type should handle a given URL.
-///
-/// Priority order matches Composer:
-/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN
-pub fn detect_driver(
- url: &str,
- forced_type: Option<&str>,
- config: &DriverConfig,
-) -> Option<DriverType> {
- if let Some(t) = forced_type {
- return match t {
- "github" => Some(DriverType::GitHub),
- "gitlab" => Some(DriverType::GitLab),
- "bitbucket" => Some(DriverType::Bitbucket),
- "forgejo" => Some(DriverType::Forgejo),
- "git" => Some(DriverType::Git),
- "svn" => Some(DriverType::Svn),
- "hg" | "mercurial" => Some(DriverType::Hg),
- _ => None,
- };
- }
-
- let url_lower = url.to_lowercase();
-
- // GitHub
- if github::GitHubDriver::supports(url) {
- return Some(DriverType::GitHub);
- }
-
- // GitLab
- if gitlab::GitLabDriver::supports(url, &config.gitlab_domains) {
- return Some(DriverType::GitLab);
- }
-
- // Bitbucket
- if bitbucket::BitbucketDriver::supports(url) {
- return Some(DriverType::Bitbucket);
- }
-
- // Forgejo
- if forgejo::ForgejoDriver::supports(url, &config.forgejo_domains) {
- return Some(DriverType::Forgejo);
- }
-
- // Git
- if git::GitDriver::supports(url) {
- return Some(DriverType::Git);
- }
-
- // Hg
- if hg::HgDriver::supports(url) {
- return Some(DriverType::Hg);
- }
-
- // SVN
- if url_lower.contains("svn") || svn::SvnDriver::supports(url) {
- return Some(DriverType::Svn);
- }
-
- // Default to git for generic URLs
- if url.starts_with("http://") || url.starts_with("https://") {
- return Some(DriverType::Git);
- }
-
- None
-}
-
-/// Create a driver instance for the given URL and type.
-pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver {
- match driver_type {
- DriverType::GitHub => AnyVcsDriver::GitHub(github::GitHubDriver::new(url, config)),
- DriverType::GitLab => AnyVcsDriver::GitLab(gitlab::GitLabDriver::new(url, config)),
- DriverType::Bitbucket => {
- AnyVcsDriver::Bitbucket(bitbucket::BitbucketDriver::new(url, config))
- }
- DriverType::Forgejo => AnyVcsDriver::Forgejo(forgejo::ForgejoDriver::new(url, config)),
- DriverType::Git => AnyVcsDriver::Git(git::GitDriver::new(url, config)),
- DriverType::Svn => AnyVcsDriver::Svn(svn::SvnDriver::new(url, config)),
- DriverType::Hg => AnyVcsDriver::Hg(hg::HgDriver::new(url, config)),
- }
-}
diff --git a/crates/mozart-core/src/vcs/driver/svn.rs b/crates/mozart-core/src/vcs/driver/svn.rs
deleted file mode 100644
index 7ba9e86..0000000
--- a/crates/mozart-core/src/vcs/driver/svn.rs
+++ /dev/null
@@ -1,214 +0,0 @@
-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/repository.rs b/crates/mozart-core/src/vcs/repository.rs
index 55f98f9..8da847a 100644
--- a/crates/mozart-core/src/vcs/repository.rs
+++ b/crates/mozart-core/src/vcs/repository.rs
@@ -1,4 +1,4 @@
-use super::driver::{
+use crate::repository::vcs::{
DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver,
};
use anyhow::{Result, bail};