aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/vcs/downloader
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-core/src/vcs/downloader')
-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
4 files changed, 495 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
+ }
+}