diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 19:52:18 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 19:52:18 +0900 |
| commit | 5cb8fc4e306970764e84bb850da2c56f844c3b12 (patch) | |
| tree | 0d66f3129a26138fcfee9402616b24929c40a017 /crates | |
| parent | d83b9ef48775aeb31ba1909b29d5470e6d0ddaaa (diff) | |
| download | php-mozart-5cb8fc4e306970764e84bb850da2c56f844c3b12.tar.gz php-mozart-5cb8fc4e306970764e84bb850da2c56f844c3b12.tar.zst php-mozart-5cb8fc4e306970764e84bb850da2c56f844c3b12.zip | |
fix(status): align with Composer's StatusCommand pipeline
Replace the dist-hash tree-diff implementation with Composer's VCS-level
status flow: three buckets (errors / unpushed_changes / vcs_version_changes)
populated via ChangeReportInterface / DvcsDownloaderInterface /
VcsCapableDownloaderInterface, and a bitfield exit code (1|2|4) instead
of always 1.
Supporting work:
- mozart-semver: add normalize_branch (VersionParser::normalizeBranch).
- mozart-vcs: extend VcsDownloader trait with unpushed_changes /
vcs_reference; port GitDownloader::getUnpushedChanges (HEAD-ref
discovery + git diff --name-status remote...branch + two-pass fetch);
fix git status invocation to use --untracked-files=no (Composer parity);
add hasMetadataRepository preconditions to git/hg/svn local_changes;
port VersionGuesser (git/hg/svn dispatch — Fossil omitted, feature
branch detection runs sequentially instead of via async promises).
- mozart-core: extend LocalPackage with pretty_version, package_type,
installation_source, source, dist, extra; add InstallationSource and
PackageReference. factory.rs reads them from installed.json.
- mozart-registry: new download_manager mirroring
DownloadManager::getDownloaderForPackage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-core/src/composer.rs | 100 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 57 | ||||
| -rw-r--r-- | crates/mozart-registry/src/download_manager.rs | 140 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-semver/src/lib.rs | 104 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/downloader/git.rs | 156 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/downloader/hg.rs | 8 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/downloader/mod.rs | 16 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/downloader/svn.rs | 19 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/version_guesser.rs | 583 | ||||
| -rw-r--r-- | crates/mozart-vcs/tests/git_driver_test.rs | 72 | ||||
| -rw-r--r-- | crates/mozart/src/commands/status.rs | 713 |
13 files changed, 1396 insertions, 574 deletions
diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs index 6fe022a..66bae92 100644 --- a/crates/mozart-core/src/composer.rs +++ b/crates/mozart-core/src/composer.rs @@ -92,20 +92,72 @@ pub struct Composer { locker: Locker, } -/// Subset of `Composer\Package\PackageInterface` needed by the -/// installation manager. Today only the fields referenced by -/// `LibraryInstaller::getInstallPath` (`prettyName`, `targetDir`). +/// Which source the package was installed from. Mirrors +/// `PackageInterface::getInstallationSource` ("source" | "dist"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallationSource { + Source, + Dist, +} + +impl InstallationSource { + /// Parse the `installation-source` field from `installed.json`. + pub fn parse(s: &str) -> Option<Self> { + match s { + "source" => Some(InstallationSource::Source), + "dist" => Some(InstallationSource::Dist), + _ => None, + } + } +} + +/// Source/dist descriptor — mirrors the nested `source`/`dist` objects in +/// `installed.json`. +#[derive(Debug, Clone)] +pub struct PackageReference { + pub kind: String, + pub url: String, + pub reference: Option<String>, + pub shasum: Option<String>, +} + +/// Subset of `Composer\Package\PackageInterface` carried through Mozart's +/// `LocalRepository`. Holds the fields needed by both the installation +/// manager (`prettyName`, `targetDir`) and the status command +/// (installation source, source/dist refs, version, extra). #[derive(Debug, Clone)] pub struct LocalPackage { pretty_name: String, + pretty_version: String, target_dir: Option<String>, + package_type: Option<String>, + installation_source: Option<InstallationSource>, + source: Option<PackageReference>, + dist: Option<PackageReference>, + extra: serde_json::Value, } impl LocalPackage { - pub fn new(pretty_name: String, target_dir: Option<String>) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new( + pretty_name: String, + pretty_version: String, + target_dir: Option<String>, + package_type: Option<String>, + installation_source: Option<InstallationSource>, + source: Option<PackageReference>, + dist: Option<PackageReference>, + extra: serde_json::Value, + ) -> Self { Self { pretty_name, + pretty_version, target_dir, + package_type, + installation_source, + source, + dist, + extra, } } @@ -115,11 +167,51 @@ impl LocalPackage { &self.pretty_name } + /// Original-case version string (e.g. `v1.0.0`). Mirrors + /// `PackageInterface::getPrettyVersion`. + pub fn pretty_version(&self) -> &str { + &self.pretty_version + } + /// Optional sub-directory inside the install path that holds the /// package code. Mirrors `PackageInterface::getTargetDir`. pub fn target_dir(&self) -> Option<&str> { self.target_dir.as_deref() } + + /// Mirrors `PackageInterface::getType`. + pub fn package_type(&self) -> Option<&str> { + self.package_type.as_deref() + } + + /// Mirrors `PackageInterface::getInstallationSource`. + pub fn installation_source(&self) -> Option<InstallationSource> { + self.installation_source + } + + pub fn source(&self) -> Option<&PackageReference> { + self.source.as_ref() + } + + pub fn dist(&self) -> Option<&PackageReference> { + self.dist.as_ref() + } + + /// Mirrors `PackageInterface::getSourceReference`. + pub fn source_reference(&self) -> Option<&str> { + self.source.as_ref().and_then(|r| r.reference.as_deref()) + } + + /// Mirrors `PackageInterface::getDistReference`. + pub fn dist_reference(&self) -> Option<&str> { + self.dist.as_ref().and_then(|r| r.reference.as_deref()) + } + + /// Raw `extra` field — used by VersionGuesser to read + /// `branch-alias`, `non-feature-branches`, etc. + pub fn extra(&self) -> &serde_json::Value { + &self.extra + } } /// In-memory mirror of `Composer\Repository\InstalledFilesystemRepository` diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs index 92aa70e..c9d346b 100644 --- a/crates/mozart-core/src/factory.rs +++ b/crates/mozart-core/src/factory.rs @@ -276,15 +276,70 @@ fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<Vec<LocalPackage>> { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); + let pretty_version = entry + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let target_dir = entry .get("target-dir") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - out.push(LocalPackage::new(pretty_name, target_dir)); + let package_type = entry + .get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let installation_source = entry + .get("installation-source") + .and_then(|v| v.as_str()) + .and_then(crate::composer::InstallationSource::parse); + let source = read_package_reference(entry.get("source")); + let dist = read_package_reference(entry.get("dist")); + let extra = entry + .get("extra") + .cloned() + .unwrap_or(serde_json::Value::Null); + out.push(LocalPackage::new( + pretty_name, + pretty_version, + target_dir, + package_type, + installation_source, + source, + dist, + extra, + )); } Ok(out) } +fn read_package_reference( + value: Option<&serde_json::Value>, +) -> Option<crate::composer::PackageReference> { + let v = value?; + let kind = v.get("type").and_then(|x| x.as_str())?.to_string(); + let url = v + .get("url") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let reference = v + .get("reference") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let shasum = v + .get("shasum") + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Some(crate::composer::PackageReference { + kind, + url, + reference, + shasum, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mozart-registry/src/download_manager.rs b/crates/mozart-registry/src/download_manager.rs new file mode 100644 index 0000000..3e05517 --- /dev/null +++ b/crates/mozart-registry/src/download_manager.rs @@ -0,0 +1,140 @@ +//! `DownloadManager` — pick the right [`VcsDownloader`] for a given +//! [`LocalPackage`]. Mirrors `Composer\Downloader\DownloadManager`. + +use std::path::PathBuf; + +use mozart_core::composer::{InstallationSource, LocalPackage}; +use mozart_vcs::downloader::VcsDownloader; +use mozart_vcs::downloader::git::GitDownloader; +use mozart_vcs::downloader::hg::HgDownloader; +use mozart_vcs::downloader::svn::SvnDownloader; +use mozart_vcs::process::ProcessExecutor; +use mozart_vcs::util::git::GitUtil; +use mozart_vcs::util::hg::HgUtil; +use mozart_vcs::util::svn::SvnUtil; + +/// Selects a `VcsDownloader` for a package based on its installation source +/// and source type. Mirrors `DownloadManager::getDownloaderForPackage`: +/// +/// - `metapackage` → `None`. +/// - `installation-source: dist` → `None` (Composer would return a +/// `FileDownloader`-family object that does not implement +/// `ChangeReportInterface` / `DvcsDownloaderInterface`, so the status +/// command's `instanceof` checks all become no-ops; returning `None` +/// directly is the equivalent in our trait-object world). +/// - `installation-source: source` → the matching VCS downloader by +/// `source.type` (`git` / `hg` / `svn`). +pub struct DownloadManager { + git_cache_dir: PathBuf, +} + +impl DownloadManager { + /// `git_cache_dir`: where `GitUtil` should keep mirror clones (e.g. + /// `<vendor>/.cache/git`). + pub fn new(git_cache_dir: PathBuf) -> Self { + Self { git_cache_dir } + } + + pub fn for_package(&self, package: &LocalPackage) -> Option<Box<dyn VcsDownloader>> { + if package.package_type() == Some("metapackage") { + return None; + } + match package.installation_source()? { + InstallationSource::Dist => None, + InstallationSource::Source => { + let kind = package.source()?.kind.as_str(); + match kind { + "git" => { + let git_util = + GitUtil::new(ProcessExecutor::new(), self.git_cache_dir.clone()); + Some(Box::new(GitDownloader::new(git_util))) + } + "hg" => { + let hg_util = HgUtil::new(ProcessExecutor::new()); + Some(Box::new(HgDownloader::new(hg_util))) + } + "svn" => { + let svn_util = SvnUtil::new(ProcessExecutor::new()); + Some(Box::new(SvnDownloader::new(svn_util))) + } + _ => None, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mozart_core::composer::PackageReference; + use serde_json::Value; + + fn pkg( + installation_source: Option<InstallationSource>, + source_kind: Option<&str>, + ) -> LocalPackage { + let source = source_kind.map(|kind| PackageReference { + kind: kind.to_string(), + url: "https://example/repo".into(), + reference: Some("abc123".into()), + shasum: None, + }); + LocalPackage::new( + "vendor/pkg".into(), + "1.0.0".into(), + None, + Some("library".into()), + installation_source, + source, + None, + Value::Null, + ) + } + + #[test] + fn metapackage_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let mut p = pkg(Some(InstallationSource::Source), Some("git")); + // override type + p = LocalPackage::new( + "vendor/pkg".into(), + "1.0.0".into(), + None, + Some("metapackage".into()), + p.installation_source(), + p.source().cloned(), + None, + Value::Null, + ); + assert!(dm.for_package(&p).is_none()); + } + + #[test] + fn dist_install_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Dist), Some("git")); + assert!(dm.for_package(&p).is_none()); + } + + #[test] + fn source_install_with_git_returns_some() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Source), Some("git")); + assert!(dm.for_package(&p).is_some()); + } + + #[test] + fn unknown_source_kind_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Source), Some("perforce")); + assert!(dm.for_package(&p).is_none()); + } + + #[test] + fn missing_installation_source_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(None, Some("git")); + assert!(dm.for_package(&p).is_none()); + } +} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index 36a12c6..9d72c36 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -1,6 +1,7 @@ pub mod browse_repos; pub mod cache; pub mod composer_repo; +pub mod download_manager; pub mod downloader; pub mod inline_package; pub mod installed; diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs index bad1690..013579d 100644 --- a/crates/mozart-semver/src/lib.rs +++ b/crates/mozart-semver/src/lib.rs @@ -954,6 +954,63 @@ fn hyphen_upper_bound(raw: &str) -> Result<VersionConstraint, String> { Ok(VersionConstraint::Single(Constraint::LessThan(next))) } +/// Normalize a branch name into a normalized "X.Y.Z.W-dev" form, mirroring +/// `Composer\Semver\VersionParser::normalizeBranch`. Numeric branches like +/// `1.0` or `2.x` are zero-padded out to four segments with `9999999` +/// substituted for `x`/`X`/`*`. Any other shape (e.g. `main`, `feat/x`) +/// becomes `dev-<branch>`. +pub fn normalize_branch(name: &str) -> String { + let trimmed = name.trim(); + + let stripped = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed); + + if stripped.is_empty() { + return format!("dev-{name}"); + } + + let parts: Vec<&str> = stripped.split('.').collect(); + if parts.len() > 4 { + return format!("dev-{name}"); + } + + if parts[0].is_empty() || !parts[0].chars().all(|c| c.is_ascii_digit()) { + return format!("dev-{name}"); + } + for seg in &parts[1..] { + if seg.is_empty() { + return format!("dev-{name}"); + } + let all_digits = seg.chars().all(|c| c.is_ascii_digit()); + let single_wildcard = + seg.len() == 1 && matches!(seg.chars().next().unwrap(), 'x' | 'X' | '*'); + if !all_digits && !single_wildcard { + return format!("dev-{name}"); + } + } + + let mut out = String::with_capacity(stripped.len() + 32); + out.push_str(parts[0]); + for i in 1..4 { + out.push('.'); + match parts.get(i) { + None => out.push_str("9999999"), + Some(seg) => { + let first = seg.chars().next().unwrap(); + if seg.len() == 1 && matches!(first, 'x' | 'X' | '*') { + out.push_str("9999999"); + } else { + out.push_str(seg); + } + } + } + } + out.push_str("-dev"); + out +} + #[cfg(test)] mod tests { use super::*; @@ -2346,4 +2403,51 @@ mod tests { let b = VersionConstraint::parse(">=2.0 <=3.0").unwrap(); assert!(a.intersects(&b)); } + + #[test] + fn test_normalize_branch_numeric_full() { + assert_eq!(normalize_branch("1.0.0"), "1.0.0.9999999-dev"); + assert_eq!(normalize_branch("1.0.0.5"), "1.0.0.5-dev"); + } + + #[test] + fn test_normalize_branch_numeric_short() { + assert_eq!(normalize_branch("1"), "1.9999999.9999999.9999999-dev"); + assert_eq!(normalize_branch("1.0"), "1.0.9999999.9999999-dev"); + } + + #[test] + fn test_normalize_branch_wildcards() { + assert_eq!(normalize_branch("2.x"), "2.9999999.9999999.9999999-dev"); + assert_eq!(normalize_branch("1.0.x"), "1.0.9999999.9999999-dev"); + assert_eq!(normalize_branch("1.0.X"), "1.0.9999999.9999999-dev"); + assert_eq!(normalize_branch("1.0.*"), "1.0.9999999.9999999-dev"); + } + + #[test] + fn test_normalize_branch_v_prefix() { + assert_eq!(normalize_branch("v1.2"), "1.2.9999999.9999999-dev"); + assert_eq!(normalize_branch("V2"), "2.9999999.9999999.9999999-dev"); + } + + #[test] + fn test_normalize_branch_non_numeric() { + assert_eq!(normalize_branch("master"), "dev-master"); + assert_eq!(normalize_branch("main"), "dev-main"); + assert_eq!(normalize_branch("feature/x"), "dev-feature/x"); + assert_eq!(normalize_branch("1.0-beta"), "dev-1.0-beta"); + } + + #[test] + fn test_normalize_branch_trims_input() { + assert_eq!(normalize_branch(" 1.0 "), "1.0.9999999.9999999-dev"); + } + + #[test] + fn test_normalize_branch_empty_or_invalid_segments() { + assert_eq!(normalize_branch(""), "dev-"); + assert_eq!(normalize_branch("1."), "dev-1."); + assert_eq!(normalize_branch("1.0.0.0.0"), "dev-1.0.0.0.0"); + assert_eq!(normalize_branch("xx"), "dev-xx"); + } } diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs index 3bdb9ca..0c78f89 100644 --- a/crates/mozart-vcs/src/downloader/git.rs +++ b/crates/mozart-vcs/src/downloader/git.rs @@ -1,12 +1,18 @@ use std::path::Path; +use std::sync::LazyLock; use anyhow::Result; +use regex::Regex; use crate::process::ProcessExecutor; use crate::util::git::GitUtil; use super::VcsDownloader; +/// Match `<hex> HEAD` lines in `git show-ref --head -d` output. +static HEAD_REF_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap()); + /// Git downloader using clone/checkout with optional mirror cache. /// /// Corresponds to Composer's `Downloader\GitDownloader`. @@ -91,13 +97,121 @@ impl VcsDownloader for GitDownloader { } fn 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", "status", "--porcelain"], Some(target))?; - if output.stdout.trim().is_empty() { + 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(output.stdout)) + 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> { @@ -110,3 +224,39 @@ impl VcsDownloader for GitDownloader { Ok(output.stdout) } } + +fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result<Option<String>> { + let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?; + if output.status != 0 { + anyhow::bail!( + "Failed to execute git show-ref --head -d\n\n{}", + output.stderr.trim() + ); + } + Ok(Some(output.stdout.trim().to_string())) +} + +fn collect_local_branches(refs: &str, head_ref: &str) -> Vec<String> { + let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref)); + let re = match Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + re.captures_iter(refs) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +fn collect_remote_branches(refs: &str, candidate: &str) -> Vec<String> { + let pattern = format!( + r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$", + regex::escape(candidate) + ); + let re = match Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + re.captures_iter(refs) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() +} diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs index bfffa07..926cfa8 100644 --- a/crates/mozart-vcs/src/downloader/hg.rs +++ b/crates/mozart-vcs/src/downloader/hg.rs @@ -46,11 +46,15 @@ impl VcsDownloader for HgDownloader { } fn 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))?; - if output.stdout.trim().is_empty() { + let trimmed = output.stdout.trim(); + if trimmed.is_empty() { Ok(None) } else { - Ok(Some(output.stdout)) + Ok(Some(trimmed.to_string())) } } diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs index 7186348..8948921 100644 --- a/crates/mozart-vcs/src/downloader/mod.rs +++ b/crates/mozart-vcs/src/downloader/mod.rs @@ -24,8 +24,24 @@ pub trait VcsDownloader { /// Detect local changes in the working copy. /// Returns `None` if clean, `Some(diff)` if modified. + /// Mirrors `Composer\Downloader\ChangeReportInterface::getLocalChanges`. fn 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>; } diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs index 5222b06..533e15a 100644 --- a/crates/mozart-vcs/src/downloader/svn.rs +++ b/crates/mozart-vcs/src/downloader/svn.rs @@ -1,11 +1,17 @@ use std::path::Path; +use std::sync::LazyLock; use anyhow::Result; +use regex::Regex; use crate::util::svn::SvnUtil; use super::VcsDownloader; +/// Match any non-`X` status line (mirror of Composer's +/// `{^ *[^X ] +}m`). Ignores externals (`X` prefix). +static SVN_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^ *[^X ] +").unwrap()); + /// SVN downloader using checkout/switch. pub struct SvnDownloader { svn_util: SvnUtil, @@ -46,11 +52,16 @@ impl VcsDownloader for SvnDownloader { } fn local_changes(&self, target: &Path) -> Result<Option<String>> { - let output = self.svn_util.execute(&["status"], Some(target))?; - if output.stdout.trim().is_empty() { - Ok(None) - } else { + 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) } } diff --git a/crates/mozart-vcs/src/lib.rs b/crates/mozart-vcs/src/lib.rs index 11db58d..e7ca383 100644 --- a/crates/mozart-vcs/src/lib.rs +++ b/crates/mozart-vcs/src/lib.rs @@ -3,3 +3,4 @@ pub mod driver; pub mod process; pub mod repository; pub mod util; +pub mod version_guesser; diff --git a/crates/mozart-vcs/src/version_guesser.rs b/crates/mozart-vcs/src/version_guesser.rs new file mode 100644 index 0000000..e70eb4e --- /dev/null +++ b/crates/mozart-vcs/src/version_guesser.rs @@ -0,0 +1,583 @@ +//! `VersionGuesser` — derive a package's current version from the working +//! copy, mirroring `Composer\Package\Version\VersionGuesser`. +//! +//! Differences from the PHP version: +//! - Fossil is not supported (Mozart has no Fossil driver). +//! - `Platform::isInputCompletionProcess()` short-circuit is omitted. +//! - `guess_feature_version` runs candidate comparisons sequentially. +//! Composer parallelises via `executeAsync`; ours is simpler at the +//! cost of speed when many candidate branches exist. + +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::Value; + +use mozart_semver::{Version, normalize_branch}; + +use crate::process::ProcessExecutor; + +const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; + +#[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() + } +} + +impl VersionGuesser { + pub fn new() -> Self { + Self { + process: ProcessExecutor::new(), + } + } + + /// `Composer\Package\Version\VersionGuesser::guessVersion`. + pub fn guess_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { + if let Some(v) = self.guess_git_version(package_config, path) { + return Some(postprocess(v)); + } + if let Some(v) = self.guess_hg_version(package_config, path) { + return Some(postprocess(v)); + } + if let Some(v) = self.guess_svn_version(package_config, path) { + return Some(postprocess(v)); + } + None + } + + fn guess_git_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { + let mut commit: Option<String> = None; + let mut version: Option<String> = None; + let mut pretty_version: Option<String> = None; + let mut feature_version: Option<String> = None; + let mut feature_pretty_version: Option<String> = None; + let mut is_detached = false; + + let branch_out = self + .process + .execute( + &["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + Some(path), + ) + .ok()?; + if branch_out.status != 0 { + return None; + } + + let mut branches: Vec<String> = Vec::new(); + let mut is_feature_branch = false; + + for line in branch_out.stdout.lines() { + if line.is_empty() { + continue; + } + if let Some(caps) = CURRENT_BRANCH_RE.captures(line) { + let name = caps.get(1).map_or("", |m| m.as_str()); + let hash = caps.get(2).map_or("", |m| m.as_str()); + if name == "(no branch)" + || name.starts_with("(detached ") + || name.starts_with("(HEAD detached at") + { + let v = format!("dev-{hash}"); + version = Some(v.clone()); + pretty_version = Some(v); + is_feature_branch = true; + is_detached = true; + } else { + version = Some(normalize_branch(name)); + pretty_version = Some(format!("dev-{name}")); + is_feature_branch = is_feature_branch_name(package_config, name); + } + commit = Some(hash.to_string()); + } + + if !REMOTE_HEAD_RE.is_match(line) + && let Some(caps) = ANY_BRANCH_RE.captures(line) + && let Some(m) = caps.get(1) + { + branches.push(m.as_str().to_string()); + } + } + + if is_feature_branch { + feature_version = version.clone(); + feature_pretty_version = pretty_version.clone(); + let result = self.guess_feature_version( + package_config, + version.as_deref(), + &branches, + &["git", "rev-list", "%candidate%..%branch%"], + path, + ); + version = result.0; + pretty_version = result.1; + } + + if (version.is_none() || is_detached) + && let Some((tag_v, tag_pretty)) = self.version_from_git_tags(path) + { + version = Some(tag_v); + pretty_version = Some(tag_pretty); + feature_version = None; + feature_pretty_version = None; + } + + if commit.is_none() + && let Ok(out) = self + .process + .execute(&["git", "rev-parse", "HEAD"], Some(path)) + && out.status == 0 + { + let trimmed = out.stdout.trim(); + if !trimmed.is_empty() { + commit = Some(trimmed.to_string()); + } + } + + version.as_ref()?; + Some(GuessedVersion { + version: version.unwrap(), + commit, + pretty_version, + feature_version, + feature_pretty_version, + }) + } + + fn version_from_git_tags(&self, path: &Path) -> Option<(String, String)> { + let out = self + .process + .execute(&["git", "describe", "--exact-match", "--tags"], Some(path)) + .ok()?; + if out.status != 0 { + return None; + } + let pretty = out.stdout.trim().to_string(); + if pretty.is_empty() { + return None; + } + let normalized = Version::parse(&pretty).ok()?; + Some((normalized.to_string(), pretty)) + } + + fn guess_hg_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { + let out = self.process.execute(&["hg", "branch"], Some(path)).ok()?; + if out.status != 0 { + return None; + } + let branch = out.stdout.trim().to_string(); + if branch.is_empty() { + return None; + } + let version = normalize_branch(&branch); + let is_feature = version.starts_with("dev-"); + + if version == DEFAULT_BRANCH_ALIAS { + return Some(GuessedVersion { + version, + commit: None, + pretty_version: Some(format!("dev-{branch}")), + feature_version: None, + feature_pretty_version: None, + }); + } + + if !is_feature { + return Some(GuessedVersion { + version: version.clone(), + commit: None, + pretty_version: Some(version), + feature_version: None, + feature_pretty_version: None, + }); + } + + // List branches via `hg branches` (first whitespace-separated token per line). + let branches_out = self.process.execute(&["hg", "branches"], Some(path)).ok()?; + let branches: Vec<String> = if branches_out.status == 0 { + branches_out + .stdout + .lines() + .filter_map(|l| l.split_whitespace().next().map(str::to_string)) + .collect() + } else { + Vec::new() + }; + + let (out_version, out_pretty) = self.guess_feature_version( + package_config, + Some(&version), + &branches, + &[ + "hg", + "log", + "-r", + "not ancestors('%candidate%') and ancestors('%branch%')", + "--template", + "\"{node}\\n\"", + ], + path, + ); + + Some(GuessedVersion { + version: out_version.unwrap_or(version.clone()), + commit: Some(String::new()), + pretty_version: out_pretty, + feature_version: Some(version.clone()), + feature_pretty_version: Some(version), + }) + } + + fn guess_svn_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { + let out = self + .process + .execute(&["svn", "info", "--xml"], Some(path)) + .ok()?; + if out.status != 0 { + return None; + } + + let trunk = package_config + .get("trunk-path") + .and_then(Value::as_str) + .unwrap_or("trunk"); + let branches = package_config + .get("branches-path") + .and_then(Value::as_str) + .unwrap_or("branches"); + let tags = package_config + .get("tags-path") + .and_then(Value::as_str) + .unwrap_or("tags"); + + let pattern = format!( + r"<url>.*/({trunk}|({branches}|{tags})/(.*))</url>", + trunk = regex::escape(trunk), + branches = regex::escape(branches), + tags = regex::escape(tags), + ); + let re = Regex::new(&pattern).ok()?; + let caps = re.captures(&out.stdout)?; + + let kind = caps.get(2).map(|m| m.as_str().to_string()); + let inner = caps.get(3).map(|m| m.as_str().to_string()); + + if let (Some(kind), Some(inner)) = (kind, inner) + && (kind == branches || kind == tags) + { + let pretty = format!("dev-{inner}"); + return Some(GuessedVersion { + version: normalize_branch(&inner), + commit: Some(String::new()), + pretty_version: Some(pretty), + feature_version: None, + feature_pretty_version: None, + }); + } + + let trunk_match = caps.get(1)?; + let pretty = trunk_match.as_str().trim().to_string(); + let version = if pretty == "trunk" { + "dev-trunk".to_string() + } else { + Version::parse(&pretty).ok()?.to_string() + }; + Some(GuessedVersion { + version, + commit: Some(String::new()), + pretty_version: Some(pretty), + feature_version: None, + feature_pretty_version: None, + }) + } + + /// Find the nearest non-feature branch by diff size. Sequential port of + /// `guessFeatureVersion`; Composer runs candidates in parallel. + fn guess_feature_version( + &self, + package_config: &Value, + version: Option<&str>, + branches: &[String], + scm_cmdline: &[&str], + path: &Path, + ) -> (Option<String>, Option<String>) { + let version = version.map(str::to_string); + let pretty_version = version.clone(); + + let Some(v) = version.clone() else { + return (version, pretty_version); + }; + + // Skip if the branch has a non-self.version branch-alias OR self.version is referenced. + let has_branch_alias = package_config + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.get(&v)) + .is_some(); + let uses_self_version = serde_json::to_string(package_config) + .map(|s| s.contains("\"self.version\"")) + .unwrap_or(false); + if has_branch_alias && !uses_self_version { + return (Some(v), pretty_version); + } + + // Composer also returns early if `self.version` is referenced — see L283. + // The PHP precedence is: skip iff (no branch-alias) OR (json contains self.version). + if uses_self_version { + return (Some(v), pretty_version); + } + + let branch = v.strip_prefix("dev-").unwrap_or(&v).to_string(); + + if !is_feature_branch_name(package_config, &branch) { + return (Some(v), pretty_version); + } + + let mut sorted: Vec<String> = branches.to_vec(); + sorted.sort_by(|a, b| { + let a_remote = a.starts_with("remotes/"); + let b_remote = b.starts_with("remotes/"); + if a_remote != b_remote { + return if a_remote { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Less + }; + } + // strnatcasecmp(b, a) — natural-sort, descending, case-insensitive. + natural_cmp(&b.to_ascii_lowercase(), &a.to_ascii_lowercase()) + }); + + let mut last_index: i64 = -1; + let mut length: usize = usize::MAX; + let mut version = Some(v); + let mut pretty = pretty_version; + + for (index, candidate) in sorted.iter().enumerate() { + let candidate_version = REMOTES_PREFIX_RE.replace(candidate, "").to_string(); + if candidate.as_str() == branch.as_str() + || is_feature_branch_name(package_config, &candidate_version) + { + continue; + } + let cmd: Vec<String> = scm_cmdline + .iter() + .map(|c| { + c.replace("%candidate%", candidate) + .replace("%branch%", &branch) + }) + .collect(); + let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); + let Ok(output) = self.process.execute(&cmd_refs, Some(path)) else { + continue; + }; + if output.status != 0 { + continue; + } + let len = output.stdout.len(); + if len < length || (len == length && last_index < index as i64) { + last_index = index as i64; + length = len; + version = Some(normalize_branch(&candidate_version)); + pretty = Some(format!("dev-{candidate_version}")); + if length == 0 { + break; + } + } + } + + (version, pretty) + } +} + +fn postprocess(mut v: GuessedVersion) -> GuessedVersion { + if v.feature_version.is_some() + && v.feature_version == Some(v.version.clone()) + && v.feature_pretty_version == v.pretty_version + { + v.feature_version = None; + v.feature_pretty_version = None; + } + + if v.version.ends_with("-dev") && contains_long_nines(&v.version) { + v.pretty_version = Some(replace_long_nines_with_x(&v.version)); + } + if let Some(ref fv) = v.feature_version + && fv.ends_with("-dev") + && contains_long_nines(fv) + { + v.feature_pretty_version = Some(replace_long_nines_with_x(fv)); + } + v +} + +fn contains_long_nines(s: &str) -> bool { + NINE_SEVEN_RE.is_match(s) +} + +fn replace_long_nines_with_x(s: &str) -> String { + NINE_SEVEN_GROUP_RE.replace_all(s, ".x").to_string() +} + +fn is_feature_branch_name(package_config: &Value, branch_name: &str) -> bool { + let mut non_feature = String::new(); + if let Some(arr) = package_config + .get("non-feature-branches") + .and_then(Value::as_array) + { + let parts: Vec<String> = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + if !parts.is_empty() { + non_feature = parts.join("|"); + } + } + let pattern = format!( + r"^({non_feature}|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$" + ); + let Ok(re) = Regex::new(&pattern) else { + return true; + }; + !re.is_match(branch_name) +} + +/// Natural-order, case-insensitive string comparison (mirrors PHP `strnatcasecmp`). +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + let mut ai = a.chars().peekable(); + let mut bi = b.chars().peekable(); + loop { + match (ai.peek().copied(), bi.peek().copied()) { + (None, None) => return std::cmp::Ordering::Equal, + (None, _) => return std::cmp::Ordering::Less, + (_, None) => return std::cmp::Ordering::Greater, + (Some(ac), Some(bc)) => { + if ac.is_ascii_digit() && bc.is_ascii_digit() { + let mut na = String::new(); + let mut nb = String::new(); + while let Some(&c) = ai.peek() { + if !c.is_ascii_digit() { + break; + } + na.push(c); + ai.next(); + } + while let Some(&c) = bi.peek() { + if !c.is_ascii_digit() { + break; + } + nb.push(c); + bi.next(); + } + let na_v: u128 = na.parse().unwrap_or(0); + let nb_v: u128 = nb.parse().unwrap_or(0); + match na_v.cmp(&nb_v) { + std::cmp::Ordering::Equal => continue, + ord => return ord, + } + } else { + match ac.cmp(&bc) { + std::cmp::Ordering::Equal => { + ai.next(); + bi.next(); + } + ord => return ord, + } + } + } + } + } +} + +static CURRENT_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new( + r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$", + ) + .unwrap() +}); + +static REMOTE_HEAD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap()); + +static ANY_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap() +}); + +static REMOTES_PREFIX_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap()); + +static NINE_SEVEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap()); + +static NINE_SEVEN_GROUP_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\.9{7})+").unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_postprocess_strips_duplicate_feature() { + let v = GuessedVersion { + version: "1.0.0.0".into(), + commit: None, + pretty_version: Some("1.0.0".into()), + feature_version: Some("1.0.0.0".into()), + feature_pretty_version: Some("1.0.0".into()), + }; + let p = postprocess(v); + assert_eq!(p.feature_version, None); + assert_eq!(p.feature_pretty_version, None); + } + + #[test] + fn test_postprocess_nine_seven_to_x() { + let v = GuessedVersion { + version: "1.9999999.9999999.9999999-dev".into(), + commit: None, + pretty_version: Some("dev-1.x".into()), + feature_version: None, + feature_pretty_version: None, + }; + let p = postprocess(v); + assert_eq!(p.pretty_version.as_deref(), Some("1.x-dev")); + } + + #[test] + fn test_is_feature_branch_known_mainlines() { + let cfg = json!({}); + assert!(!is_feature_branch_name(&cfg, "master")); + assert!(!is_feature_branch_name(&cfg, "main")); + assert!(!is_feature_branch_name(&cfg, "develop")); + assert!(!is_feature_branch_name(&cfg, "1.0")); + assert!(is_feature_branch_name(&cfg, "feature/x")); + } + + #[test] + fn test_is_feature_branch_with_non_feature_list() { + let cfg = json!({"non-feature-branches": ["staging", "release-.+"]}); + assert!(!is_feature_branch_name(&cfg, "staging")); + assert!(!is_feature_branch_name(&cfg, "release-2")); + assert!(is_feature_branch_name(&cfg, "wip-x")); + } + + #[test] + fn test_natural_cmp_orders_naturally() { + assert_eq!(natural_cmp("1.10", "1.9"), std::cmp::Ordering::Greater); + assert_eq!(natural_cmp("1.2", "1.10"), std::cmp::Ordering::Less); + assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal); + } +} diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs index 04b224b..2654665 100644 --- a/crates/mozart-vcs/tests/git_driver_test.rs +++ b/crates/mozart-vcs/tests/git_driver_test.rs @@ -168,11 +168,21 @@ fn test_git_downloader() { let changes = downloader.local_changes(&target).unwrap(); assert!(changes.is_none(), "Expected no changes, got: {:?}", changes); - // Make a local change and detect it - std::fs::write(target.join("local_change.txt"), "change").unwrap(); + // Untracked files alone must NOT count as local changes (matches + // Composer's `git status --porcelain --untracked-files=no`). + std::fs::write(target.join("untracked.txt"), "untracked").unwrap(); + let changes = downloader.local_changes(&target).unwrap(); + assert!( + changes.is_none(), + "Untracked files should be ignored, got: {:?}", + changes + ); + + // Modifying a tracked file is a local change. + std::fs::write(target.join("composer.json"), "{\"name\":\"changed\"}\n").unwrap(); let changes = downloader.local_changes(&target).unwrap(); assert!(changes.is_some()); - assert!(changes.unwrap().contains("local_change.txt")); + assert!(changes.unwrap().contains("composer.json")); // Commit logs let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap(); @@ -184,6 +194,62 @@ fn test_git_downloader() { } #[test] +fn test_git_downloader_unpushed_changes() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + let install_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, cache_dir.path().join("git")); + let downloader = GitDownloader::new(git_util); + + let url = repo_dir.path().to_str().unwrap(); + let target = install_dir.path().join("test-package"); + + downloader.download(url, "main", &target).unwrap(); + downloader.install(url, "main", &target).unwrap(); + + // No commits added locally → no unpushed changes. + let unpushed = downloader.unpushed_changes(&target).unwrap(); + assert!( + unpushed.is_none(), + "Expected no unpushed changes, got: {:?}", + unpushed + ); + + // Commit a local change without pushing. + let run = |args: &[&str]| { + let output = Command::new(args[0]) + .args(&args[1..]) + .current_dir(&target) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!(output.status.success(), "Command failed: {:?}", args); + }; + std::fs::write(target.join("local-only.txt"), "local-only").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Local-only commit"]); + + let unpushed = downloader.unpushed_changes(&target).unwrap(); + assert!(unpushed.is_some(), "Expected unpushed changes"); + let body = unpushed.unwrap(); + assert!( + body.contains("local-only.txt"), + "Expected diff body to mention local-only.txt, got: {body}" + ); +} + +#[test] fn test_detect_driver() { use mozart_vcs::driver::{DriverType, detect_driver}; diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs index 60db8ac..8647078 100644 --- a/crates/mozart/src/commands/status.rs +++ b/crates/mozart/src/commands/status.rs @@ -1,610 +1,209 @@ use clap::Args; use indexmap::IndexMap; +use mozart_core::composer::{Composer, InstallationSource, LocalPackage}; +use mozart_core::console::Console; +use mozart_core::console_format; use mozart_core::console_writeln; -use sha1::{Digest, Sha1}; -use std::path::{Path, PathBuf}; +use mozart_core::console_writeln_error; +use mozart_core::exit_code; +use mozart_registry::download_manager::DownloadManager; +use mozart_vcs::version_guesser::VersionGuesser; #[derive(Args)] pub struct StatusArgs {} -/// Information extracted from a package's dist field. -struct DistInfo { - dist_type: String, - url: String, - shasum: Option<String>, +struct VcsVerChange { + previous: VerRef, + current: VerRef, } -/// The kind of change detected for a file. -#[derive(Debug, PartialEq)] -enum ChangeKind { - /// File was modified (exists in both, different hash). - Modified, - /// File was added in the installed copy (not in original archive). - Added, - /// File was removed from the installed copy (in original archive, not installed). - Removed, -} - -/// A single file change within a package. -struct FileChange { - kind: ChangeKind, - path: String, -} - -/// Changes detected for one package. -struct PackageStatus { - install_path: String, - note: Option<String>, - changes: Vec<FileChange>, +struct VerRef { + version: String, + reference: String, } pub async fn execute( _args: &StatusArgs, cli: &super::Cli, - console: &mozart_core::console::Console, + console: &Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; + let composer = Composer::require(&working_dir)?; + let installed_repo = composer.repository_manager().local_repository(); + let im = composer.installation_manager(); + let dm = DownloadManager::new(im.vendor_dir().join(".cache").join("git")); + let guesser = VersionGuesser::new(); - let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; - - if installed.packages.is_empty() { - console.info("No packages installed."); - return Ok(()); - } - - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let mut errors: IndexMap<String, String> = IndexMap::new(); + let mut unpushed_changes: IndexMap<String, String> = IndexMap::new(); + let mut vcs_version_changes: IndexMap<String, VcsVerChange> = IndexMap::new(); - let show_files = cli.verbose > 0; - - let mut modified_packages: Vec<PackageStatus> = Vec::new(); - - for pkg in &installed.packages { - let dist = match extract_dist_info(pkg) { - Some(d) => d, - None => { - if cli.verbose > 1 { - console.verbose(&format!(" Skipping {} — no dist info available", pkg.name)); - } - continue; - } + for package in installed_repo.canonical_packages() { + let Some(downloader) = dm.for_package(package) else { + continue; }; - - // Resolve install path - let install_path = resolve_install_path(pkg, &vendor_dir); - if !install_path.exists() { - if cli.verbose > 0 { - console.verbose(&format!( - " Skipping {} — install path does not exist: {}", - pkg.name, - install_path.display() - )); - } + let Some(target_dir) = im.get_install_path(package) else { continue; - } + }; + let target_key = target_dir.display().to_string(); - // Check if the install path is a symlink — skip archive comparison - if install_path.is_symlink() { - let note = format!("{} is a symbolic link.", install_path.display()); - modified_packages.push(PackageStatus { - install_path: install_path.to_string_lossy().into_owned(), - note: Some(note), - changes: Vec::new(), - }); - continue; + // ChangeReportInterface — Composer mirrors the symlink branch and + // the local-changes branch unconditionally; the latter overrides + // the former when both fire. + if std::fs::symlink_metadata(&target_dir) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + errors.insert( + target_key.clone(), + format!("{target_key} is a symbolic link."), + ); } - - if cli.verbose > 0 { - console.verbose(&format!(" Checking {} ...", pkg.name)); + if let Some(changes) = downloader.local_changes(&target_dir)? { + errors.insert(target_key.clone(), changes); } - // Download original archive to a temp dir - let tmp_dir = make_temp_dir(&pkg.name)?; - let downloaded = mozart_registry::downloader::download_dist( - &dist.url, - dist.shasum.as_deref(), - None, - &files_cache, - ) - .await; - - let bytes = match downloaded { - Ok(b) => b, - Err(e) => { - console.info(&format!( - " Warning: could not download dist for {}: {}", - pkg.name, e - )); - let _ = std::fs::remove_dir_all(&tmp_dir); - continue; - } - }; - - // Extract archive to temp dir - let extract_result = match dist.dist_type.as_str() { - "zip" => mozart_registry::downloader::extract_zip(&bytes, &tmp_dir), - "tar" | "tar.gz" | "tgz" => { - mozart_registry::downloader::extract_tar_gz(&bytes, &tmp_dir) - } - other => { - console.info(&format!( - " Warning: unsupported dist type '{}' for {}", - other, pkg.name - )); - let _ = std::fs::remove_dir_all(&tmp_dir); - continue; + // VcsCapableDownloaderInterface + if downloader.vcs_reference(&target_dir)?.is_some() { + let previous_ref = match package.installation_source() { + Some(InstallationSource::Source) => package.source_reference(), + Some(InstallationSource::Dist) => package.dist_reference(), + _ => None, + }; + let pkg_config = build_package_config(package); + let current_version = guesser.guess_version(&pkg_config, &target_dir); + if let (Some(previous_ref), Some(current_version)) = (previous_ref, current_version) { + let cur_commit = current_version.commit.as_deref().unwrap_or(""); + let cur_pretty = current_version.pretty_version.as_deref().unwrap_or(""); + if cur_commit != previous_ref && cur_pretty != previous_ref { + vcs_version_changes.insert( + target_key.clone(), + VcsVerChange { + previous: VerRef { + version: package.pretty_version().to_string(), + reference: previous_ref.to_string(), + }, + current: VerRef { + version: cur_pretty.to_string(), + reference: cur_commit.to_string(), + }, + }, + ); + } } - }; - - if let Err(e) = extract_result { - console.info(&format!( - " Warning: could not extract dist for {}: {}", - pkg.name, e - )); - let _ = std::fs::remove_dir_all(&tmp_dir); - continue; } - // Hash both directories - let original_hashes = hash_directory(&tmp_dir)?; - let installed_hashes = hash_directory(&install_path)?; - let _ = std::fs::remove_dir_all(&tmp_dir); - - // Compute diff - let changes = compute_diff(&original_hashes, &installed_hashes); - - if !changes.is_empty() { - modified_packages.push(PackageStatus { - install_path: install_path.to_string_lossy().into_owned(), - note: None, - changes, - }); + // DvcsDownloaderInterface + if let Some(unpushed) = downloader.unpushed_changes(&target_dir)? { + unpushed_changes.insert(target_key.clone(), unpushed); } } - if modified_packages.is_empty() { - console.info("No local changes"); + if errors.is_empty() && unpushed_changes.is_empty() && vcs_version_changes.is_empty() { + console_writeln_error!(console, &console_format!("<info>No local changes</info>")); return Ok(()); } - console.info("You have changes in the following dependencies:\n"); - - for pkg_status in &modified_packages { - if let Some(ref note) = pkg_status.note { - console_writeln!(console, note); - } else { - console_writeln!(console, &pkg_status.install_path); - - if show_files { - let mut sorted_changes: Vec<&FileChange> = pkg_status.changes.iter().collect(); - sorted_changes.sort_by_key(|c| c.path.as_str()); + let verbose = cli.verbose > 0; + let very_verbose = cli.verbose >= 2; - for change in sorted_changes { - let prefix = match change.kind { - ChangeKind::Modified => 'M', - ChangeKind::Added => '+', - ChangeKind::Removed => '-', - }; - console_writeln!(console, &format!(" {} {}", prefix, change.path),); - } + if !errors.is_empty() { + console_writeln_error!( + console, + &console_format!("<error>You have changes in the following dependencies:</error>") + ); + for (path, changes) in &errors { + if verbose { + console_writeln!(console, &console_format!("<info>{path}</info>:")); + console_writeln!(console, &indent_block(changes)); + } else { + console_writeln!(console, path); } } } - // Hint about --verbose if not already showing files and there are modified packages - if !show_files { - console.info("Use --verbose (-v) to see a list of files"); - } - - // Exit with code 1 if modifications found - Err(mozart_core::exit_code::bail_silent(1)) -} - -/// Extract dist info from an installed package entry. -fn extract_dist_info(pkg: &mozart_registry::installed::InstalledPackageEntry) -> Option<DistInfo> { - // Try the strongly-typed `dist` field first - let dist_val = pkg.dist.as_ref().or_else(|| pkg.extra_fields.get("dist"))?; - - let dist_type = dist_val.get("type").and_then(|v| v.as_str())?.to_string(); - let url = dist_val.get("url").and_then(|v| v.as_str())?.to_string(); - let shasum = dist_val - .get("shasum") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - if url.is_empty() { - return None; + if !unpushed_changes.is_empty() { + console_writeln_error!( + console, + &console_format!( + "<warning>You have unpushed changes on the current branch in the following dependencies:</warning>" + ) + ); + for (path, changes) in &unpushed_changes { + if verbose { + console_writeln!(console, &console_format!("<info>{path}</info>:")); + console_writeln!(console, &indent_block(changes)); + } else { + console_writeln!(console, path); + } + } } - Some(DistInfo { - dist_type, - url, - shasum, - }) -} - -/// Resolve the on-disk install path for a package. -/// -/// Prefers the `install-path` field from installed.json when available, -/// since it is a path relative to `vendor/composer/`. Falls back to -/// `vendor/<package-name>`. -fn resolve_install_path( - pkg: &mozart_registry::installed::InstalledPackageEntry, - vendor_dir: &Path, -) -> PathBuf { - if let Some(ref rel) = pkg.install_path { - // install-path is relative to vendor/composer/ - let base = vendor_dir.join("composer"); - let resolved = base.join(rel); - // Normalize out ".." segments using canonicalize-like logic - let resolved_str = resolved.to_string_lossy().into_owned(); - let mut components: Vec<&str> = Vec::new(); - for part in resolved_str.split('/') { - match part { - ".." => { - components.pop(); + if !vcs_version_changes.is_empty() { + console_writeln_error!( + console, + &console_format!( + "<warning>You have version variations in the following dependencies:</warning>" + ) + ); + for (path, change) in &vcs_version_changes { + if verbose { + let mut prev = if change.previous.version.is_empty() { + change.previous.reference.clone() + } else { + change.previous.version.clone() + }; + let mut curr = if change.current.version.is_empty() { + change.current.reference.clone() + } else { + change.current.version.clone() + }; + if very_verbose { + prev.push_str(&format!(" ({})", change.previous.reference)); + curr.push_str(&format!(" ({})", change.current.reference)); } - "." | "" => {} - p => components.push(p), + console_writeln!(console, &console_format!("<info>{path}</info>:")); + console_writeln!( + console, + &console_format!( + " From <comment>{prev}</comment> to <comment>{curr}</comment>" + ) + ); + } else { + console_writeln!(console, path); } } - PathBuf::from("/".to_string() + &components.join("/")) - } else { - vendor_dir.join(&pkg.name) } -} -/// Create a unique temporary directory for extracting a package archive. -/// -/// The directory is placed under the system temp dir and named using the -/// package name (with `/` replaced) and a timestamp-derived suffix so that -/// concurrent runs are unlikely to collide. The caller is responsible for -/// removing the directory when done. -fn make_temp_dir(package_name: &str) -> anyhow::Result<PathBuf> { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos(); - let safe_name = package_name.replace('/', "_"); - let dir = std::env::temp_dir().join(format!("mozart_status_{}_{}", safe_name, nanos)); - std::fs::create_dir_all(&dir)?; - Ok(dir) -} - -/// Recursively hash all files in a directory. -/// -/// Returns a map from relative path string to SHA-1 hex digest. -fn hash_directory(dir: &Path) -> anyhow::Result<IndexMap<String, String>> { - let mut map = IndexMap::new(); - hash_dir_recursive(dir, dir, &mut map)?; - Ok(map) -} - -fn hash_dir_recursive( - root: &Path, - current: &Path, - map: &mut IndexMap<String, String>, -) -> anyhow::Result<()> { - let entries = match std::fs::read_dir(current) { - Ok(e) => e, - Err(_) => return Ok(()), - }; - - for entry in entries { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - - if metadata.is_dir() { - hash_dir_recursive(root, &path, map)?; - } else if metadata.is_file() { - let relative = path - .strip_prefix(root) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - - let contents = std::fs::read(&path)?; - let mut hasher = Sha1::new(); - hasher.update(&contents); - let hex = format!("{:x}", hasher.finalize()); - - map.insert(relative, hex); - } - // Symlinks are skipped + if !verbose { + console_writeln_error!(console, "Use --verbose (-v) to see a list of files"); } + let code = (if !errors.is_empty() { 1 } else { 0 }) + | (if !unpushed_changes.is_empty() { 2 } else { 0 }) + | (if !vcs_version_changes.is_empty() { + 4 + } else { + 0 + }); + if code != 0 { + return Err(exit_code::bail_silent(code)); + } Ok(()) } -/// Compare two hash maps (original vs installed) and return a list of changes. -fn compute_diff( - original: &IndexMap<String, String>, - installed: &IndexMap<String, String>, -) -> Vec<FileChange> { - let mut changes: Vec<FileChange> = Vec::new(); - - // Files in original: check for modifications and removals - for (path, orig_hash) in original { - match installed.get(path) { - Some(inst_hash) if inst_hash != orig_hash => { - changes.push(FileChange { - kind: ChangeKind::Modified, - path: path.clone(), - }); - } - Some(_) => {} // unchanged - None => { - changes.push(FileChange { - kind: ChangeKind::Removed, - path: path.clone(), - }); - } - } - } - - // Files in installed but not in original: added - for path in installed.keys() { - if !original.contains_key(path) { - changes.push(FileChange { - kind: ChangeKind::Added, - path: path.clone(), - }); - } - } - - changes +fn indent_block(s: &str) -> String { + s.split('\n') + .map(|line| format!(" {}", line.trim_start())) + .collect::<Vec<_>>() + .join("\n") } -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - #[test] - fn test_hash_directory() { - let dir = tempdir().unwrap(); - - fs::write(dir.path().join("file.txt"), b"hello").unwrap(); - fs::create_dir(dir.path().join("sub")).unwrap(); - fs::write(dir.path().join("sub/nested.txt"), b"world").unwrap(); - - let hashes = hash_directory(dir.path()).unwrap(); - assert_eq!(hashes.len(), 2); - assert!(hashes.contains_key("file.txt")); - assert!(hashes.contains_key("sub/nested.txt")); - - // Same content → same hash - let dir2 = tempdir().unwrap(); - fs::write(dir2.path().join("file.txt"), b"hello").unwrap(); - fs::create_dir(dir2.path().join("sub")).unwrap(); - fs::write(dir2.path().join("sub/nested.txt"), b"world").unwrap(); - - let hashes2 = hash_directory(dir2.path()).unwrap(); - assert_eq!(hashes["file.txt"], hashes2["file.txt"]); - assert_eq!(hashes["sub/nested.txt"], hashes2["sub/nested.txt"]); - - // Different content → different hash - let dir3 = tempdir().unwrap(); - fs::write(dir3.path().join("file.txt"), b"different").unwrap(); - let hashes3 = hash_directory(dir3.path()).unwrap(); - assert_ne!(hashes["file.txt"], hashes3["file.txt"]); - } - - #[test] - fn test_compute_diff_no_changes() { - let mut map: IndexMap<String, String> = IndexMap::new(); - map.insert("src/Foo.php".to_string(), "abc123".to_string()); - map.insert("src/Bar.php".to_string(), "def456".to_string()); - - let changes = compute_diff(&map, &map); - assert!(changes.is_empty()); - } - - #[test] - fn test_compute_diff_modified() { - let mut original: IndexMap<String, String> = IndexMap::new(); - original.insert("src/Foo.php".to_string(), "abc123".to_string()); - - let mut installed: IndexMap<String, String> = IndexMap::new(); - installed.insert("src/Foo.php".to_string(), "xyz999".to_string()); - - let changes = compute_diff(&original, &installed); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].kind, ChangeKind::Modified); - assert_eq!(changes[0].path, "src/Foo.php"); - } - - #[test] - fn test_compute_diff_added() { - let original: IndexMap<String, String> = IndexMap::new(); - - let mut installed: IndexMap<String, String> = IndexMap::new(); - installed.insert("src/NewFile.php".to_string(), "aabbcc".to_string()); - - let changes = compute_diff(&original, &installed); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].kind, ChangeKind::Added); - assert_eq!(changes[0].path, "src/NewFile.php"); - } - - #[test] - fn test_compute_diff_removed() { - let mut original: IndexMap<String, String> = IndexMap::new(); - original.insert("src/OldFile.php".to_string(), "112233".to_string()); - - let installed: IndexMap<String, String> = IndexMap::new(); - - let changes = compute_diff(&original, &installed); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].kind, ChangeKind::Removed); - assert_eq!(changes[0].path, "src/OldFile.php"); - } - - #[test] - fn test_compute_diff_mixed() { - let mut original: IndexMap<String, String> = IndexMap::new(); - original.insert("src/Unchanged.php".to_string(), "same".to_string()); - original.insert("src/Modified.php".to_string(), "old".to_string()); - original.insert("src/Removed.php".to_string(), "gone".to_string()); - - let mut installed: IndexMap<String, String> = IndexMap::new(); - installed.insert("src/Unchanged.php".to_string(), "same".to_string()); - installed.insert("src/Modified.php".to_string(), "new".to_string()); - installed.insert("src/Added.php".to_string(), "extra".to_string()); - - let changes = compute_diff(&original, &installed); - assert_eq!(changes.len(), 3); - - let modified: Vec<_> = changes - .iter() - .filter(|c| c.kind == ChangeKind::Modified) - .collect(); - let added: Vec<_> = changes - .iter() - .filter(|c| c.kind == ChangeKind::Added) - .collect(); - let removed: Vec<_> = changes - .iter() - .filter(|c| c.kind == ChangeKind::Removed) - .collect(); - - assert_eq!(modified.len(), 1); - assert_eq!(modified[0].path, "src/Modified.php"); - - assert_eq!(added.len(), 1); - assert_eq!(added[0].path, "src/Added.php"); - - assert_eq!(removed.len(), 1); - assert_eq!(removed[0].path, "src/Removed.php"); - } - - #[test] - fn test_extract_dist_info_from_dist_field() { - use std::collections::BTreeMap; - - let pkg = mozart_registry::installed::InstalledPackageEntry { - name: "vendor/pkg".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: Some(serde_json::json!({ - "type": "zip", - "url": "https://example.com/pkg.zip", - "reference": "abc123", - "shasum": "deadbeef" - })), - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - }; - - let info = extract_dist_info(&pkg).unwrap(); - assert_eq!(info.dist_type, "zip"); - assert_eq!(info.url, "https://example.com/pkg.zip"); - assert_eq!(info.shasum.as_deref(), Some("deadbeef")); - } - - #[test] - fn test_extract_dist_info_no_url() { - use std::collections::BTreeMap; - - let pkg = mozart_registry::installed::InstalledPackageEntry { - name: "vendor/pkg".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: Some(serde_json::json!({ - "type": "zip", - "url": "", - "shasum": "" - })), - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - }; - - assert!(extract_dist_info(&pkg).is_none()); - } - - #[test] - fn test_extract_dist_info_absent() { - use std::collections::BTreeMap; - - let pkg = mozart_registry::installed::InstalledPackageEntry { - name: "vendor/pkg".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - }; - - assert!(extract_dist_info(&pkg).is_none()); - } - - #[test] - fn test_resolve_install_path_default() { - use std::collections::BTreeMap; - - let pkg = mozart_registry::installed::InstalledPackageEntry { - name: "monolog/monolog".to_string(), - version: "3.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - }; - - let vendor = PathBuf::from("/project/vendor"); - let path = resolve_install_path(&pkg, &vendor); - assert_eq!(path, PathBuf::from("/project/vendor/monolog/monolog")); - } - - #[test] - fn test_resolve_install_path_with_install_path() { - use std::collections::BTreeMap; - - let pkg = mozart_registry::installed::InstalledPackageEntry { - name: "monolog/monolog".to_string(), - version: "3.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: None, - install_path: Some("../monolog/monolog".to_string()), - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - }; - - let vendor = PathBuf::from("/project/vendor"); - let path = resolve_install_path(&pkg, &vendor); - assert_eq!(path, PathBuf::from("/project/vendor/monolog/monolog")); - } +/// Build the `package_config` shape that `VersionGuesser` reads. The PHP +/// equivalent is `ArrayDumper::dump($package)`; we only need the fields +/// that `VersionGuesser` actually inspects. +fn build_package_config(package: &LocalPackage) -> serde_json::Value { + serde_json::json!({ + "extra": package.extra(), + }) } |
