aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 19:52:18 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 19:52:18 +0900
commit5cb8fc4e306970764e84bb850da2c56f844c3b12 (patch)
tree0d66f3129a26138fcfee9402616b24929c40a017 /crates
parentd83b9ef48775aeb31ba1909b29d5470e6d0ddaaa (diff)
downloadphp-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.rs100
-rw-r--r--crates/mozart-core/src/factory.rs57
-rw-r--r--crates/mozart-registry/src/download_manager.rs140
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-semver/src/lib.rs104
-rw-r--r--crates/mozart-vcs/src/downloader/git.rs156
-rw-r--r--crates/mozart-vcs/src/downloader/hg.rs8
-rw-r--r--crates/mozart-vcs/src/downloader/mod.rs16
-rw-r--r--crates/mozart-vcs/src/downloader/svn.rs19
-rw-r--r--crates/mozart-vcs/src/lib.rs1
-rw-r--r--crates/mozart-vcs/src/version_guesser.rs583
-rw-r--r--crates/mozart-vcs/tests/git_driver_test.rs72
-rw-r--r--crates/mozart/src/commands/status.rs713
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(),
+ })
}