diff options
Diffstat (limited to 'crates/mozart-core/src/vcs/downloader')
| -rw-r--r-- | crates/mozart-core/src/vcs/downloader/git.rs | 271 | ||||
| -rw-r--r-- | crates/mozart-core/src/vcs/downloader/hg.rs | 84 | ||||
| -rw-r--r-- | crates/mozart-core/src/vcs/downloader/mod.rs | 56 | ||||
| -rw-r--r-- | crates/mozart-core/src/vcs/downloader/svn.rs | 84 |
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 + } +} |
