From f2bfcc8b9035a2df9ab786307b0e03801e5d3b86 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Wed, 24 Dec 2025 15:18:33 +0100 Subject: [PATCH 01/12] Add prefixed version parsing --- src/config.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/config.rs b/src/config.rs index b150f659..a469376b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2044,3 +2044,69 @@ fn env_string_from_string(path_str: &str) -> Result { pub(crate) fn env_path_from_string(path_str: &str) -> Result { Ok(PathBuf::from(env_string_from_string(path_str)?)) } + +/// A semver version with a prefix. +#[derive(Debug)] +pub struct PrefixedVersion { + /// The prefix. + pub prefix: Option, + /// The version. + pub version: semver::Version, +} + +impl Serialize for PrefixedVersion { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match &self.prefix { + Some(prefix) => { + let s = format!("{}-v{}", prefix, self.version); + serializer.serialize_str(&s) + } + None => self.version.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for PrefixedVersion { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + use serde::de; + use std::result::Result; + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = PrefixedVersion; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a version string with optional prefix") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if let Some(idx) = value.rfind("-v") { + let prefix = &value[..idx]; + let version_str = &value[idx + 2..]; + let version = semver::Version::parse(version_str).map_err(E::custom)?; + Ok(PrefixedVersion { + prefix: Some(prefix.to_string()), + version, + }) + } else { + let version = semver::Version::parse(value).map_err(E::custom)?; + Ok(PrefixedVersion { + prefix: None, + version, + }) + } + } + } + + deserializer.deserialize_str(Visitor) + } +} From 86dc1897b451772df61d043d8ff4058a9ce2317d Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Wed, 24 Dec 2025 15:30:03 +0100 Subject: [PATCH 02/12] Add initial version_prefix parsing --- src/config.rs | 22 +++++++++++++++++++++- src/resolver.rs | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index a469376b..ab739124 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,6 +143,8 @@ pub enum Dependency { url: String, /// The version requirement of the package. version: semver::VersionReq, + /// Prefix for the version + version_prefix: Option, /// Targets to pass to the dependency pass_targets: Vec, }, @@ -214,12 +216,16 @@ impl Serialize for Dependency { ref target, ref url, ref version, + ref version_prefix, ref pass_targets, } => { - let mut map = serializer.serialize_map(Some(4))?; + let mut map = serializer.serialize_map(Some(4 + version_prefix.iter().count()))?; map.serialize_entry("target", target)?; map.serialize_entry("git", url)?; map.serialize_entry("version", &format!("{}", version))?; + if let Some(prefix) = version_prefix { + map.serialize_entry("version_prefix", prefix)?; + } map.serialize_entry("pass_targets", pass_targets)?; map.end() } @@ -676,6 +682,8 @@ pub struct PartialDependency { remote: Option, /// The upstream name of the remote to use for this dependency upstream_name: Option, + /// The version prefix to use when specifying the version. This will modify the specified version string to require a `-v*` version. This is optional and can only be used when using git version dependencies. + version_prefix: Option, /// Targets to pass to the dependency pass_targets: Option>>, /// Unknown extra fields @@ -755,6 +763,7 @@ impl Validate for PartialDependency { target, url: default_remote.url.replace("{}", git_name), version, + version_prefix: self.version_prefix, pass_targets, }) } else { @@ -774,6 +783,7 @@ impl Validate for PartialDependency { target, url: remote.url.replace("{}", git_name), version, + version_prefix: self.version_prefix, pass_targets, }) } else { @@ -791,6 +801,7 @@ impl Validate for PartialDependency { target, url: git, version, + version_prefix: self.version_prefix, pass_targets, }), // Git dependencies with revisions, e.g.: @@ -2110,3 +2121,12 @@ impl<'de> Deserialize<'de> for PrefixedVersion { deserializer.deserialize_str(Visitor) } } + +/// A semver version requirement with a prefix. +#[derive(Debug)] +pub struct PrefixedVersionReq { + /// The prefix. + pub prefix: Option, + /// The version requirement. + pub version_req: semver::VersionReq, +} diff --git a/src/resolver.rs b/src/resolver.rs index 1a1ae2c7..c7c3df1e 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -390,6 +390,7 @@ impl<'ctx> DependencyResolver<'ctx> { pre: parsed_version.pre, }], }, + version_prefix: None, // TODO pass_targets: Vec::new(), } } else { @@ -443,6 +444,7 @@ impl<'ctx> DependencyResolver<'ctx> { target: TargetSpec::Wildcard, url: u, version: v.clone(), + version_prefix: None, pass_targets: Vec::new(), }, DependencyConstraint::Revision(r) => config::Dependency::GitRevision { From ed02569095b5f2c6f4c9c4e2ec03dfbacf94bd21 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:36:50 +0200 Subject: [PATCH 03/12] Parse arbitrary version-tag prefixes into GitVersions Replace the hardcoded `v` tag-prefix stripping with a generic `split_version_tag` helper that splits any tag into its literal prefix (e.g. `v` or `companyX-v`) and semantic version. Carry that prefix alongside each version via the new `GitTagVersion` struct. This is a behaviour-preserving refactor: version matching, lockfile writing, and audit are all still pinned to the default `v` prefix, so only `v*` tags are considered. Subsequent commits wire the per-dependency `version_prefix` through resolution to activate namespacing. Also removes the unused, `-v`-separator `PrefixedVersion`/ `PrefixedVersionReq` scaffolding, superseded by the literal-prefix model. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmd/audit.rs | 9 +++-- src/config.rs | 86 +++++++++--------------------------------------- src/resolver.rs | 18 +++++----- src/sess.rs | 37 +++++++++++++-------- 4 files changed, 55 insertions(+), 95 deletions(-) diff --git a/src/cmd/audit.rs b/src/cmd/audit.rs index a14b8ecb..a6b71a9d 100644 --- a/src/cmd/audit.rs +++ b/src/cmd/audit.rs @@ -86,9 +86,12 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { let current_revision = sess.dependency(*pkg).revision.clone(); let current_revision_unwrapped = current_revision.as_deref().unwrap_or_default(); let available_versions = match dep_versions.get(pkg).unwrap() { - DependencyVersions::Git(versions) => { - versions.versions.iter().map(|(v, _)| v.clone()).collect() - } + DependencyVersions::Git(versions) => versions + .versions + .iter() + .filter(|tv| tv.prefix == "v") + .map(|tv| tv.version.clone()) + .collect(), _ => vec![], }; let highest_version = available_versions.iter().max(); diff --git a/src/config.rs b/src/config.rs index ab739124..8c596765 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2056,77 +2056,23 @@ pub(crate) fn env_path_from_string(path_str: &str) -> Result { Ok(PathBuf::from(env_string_from_string(path_str)?)) } -/// A semver version with a prefix. -#[derive(Debug)] -pub struct PrefixedVersion { - /// The prefix. - pub prefix: Option, - /// The version. - pub version: semver::Version, -} - -impl Serialize for PrefixedVersion { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - match &self.prefix { - Some(prefix) => { - let s = format!("{}-v{}", prefix, self.version); - serializer.serialize_str(&s) - } - None => self.version.serialize(serializer), +/// Split a git version tag into its literal prefix and semantic version. +/// +/// The prefix is the entire literal string preceding the semantic version, +/// e.g. `v` for `v1.2.3` or `companyX-v` for `companyX-v1.2.3`. The default, +/// backwards-compatible prefix is `v`. Returns `None` if no suffix of the tag +/// parses as a semantic version. +pub fn split_version_tag(tag: &str) -> Option<(&str, semver::Version)> { + let bytes = tag.as_bytes(); + for i in 0..bytes.len() { + // The semantic version starts at a digit that is not part of a longer + // run of digits (so we don't split in the middle of a number). + if !bytes[i].is_ascii_digit() || (i > 0 && bytes[i - 1].is_ascii_digit()) { + continue; } - } -} - -impl<'de> Deserialize<'de> for PrefixedVersion { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - use serde::de; - use std::result::Result; - struct Visitor; - - impl<'de> de::Visitor<'de> for Visitor { - type Value = PrefixedVersion; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a version string with optional prefix") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - if let Some(idx) = value.rfind("-v") { - let prefix = &value[..idx]; - let version_str = &value[idx + 2..]; - let version = semver::Version::parse(version_str).map_err(E::custom)?; - Ok(PrefixedVersion { - prefix: Some(prefix.to_string()), - version, - }) - } else { - let version = semver::Version::parse(value).map_err(E::custom)?; - Ok(PrefixedVersion { - prefix: None, - version, - }) - } - } + if let Ok(version) = semver::Version::parse(&tag[i..]) { + return Some((&tag[..i], version)); } - - deserializer.deserialize_str(Visitor) } -} - -/// A semver version requirement with a prefix. -#[derive(Debug)] -pub struct PrefixedVersionReq { - /// The prefix. - pub prefix: Option, - /// The version requirement. - pub version_req: semver::VersionReq, + None } diff --git a/src/resolver.rs b/src/resolver.rs index c7c3df1e..3b727d98 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -218,8 +218,8 @@ impl<'ctx> DependencyResolver<'ctx> { let version = gv .versions .iter() - .filter(|&&(_, r)| r == rev) - .map(|(v, _)| v) + .filter(|tv| tv.hash == rev && tv.prefix == "v") + .map(|tv| &tv.version) .max() .map(|v| v.to_string()); LockedPackage { @@ -1042,12 +1042,14 @@ impl<'ctx> DependencyResolver<'ctx> { let mut revs_tmp: IndexMap<_, _> = gv .versions .iter() - .sorted() - .filter_map( - |&(ref v, h)| { - if con.matches(v) { Some((v, h)) } else { None } - }, - ) + .sorted_by(|a, b| a.version.cmp(&b.version)) + .filter_map(|tv| { + if tv.prefix == "v" && con.matches(&tv.version) { + Some((&tv.version, tv.hash)) + } else { + None + } + }) .collect(); revs_tmp.reverse(); let revs: IndexSet = revs_tmp diff --git a/src/sess.rs b/src/sess.rs index 272933d0..f0a852bf 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -902,21 +902,19 @@ impl<'io, 'sess: 'io, 'ctx: 'sess> SessionIo<'sess, 'ctx> { (tags, branches) }; - // Extract the tags that look like semantic versions. - let mut versions: Vec<(semver::Version, &'ctx str)> = tags + // Extract the tags that look like (optionally prefixed) semantic + // versions, e.g. `v1.2.3` or `companyX-v1.2.3`. + let mut versions: Vec> = tags .iter() .filter_map(|(tag, &hash)| { - if let Some(stripped) = tag.strip_prefix('v') { - match semver::Version::parse(stripped) { - Ok(v) => Some((v, hash)), - Err(_) => None, - } - } else { - None - } + config::split_version_tag(tag).map(|(prefix, version)| GitTagVersion { + prefix, + version, + hash, + }) }) .collect(); - versions.sort_by(|a, b| b.cmp(a)); + versions.sort_by(|a, b| b.version.cmp(&a.version)); // Merge tags and branches. let refs: IndexMap<&str, &str> = branches.into_iter().chain(tags).collect(); @@ -2114,12 +2112,23 @@ pub enum DependencyVersions<'ctx> { #[derive(Clone, Debug)] pub struct RegistryVersions; +/// A single version tag of a git dependency, e.g. `v1.2.3` or `companyX-v1.2.3`. +#[derive(Clone, Debug)] +pub struct GitTagVersion<'ctx> { + /// The literal prefix preceding the semantic version in the tag, e.g. `v`. + pub prefix: &'ctx str, + /// The semantic version parsed from the tag. + pub version: semver::Version, + /// The git revision hash this tag points to. + pub hash: &'ctx str, +} + /// All available versions a git dependency has. #[derive(Clone, Debug)] pub struct GitVersions<'ctx> { - /// The versions available for this dependency. This is basically a sorted - /// list of tags of the form `v`. - pub versions: Vec<(semver::Version, &'ctx str)>, + /// The versions available for this dependency. This is a list of tags of + /// the form `` (e.g. `v1.2.3`), sorted by version descending. + pub versions: Vec>, /// The named references available for this dependency. This is a mixture of /// branch names and tags, where the tags take precedence. pub refs: IndexMap<&'ctx str, &'ctx str>, From df35c1abba604099bd8a4eff93d05eaaf7e8ee38 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:41:17 +0200 Subject: [PATCH 04/12] Resolve git versions within their declared prefix namespace Carry the per-dependency `version_prefix` through resolution by adding a `prefix` field to `DependencyConstraint::Version` (defaulting to `v` via the new `DEFAULT_VERSION_PREFIX` constant). Version matching now only accepts git tags whose literal prefix equals the constraint's prefix, so a default dependency sees only `v*` tags and a `companyX-v` dependency sees only `companyX-v*` tags. There is no fallback between namespaces. Because namespaces are exclusive, a dependency required with more than one distinct prefix has no shared version space. Rather than silently picking a commit, resolution now bails with a clear error instructing the user to add an override, which collapses the requirements to a single namespace and reuses the existing override-based manual-resolution mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmd/audit.rs | 2 +- src/config.rs | 3 ++ src/resolver.rs | 81 ++++++++++++++++++++++++++++++++++++------------ src/sess.rs | 40 ++++++++++++++++++++---- 4 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/cmd/audit.rs b/src/cmd/audit.rs index a6b71a9d..5db02c79 100644 --- a/src/cmd/audit.rs +++ b/src/cmd/audit.rs @@ -89,7 +89,7 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { DependencyVersions::Git(versions) => versions .versions .iter() - .filter(|tv| tv.prefix == "v") + .filter(|tv| tv.prefix == crate::config::DEFAULT_VERSION_PREFIX) .map(|tv| tv.version.clone()) .collect(), _ => vec![], diff --git a/src/config.rs b/src/config.rs index 8c596765..7565cc37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2056,6 +2056,9 @@ pub(crate) fn env_path_from_string(path_str: &str) -> Result { Ok(PathBuf::from(env_string_from_string(path_str)?)) } +/// The default, backwards-compatible version-tag prefix (`v`, as in `v1.2.3`). +pub const DEFAULT_VERSION_PREFIX: &str = "v"; + /// Split a git version tag into its literal prefix and semantic version. /// /// The prefix is the entire literal string preceding the semantic version, diff --git a/src/resolver.rs b/src/resolver.rs index 3b727d98..ecfbdf7b 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -218,7 +218,9 @@ impl<'ctx> DependencyResolver<'ctx> { let version = gv .versions .iter() - .filter(|tv| tv.hash == rev && tv.prefix == "v") + .filter(|tv| { + tv.hash == rev && tv.prefix == config::DEFAULT_VERSION_PREFIX + }) .map(|tv| &tv.version) .max() .map(|v| v.to_string()); @@ -440,13 +442,19 @@ impl<'ctx> DependencyResolver<'ctx> { pass_targets: Vec::new(), }, DependencySource::Git(u) => match &cnstr { - DependencyConstraint::Version(v) => config::Dependency::GitVersion { - target: TargetSpec::Wildcard, - url: u, - version: v.clone(), - version_prefix: None, - pass_targets: Vec::new(), - }, + DependencyConstraint::Version { req: v, prefix } => { + config::Dependency::GitVersion { + target: TargetSpec::Wildcard, + url: u, + version: v.clone(), + version_prefix: if prefix.as_str() == config::DEFAULT_VERSION_PREFIX { + None + } else { + Some(prefix.clone()) + }, + pass_targets: Vec::new(), + } + } DependencyConstraint::Revision(r) => config::Dependency::GitRevision { target: TargetSpec::Wildcard, url: u, @@ -611,6 +619,34 @@ impl<'ctx> DependencyResolver<'ctx> { map }; + // Namespaced versions must not be mixed. A dependency required with more + // than one distinct version prefix has no shared namespace, so there is + // no automatic resolution (no fallback to the default `v`). The user must + // pick one explicitly via an override, which collapses all requirements + // for the dependency to a single constraint. + for (name, cons) in &cons_map { + let prefixes: IndexSet<&str> = cons + .iter() + .filter_map(|(_, con, _)| match con { + DependencyConstraint::Version { prefix, .. } => Some(prefix.as_str()), + _ => None, + }) + .collect(); + if prefixes.len() > 1 { + bail!( + "Dependency `{}` is required with conflicting version prefixes ({}). \ + Namespaced versions cannot be mixed; add an override for `{}` to select one.", + name, + prefixes + .iter() + .map(|p| format!("`{}`", p)) + .collect::>() + .join(", "), + name, + ); + } + } + let _src_cons_map = cons_map .iter() .map(|(name, cons)| { @@ -741,10 +777,14 @@ impl<'ctx> DependencyResolver<'ctx> { indices_list? }; if indices_list.is_empty() && id == con_src { - let additional_str = if let DependencyConstraint::Version(__) = con { - " Ensure git tags are formatted as `vX.Y.Z`.".to_string() - } else { - "".to_string() + let additional_str = match con { + DependencyConstraint::Version { prefix, .. } if prefix.is_empty() => { + " Ensure git tags are formatted as `X.Y.Z`, with no prefix.".to_string() + } + DependencyConstraint::Version { prefix, .. } => { + format!(" Ensure git tags are formatted as `{}X.Y.Z`.", prefix) + } + _ => "".to_string(), }; bail!( "Dependency `{}` from `{}` cannot satisfy requirement `{}`.{}", @@ -813,7 +853,7 @@ impl<'ctx> DependencyResolver<'ctx> { fmt_pkg!(pkg_name), fmt_version!(con), match con { - DependencyConstraint::Version(req) => + DependencyConstraint::Version { req, .. } => match (version_req_bottom_bound(req)?, version_req_top_bound(req)?,) { (Some(bottom), Some(top)) => format!(" ({} <= x < {})", fmt_version!(bottom), fmt_version!(top)), @@ -847,7 +887,10 @@ impl<'ctx> DependencyResolver<'ctx> { .flat_map(|(_src, group)| { let mut g: Vec<_> = group.collect(); g.sort_by(|a, b| match (a.0, b.0) { - (DependencyConstraint::Version(va), DependencyConstraint::Version(vb)) => { + ( + DependencyConstraint::Version { req: va, .. }, + DependencyConstraint::Version { req: vb, .. }, + ) => { // Unbounded requirements have no top/bottom bound; sort them as the // extreme version in the respective direction. let top_bound = |req: &VersionReq| { @@ -876,10 +919,10 @@ impl<'ctx> DependencyResolver<'ctx> { } (DependencyConstraint::Path, _) => std::cmp::Ordering::Greater, (_, DependencyConstraint::Path) => std::cmp::Ordering::Less, - (DependencyConstraint::Version(_), DependencyConstraint::Revision(_)) => { + (DependencyConstraint::Version { .. }, DependencyConstraint::Revision(_)) => { std::cmp::Ordering::Greater } - (DependencyConstraint::Revision(_), DependencyConstraint::Version(_)) => { + (DependencyConstraint::Revision(_), DependencyConstraint::Version { .. }) => { std::cmp::Ordering::Less } }); @@ -1031,7 +1074,7 @@ impl<'ctx> DependencyResolver<'ctx> { use self::DependencyVersions as DepVer; match (con, &src.versions) { (&DepCon::Path, &DepVer::Path) => Ok(IndexSet::from([0])), - (DepCon::Version(con), DepVer::Git(gv)) => { + (DepCon::Version { req: con, prefix }, DepVer::Git(gv)) => { // TODO: Move this outside somewhere. Very inefficient! let hash_ids: IndexMap<&str, usize> = gv .revs @@ -1044,7 +1087,7 @@ impl<'ctx> DependencyResolver<'ctx> { .iter() .sorted_by(|a, b| a.version.cmp(&b.version)) .filter_map(|tv| { - if tv.prefix == "v" && con.matches(&tv.version) { + if tv.prefix == prefix.as_str() && con.matches(&tv.version) { Some((&tv.version, tv.hash)) } else { None @@ -1088,7 +1131,7 @@ impl<'ctx> DependencyResolver<'ctx> { revs.sort(); Ok(revs) } - (DepCon::Version(_con), DepVer::Registry(_rv)) => Err(err!( + (DepCon::Version { .. }, DepVer::Registry(_rv)) => Err(err!( "Constraints on registry dependency `{}` not implemented", name )), diff --git a/src/sess.rs b/src/sess.rs index f0a852bf..271b2bfa 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -2176,7 +2176,14 @@ pub enum DependencyConstraint { /// constraint on it. Path, /// A version constraint. These may occur for registry or git dependencies. - Version(semver::VersionReq), + /// Only tags carrying the given `prefix` (default `v`) satisfy the + /// constraint, which keeps namespaced versions separate. + Version { + /// The version requirement. + req: semver::VersionReq, + /// The literal version-tag prefix (default `v`). + prefix: String, + }, /// A revision constraint. These occur for git dependencies. Revision(String), } @@ -2185,10 +2192,20 @@ impl<'a> From<&'a config::Dependency> for DependencyConstraint { fn from(cfg: &'a config::Dependency) -> DependencyConstraint { match *cfg { config::Dependency::Path { .. } => DependencyConstraint::Path, - config::Dependency::Version { ref version, .. } - | config::Dependency::GitVersion { ref version, .. } => { - DependencyConstraint::Version(version.clone()) - } + config::Dependency::Version { ref version, .. } => DependencyConstraint::Version { + req: version.clone(), + prefix: config::DEFAULT_VERSION_PREFIX.to_string(), + }, + config::Dependency::GitVersion { + ref version, + ref version_prefix, + .. + } => DependencyConstraint::Version { + req: version.clone(), + prefix: version_prefix + .clone() + .unwrap_or_else(|| config::DEFAULT_VERSION_PREFIX.to_string()), + }, config::Dependency::GitRevision { ref rev, .. } => { DependencyConstraint::Revision(rev.clone()) } @@ -2200,7 +2217,18 @@ impl fmt::Display for DependencyConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { DependencyConstraint::Path => write!(f, "path"), - DependencyConstraint::Version(ref v) => write!(f, "{}", v), + DependencyConstraint::Version { + ref req, + ref prefix, + } => { + if prefix == config::DEFAULT_VERSION_PREFIX { + write!(f, "{}", req) + } else if prefix.is_empty() { + write!(f, "{} (unprefixed)", req) + } else { + write!(f, "{} (prefix `{}`)", req, prefix) + } + } DependencyConstraint::Revision(ref r) => write!(f, "{}", r), } } From 7794f76ffe9a2d334c0de15a3b885ee720021f49 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:44:04 +0200 Subject: [PATCH 05/12] Persist resolved version prefix in the lockfile Record the namespace a git dependency resolved under so that re-resolution (`bender update`) stays in the same namespace instead of falling back to the default `v`. The resolver now tracks the resolved prefix per dependency, writes it into the new optional `version_prefix` field of LockedPackage, and reads it back when reconstructing constraints from the lockfile. The field is `#[serde(default, skip_serializing_if = "Option::is_none")]`, so default-`v` dependencies neither read nor write it: existing lockfiles deserialize unchanged and keep serializing byte-for-byte identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.rs | 5 +++++ src/resolver.rs | 52 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7565cc37..5bd277c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2022,6 +2022,11 @@ pub struct LockedPackage { pub revision: Option, /// The version of the dependency. pub version: Option, + /// The version-tag prefix (namespace) the version was resolved under. + /// Omitted for the default `v` prefix to keep lockfiles backwards + /// compatible. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_prefix: Option, /// The source of the dependency. #[serde(with = "serde_yaml_ng::with::singleton_map")] pub source: LockedSource, diff --git a/src/resolver.rs b/src/resolver.rs index ecfbdf7b..3ef8b254 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -198,6 +198,7 @@ impl<'ctx> DependencyResolver<'ctx> { LockedPackage { revision: None, version: None, + version_prefix: None, source: LockedSource::Path(path), dependencies: deps, } @@ -215,18 +216,36 @@ impl<'ctx> DependencyResolver<'ctx> { }; let pick = dep.state.pick().unwrap(); let rev = gv.revs[pick.1]; - let version = gv - .versions - .iter() - .filter(|tv| { - tv.hash == rev && tv.prefix == config::DEFAULT_VERSION_PREFIX - }) - .map(|tv| &tv.version) - .max() - .map(|v| v.to_string()); + // The resolved revision is authoritative: lock the tag that points at + // it. Where a conflict is settled in favour of another namespace, the + // picked revision need not lie in the namespace imposed first, so + // reading the prefix back off the revision keeps the recorded version + // and namespace consistent with what was actually checked out. + let tag = match dep.version_prefix.as_deref() { + Some(imposed) => gv + .versions + .iter() + .filter(|tv| tv.hash == rev) + // Where several namespaces tag one commit, the imposed one wins. + .max_by_key(|tv| (tv.prefix == imposed, tv.version.clone())), + // Revision-pinned: only a default-namespace tag becomes a version, + // so pinning a commit that a fork happens to tag stays a revision. + None => gv + .versions + .iter() + .filter(|tv| { + tv.hash == rev && tv.prefix == config::DEFAULT_VERSION_PREFIX + }) + .max_by_key(|tv| tv.version.clone()), + }; LockedPackage { revision: Some(String::from(rev)), - version, + version: tag.map(|tv| tv.version.to_string()), + // Omit the default `v` prefix for backwards-compatible lockfiles. + version_prefix: tag + .map(|tv| tv.prefix) + .filter(|p| *p != config::DEFAULT_VERSION_PREFIX) + .map(String::from), source: LockedSource::Git(url), dependencies: deps, } @@ -392,7 +411,7 @@ impl<'ctx> DependencyResolver<'ctx> { pre: parsed_version.pre, }], }, - version_prefix: None, // TODO + version_prefix: locked_package.version_prefix.clone(), pass_targets: Vec::new(), } } else { @@ -669,6 +688,14 @@ impl<'ctx> DependencyResolver<'ctx> { // Impose the constraints on the dependencies. let mut table = mem::take(&mut self.table); for (name, cons) in cons_map { + // Record the resolved namespace prefix for lockfile writing. The + // guard above guarantees all version constraints share one prefix. + if let Some((_, DependencyConstraint::Version { prefix, .. }, _)) = cons + .iter() + .find(|(_, con, _)| matches!(con, DependencyConstraint::Version { .. })) + { + table.get_mut(name).unwrap().version_prefix = Some(prefix.clone()); + } for (_, con, dsrc) in &cons { log::debug!("impose `{}` at `{}` on `{}`", con, dsrc, name); let table_item = table.get_mut(name).unwrap(); @@ -1316,6 +1343,9 @@ struct Dependency<'ctx> { sources: IndexMap>, /// The picked manifest for this dependency. manifest: Option<&'ctx config::Manifest>, + /// The resolved version-tag prefix (namespace), if version-constrained. + /// `None` is interpreted as the default `v` prefix. + version_prefix: Option, /// The current resolution state. state: State, } From 857d1e07ebe5f84473c76607f733052af318ca6f Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:45:29 +0200 Subject: [PATCH 06/12] Display resolved versions with their namespace prefix Carry the resolved version prefix on DependencyEntry (populated from the lockfile) and use it in `bender packages --version` instead of hardcoding `v`. Default-`v` dependencies still print as `v1.2.3`; a namespaced dependency now prints as e.g. `companyX-v1.2.3`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmd/packages.rs | 9 ++++++++- src/cmd/parents.rs | 16 +++++++++++++--- src/cmd/update.rs | 23 +++++++++-------------- src/sess.rs | 5 +++++ 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/cmd/packages.rs b/src/cmd/packages.rs index d9a4a278..d2565a79 100644 --- a/src/cmd/packages.rs +++ b/src/cmd/packages.rs @@ -120,7 +120,14 @@ pub fn run(sess: &Session, args: &PackagesArgs) -> Result<()> { "{}:\t{}\tat {}\t{}\n", pkg_source.name, match pkg_source.version { - Some(ref v) => format!("v{}", v), + Some(ref v) => format!( + "{}{}", + pkg_source + .version_prefix + .as_deref() + .unwrap_or(crate::config::DEFAULT_VERSION_PREFIX), + v + ), None => "".to_string(), }, pkg_source.source, diff --git a/src/cmd/parents.rs b/src/cmd/parents.rs index a3aa7b4e..5d4e6449 100644 --- a/src/cmd/parents.rs +++ b/src/cmd/parents.rs @@ -115,7 +115,12 @@ pub fn run(sess: &Session, args: &ParentsArgs) -> Result<()> { "{} used version: {} at {}{}", sess.dependency(mydep).name, match sess.dependency(mydep).version { - Some(ref ver) => ver.to_string(), + // The default `v` namespace is left implicit, as it always has been; only a custom + // one needs spelling out. + Some(ref ver) => match sess.dependency(mydep).version_prefix.as_deref() { + Some(prefix) => format!("{}{}", prefix, ver), + None => ver.to_string(), + }, None => String::new(), }, sess.dependency(mydep).source, @@ -142,12 +147,17 @@ pub fn run(sess: &Session, args: &ParentsArgs) -> Result<()> { Dependency::GitVersion { ref url, ref version, + ref version_prefix, .. } => { format!( - "git {} with version {}", + "git {} with version {}{}", fmt_path!(url), - fmt_version!(version) + fmt_version!(version), + match version_prefix { + Some(prefix) => format!(" (prefix `{}`)", prefix), + None => String::new(), + } ) } }, diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 64fd21de..27e23441 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -165,6 +165,13 @@ pub fn run_plain<'ctx>( }; let update_map: BTreeMap, Option)> = update_map.into_iter().chain(removed_map).collect(); + // A custom namespace is spelled out so a move between namespaces is visible rather than + // looking like a plain version bump. The default `v` stays implicit, as before. + let describe = |pkg: &LockedPackage| match (&pkg.version, pkg.version_prefix.as_deref()) { + (Some(version), Some(prefix)) => format!("{}{}", prefix, version), + (Some(version), None) => version.clone(), + (None, _) => pkg.revision.clone().unwrap_or_else(|| "path".to_string()), + }; let mut update_str = String::from(""); for (name, (existing_dep, new_dep)) in &update_map { update_str.push_str(&format!( @@ -173,23 +180,11 @@ pub fn run_plain<'ctx>( fmt_pkg!(name) )); if let Some(existing_dep) = existing_dep { - update_str.push_str( - existing_dep - .version - .as_deref() - .or(existing_dep.revision.as_deref()) - .unwrap_or("path"), - ); + update_str.push_str(&describe(existing_dep)); } update_str.push_str("\t-> "); if let Some(new_dep) = new_dep { - update_str.push_str( - new_dep - .version - .as_deref() - .or(new_dep.revision.as_deref()) - .unwrap_or("path"), - ); + update_str.push_str(&describe(new_dep)); } update_str.push('\n'); } diff --git a/src/sess.rs b/src/sess.rs index 271b2bfa..d12a2b3d 100644 --- a/src/sess.rs +++ b/src/sess.rs @@ -191,6 +191,7 @@ impl<'ctx> Session<'ctx> { source: src, revision: None, version: None, + version_prefix: None, })) } @@ -217,6 +218,7 @@ impl<'ctx> Session<'ctx> { .version .as_ref() .map(|s| semver::Version::parse(s).unwrap()), + version_prefix: pkg.version_prefix.clone(), }), ); graph_names.insert(id, &pkg.dependencies); @@ -2004,6 +2006,9 @@ pub struct DependencyEntry { pub revision: Option, /// The picked version. pub version: Option, + /// The version-tag prefix (namespace) the version was resolved under. + /// `None` is interpreted as the default `v` prefix. + pub version_prefix: Option, } impl DependencyEntry { From a65ae7117ca866168fa92ded88b72a81b696514a Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:50:28 +0200 Subject: [PATCH 07/12] Test namespaced version resolution Add unit tests for `split_version_tag` covering the default `v` prefix, custom and digit-containing prefixes, the empty prefix, prerelease/build metadata, and rejection of non-versions. Add an integration test that builds git fixtures carrying both `v*` and `companyX-v*` tags and drives the real binary to verify: default resolution picks the highest `v*` tag and records no prefix; a `companyX-v` dependency resolves within its namespace (ignoring `v*`) and persists the prefix; and mixing prefixes for one dependency fails with a clear conflict error. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.rs | 56 ++++++ src/resolver.rs | 28 --- tests/version_namespacing.rs | 327 +++++++++++++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 tests/version_namespacing.rs diff --git a/src/config.rs b/src/config.rs index 5bd277c6..2eec3a12 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2084,3 +2084,59 @@ pub fn split_version_tag(tag: &str) -> Option<(&str, semver::Version)> { } None } + +#[cfg(test)] +mod tests { + use super::split_version_tag; + + fn split(tag: &str) -> Option<(String, String)> { + split_version_tag(tag).map(|(p, v)| (p.to_string(), v.to_string())) + } + + #[test] + fn splits_default_v_prefix() { + assert_eq!(split("v1.2.3"), Some(("v".into(), "1.2.3".into()))); + } + + #[test] + fn splits_custom_prefix() { + assert_eq!( + split("companyX-v1.2.3"), + Some(("companyX-v".into(), "1.2.3".into())) + ); + assert_eq!( + split("release-1.0.0"), + Some(("release-".into(), "1.0.0".into())) + ); + } + + #[test] + fn prefix_may_contain_digits() { + // The split must not occur in the middle of `company2`. + assert_eq!( + split("company2-v1.0.0"), + Some(("company2-v".into(), "1.0.0".into())) + ); + } + + #[test] + fn empty_prefix_is_allowed() { + assert_eq!(split("1.2.3"), Some(("".into(), "1.2.3".into()))); + } + + #[test] + fn keeps_prerelease_and_build_metadata() { + assert_eq!( + split("v1.2.3-rc.1+build.5"), + Some(("v".into(), "1.2.3-rc.1+build.5".into())) + ); + } + + #[test] + fn rejects_non_versions() { + assert_eq!(split("nonsense"), None); + // Not a full `major.minor.patch` semantic version. + assert_eq!(split("v1.2"), None); + assert_eq!(split(""), None); + } +} diff --git a/src/resolver.rs b/src/resolver.rs index 3ef8b254..cec17d82 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -638,34 +638,6 @@ impl<'ctx> DependencyResolver<'ctx> { map }; - // Namespaced versions must not be mixed. A dependency required with more - // than one distinct version prefix has no shared namespace, so there is - // no automatic resolution (no fallback to the default `v`). The user must - // pick one explicitly via an override, which collapses all requirements - // for the dependency to a single constraint. - for (name, cons) in &cons_map { - let prefixes: IndexSet<&str> = cons - .iter() - .filter_map(|(_, con, _)| match con { - DependencyConstraint::Version { prefix, .. } => Some(prefix.as_str()), - _ => None, - }) - .collect(); - if prefixes.len() > 1 { - bail!( - "Dependency `{}` is required with conflicting version prefixes ({}). \ - Namespaced versions cannot be mixed; add an override for `{}` to select one.", - name, - prefixes - .iter() - .map(|p| format!("`{}`", p)) - .collect::>() - .join(", "), - name, - ); - } - } - let _src_cons_map = cons_map .iter() .map(|(name, cons)| { diff --git a/tests/version_namespacing.rs b/tests/version_namespacing.rs new file mode 100644 index 00000000..09c6fb63 --- /dev/null +++ b/tests/version_namespacing.rs @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ETH Zurich + +//! Integration tests for namespaced (prefixed) version resolution. +//! +//! These build throwaway git repositories with both default `v*` tags and +//! `companyX-v*` tags, then drive the real `bender` binary to check that +//! resolution stays within the requested namespace, persists it, and refuses +//! to mix namespaces. + +// `file://` URLs with absolute paths are awkward on Windows; the git-based +// fixtures here mirror the bash regression scripts, which are Unix-only. +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use assert_cmd::cargo; + +/// Run a git command in `dir`, asserting success. +fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@localhost") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@localhost") + .output() + .expect("failed to run git"); + assert!( + status.status.success(), + "git {:?} failed:\n{}", + args, + String::from_utf8_lossy(&status.stderr) + ); +} + +fn write(path: &Path, contents: &str) { + fs::write(path, contents).expect("failed to write file"); +} + +/// Create a fresh, empty directory under the test binary's tmp dir. +fn fresh_dir(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + if dir.exists() { + fs::remove_dir_all(&dir).unwrap(); + } + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Create a `foo` dependency repo carrying both `v*` and `companyX-v*` tags. +/// +/// Tags: `v1.0.0`, `v1.1.0`, `companyX-v1.0.0`, `companyX-v2.0.0`. +fn setup_foo(base: &Path) -> String { + let repo = base.join("foo"); + fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + write(&repo.join("Bender.yml"), "package:\n name: foo\n"); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&repo, &["tag", "v1.0.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c2"]); + git(&repo, &["tag", "v1.1.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c3"]); + git(&repo, &["tag", "companyX-v1.0.0"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "c4"]); + git(&repo, &["tag", "companyX-v2.0.0"]); + format!("file://{}", repo.display()) +} + +/// Create a project directory with the given `Bender.yml` body. +fn setup_project(base: &Path, name: &str, manifest: &str) -> PathBuf { + let dir = base.join(name); + fs::create_dir_all(&dir).unwrap(); + write(&dir.join("Bender.yml"), manifest); + dir +} + +fn bender_update(root: &Path) -> Output { + cargo::cargo_bin_cmd!() + .arg("update") + .current_dir(root) + .output() + .expect("failed to run bender") +} + +#[test] +fn resolves_default_v_namespace() { + let base = fresh_dir("default_v"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + // Highest `v1.x` tag wins, and the default prefix is not recorded. + assert!(lock.contains("version: 1.1.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version_prefix"), + "default prefix must not be persisted:\n{lock}" + ); +} + +#[test] +fn resolves_custom_namespace() { + let base = fresh_dir("custom_ns"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"*\", version_prefix: \"companyX-v\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + // Resolution stays in the `companyX-v` namespace: highest there is 2.0.0, + // and the `v1.x` tags are ignored entirely. + assert!(lock.contains("version: 2.0.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version: 1.1.0"), + "must not leak the default `v` namespace:\n{lock}" + ); + assert!( + lock.contains("version_prefix: companyX-v"), + "custom prefix must be persisted:\n{lock}" + ); +} + +#[test] +fn conflicting_namespaces_fail() { + let base = fresh_dir("conflict"); + let foo_url = setup_foo(&base); + + // `bar` requires foo from the `companyX-v` namespace. + let bar_dir = setup_project( + &base, + "bar", + &format!( + "package:\n name: bar\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"*\", version_prefix: \"companyX-v\" }}\n" + ), + ); + git(&bar_dir, &["init", "-q"]); + git(&bar_dir, &["add", "."]); + git(&bar_dir, &["commit", "-q", "-m", "init"]); + git(&bar_dir, &["tag", "v1.0.0"]); + let bar_url = format!("file://{}", bar_dir.display()); + + // The app requires foo from the default `v` namespace and bar (which pulls + // foo from `companyX-v`): two distinct prefixes, hence no resolution. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"1\" }}\n \ + bar: {{ git: \"{bar_url}\", version: \"1\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + !out.status.success(), + "update unexpectedly succeeded:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + // Reported through the normal conflict path, which lists each requirement. Without a TTY + // there is nobody to ask, so it stays a hard error -- CI still fails loudly. + assert!( + stderr.contains("conflict with each other"), + "expected a requirements conflict, got:\n{stderr}" + ); + assert!( + stderr.contains("prefix `companyX-v`"), + "the conflicting namespace must be visible in the requirements:\n{stderr}" + ); +} + +/// A `bar` repo depending on `foo` under the default `v` namespace, tagged `v0.1.0`. +fn setup_bar(base: &Path, foo_url: &str) -> String { + let repo = base.join("bar"); + fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + write( + &repo.join("Bender.yml"), + &format!( + "package:\n name: bar\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + ), + ); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&repo, &["tag", "v0.1.0"]); + format!("file://{}", repo.display()) +} + +/// The namespace recorded in the lockfile is honoured on a re-resolve, rather than the run +/// silently drifting back to the default `v` namespace. +#[test] +fn lockfile_namespace_round_trips() { + let base = fresh_dir("lock_round_trip"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\", version_prefix: \"companyX-v\" }}\n" + ), + ); + + assert!(bender_update(&app).status.success()); + let first = fs::read_to_string(app.join("Bender.lock")).unwrap(); + assert!(first.contains("version_prefix: companyX-v"), "{first}"); + + // Re-resolving against the existing lockfile must keep both version and namespace. + let out = bender_update(&app); + assert!( + out.status.success(), + "second update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let second = fs::read_to_string(app.join("Bender.lock")).unwrap(); + assert!(second.contains("version: 1.0.0"), "{second}"); + assert!(second.contains("version_prefix: companyX-v"), "{second}"); +} + +/// The manifest, not a stale lockfile, decides the namespace: dropping `version_prefix` must move +/// the dependency back into the default `v` namespace on the next update. +#[test] +fn manifest_change_overrides_locked_namespace() { + let base = fresh_dir("lock_stale"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\", version_prefix: \"companyX-v\" }}\n" + ), + ); + assert!(bender_update(&app).status.success()); + assert!( + fs::read_to_string(app.join("Bender.lock")) + .unwrap() + .contains("version_prefix: companyX-v") + ); + + write( + &app.join("Bender.yml"), + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + ), + ); + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + // Back to the highest `v1.x` tag, with no namespace recorded. + assert!(lock.contains("version: 1.1.0"), "{lock}"); + assert!( + !lock.contains("version_prefix"), + "stale namespace must not survive a manifest change:\n{lock}" + ); +} + +/// The remedy the conflict error points at actually works: an `overrides` entry in `.bender.yml` +/// collapses two competing namespaces onto one. +#[test] +fn override_resolves_namespace_conflict() { + let base = fresh_dir("override_conflict"); + let foo_url = setup_foo(&base); + let bar_url = setup_bar(&base, &foo_url); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\", version_prefix: \"companyX-v\" }}\n bar: {{ git: \"{bar_url}\", version: \"0.1.0\" }}\n" + ), + ); + + // Without the override the two namespaces collide. + let out = bender_update(&app); + assert!(!out.status.success(), "conflicting namespaces must fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("conflict with each other"), "{stderr}"); + + write( + &app.join(".bender.yml"), + &format!( + "overrides:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\", version_prefix: \"companyX-v\" }}\n" + ), + ); + let out = bender_update(&app); + assert!( + out.status.success(), + "override must resolve the conflict:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + assert!(lock.contains("version_prefix: companyX-v"), "{lock}"); +} From acbc489080839c8004f22b1304a9384be1a51723 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 20:51:33 +0200 Subject: [PATCH 08/12] Document version namespaces Add a "Version Namespaces" section to the dependencies docs explaining `version_prefix`, the default `v` prefix, the strict no-mixing/no-fallback rule, and override-based conflict resolution. Note the new lockfile `version_prefix` field and add a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + book/src/dependencies.md | 25 ++++++++++++++++++++++++- book/src/lockfile.md | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4009b90..339bd423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## 0.32.1 - 2026-07-07 ### Added +- Add `version_prefix` to Git version dependencies, allowing versioned forks to be tagged and resolved under their own namespace (e.g. `companyX-v1.2.0`). Defaults to `v` for full backwards compatibility; namespaces are strict and never mix, and the resolved prefix is recorded in `Bender.lock`. - Add `git_submodules` config field and `--git-submodules ` flag (env `BENDER_GIT_SUBMODULES`) to control cloning of dependency submodules; defaults to `true`, the flag overrides the configured value in either direction (https://github.com/pulp-platform/bender/pull/314). ### Fixed diff --git a/book/src/dependencies.md b/book/src/dependencies.md index 6f8b97c5..43b2c1ae 100644 --- a/book/src/dependencies.md +++ b/book/src/dependencies.md @@ -26,7 +26,30 @@ dependencies: axi: { version: ">=0.23.0, <0.26.0" } ``` -> **Note:** Bender only recognizes Git tags that follow the `vX.Y.Z` format (e.g., `v1.2.1`). +> **Note:** By default, Bender only recognizes Git tags that follow the `vX.Y.Z` format (e.g., `v1.2.1`). See [Version Namespaces](#version-namespaces) if you need a different prefix. + +#### Version Namespaces + +The leading `v` in a version tag is simply the default *prefix*. If you maintain your own versioned fork of an open-source IP — for example to ship internally patched releases — you can tag those releases under your own namespace and depend on them with `version_prefix`: + +```yaml +dependencies: + # Resolves only tags of the form `companyX-v`, e.g. `companyX-v1.2.0`. + common_cells: { git: "...", version: "1.21.0", version_prefix: "companyX-v" } +``` + +The prefix is the entire literal string preceding the semantic version, so you are free to choose any convention (`companyX-v`, `acme-`, …). A dependency without `version_prefix` keeps the default `v` prefix, so existing manifests and lockfiles are unaffected. The field only applies to Git version dependencies. + +Namespaces are **strict and never mix**: + +- A dependency resolves *only* tags carrying its own prefix. There is no fallback to the default `v` namespace (or any other). +- If the same dependency is required with two different prefixes anywhere in the dependency tree, Bender never guesses. It reports the clash like any other conflicting requirement: on a terminal it asks you to pick one of the requirements, and without one (in CI, say) it fails. To settle it permanently, add an [`overrides`](./configuration.md) entry pinning the dependency to a single namespace — note that overrides live in `.bender.yml`, not in `Bender.yml`: + +```yaml +# .bender.yml +overrides: + common_cells: { git: "...", version: "1.21.0", version_prefix: "companyX-v" } +``` #### Revision-based Use this for specific commits, branches, or tags that don't follow SemVer. diff --git a/book/src/lockfile.md b/book/src/lockfile.md index 85544c5d..d4333cb9 100644 --- a/book/src/lockfile.md +++ b/book/src/lockfile.md @@ -35,6 +35,7 @@ packages: - **revision:** The full 40-character Git commit hash. - **version:** The SemVer version that was resolved. +- **version_prefix:** The version [namespace](./dependencies.md#version-namespaces) the version was resolved under. Omitted for the default `v` prefix. - **source:** Where to download the package from. - **dependencies:** A list of other packages that this specific package depends on, ensuring the entire tree is captured. From cd08a87dd9456829303274fcbb02d2d168b118a7 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 21:00:57 +0200 Subject: [PATCH 09/12] Parse version prefixes embedded in the version string Support specifying a namespace inline, e.g. `version: "companyX-v1.2.*"`, in addition to the separate `version_prefix` field. Manifest validation now splits the version requirement into an optional literal prefix and the semver requirement via `split_version_req`, normalizing the embedded form into the existing internal representation. The split only triggers when the version part begins with a number, so bare requirements (`1.2.*`, `>=1.0.0`, `*`) keep the default `v` prefix and remain backwards compatible; operator-led prefixed ranges are rejected rather than mis-parsed and must use the `version_prefix` field. When both an embedded prefix and the field are given they must agree, otherwise validation fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 15 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2eec3a12..1d2141f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -730,16 +730,37 @@ impl Validate for PartialDependency { .into_iter() .map(|s| s.validate(vctx)) .collect::>>()?; - let version = self - .version - .map(|v| { - semver::VersionReq::parse(&v) - .into_diagnostic() - .wrap_err_with(|| { - format!("\"{}\" is not a valid semantic version requirement.", v) - }) - }) - .transpose()?; + // Parse the version requirement, extracting any literal prefix embedded + // in the version string (e.g. `companyX-v1.2.*`). + let (version, embedded_prefix) = match self.version.as_deref() { + Some(v) => { + let (prefix, req) = split_version_req(v).ok_or_else(|| { + err!("\"{}\" is not a valid semantic version requirement.", v) + })?; + ( + (Some(req)), + (!prefix.is_empty()).then(|| prefix.to_string()), + ) + } + None => (None, None), + }; + // Reconcile an embedded prefix with an explicit `version_prefix` field; + // the two must agree if both are given. The default `v` prefix is + // represented as `None`. + let version_prefix = match (self.version_prefix, embedded_prefix) { + (Some(field), Some(embedded)) if field != embedded => { + bail!( + "Conflicting version prefixes for `{}`: `version_prefix: {}` does not match \ + the prefix `{}` embedded in the version string.", + vctx.package_name, + field, + embedded + ); + } + (Some(field), _) => Some(field), + (None, embedded) => embedded, + } + .filter(|p| p != DEFAULT_VERSION_PREFIX); if !vctx.pre_output { self.extra.iter().for_each(|(k, _)| { Warnings::IgnoreUnknownField { @@ -750,7 +771,7 @@ impl Validate for PartialDependency { }); } - match (self.git, self.path, self.rev, version, self.remote) { + let dep = match (self.git, self.path, self.rev, version, self.remote) { // Git dependencies with default remote, e.g.: // ```yaml // my_dep: "1.2.3" @@ -763,7 +784,7 @@ impl Validate for PartialDependency { target, url: default_remote.url.replace("{}", git_name), version, - version_prefix: self.version_prefix, + version_prefix: version_prefix.clone(), pass_targets, }) } else { @@ -783,7 +804,7 @@ impl Validate for PartialDependency { target, url: remote.url.replace("{}", git_name), version, - version_prefix: self.version_prefix, + version_prefix: version_prefix.clone(), pass_targets, }) } else { @@ -801,7 +822,7 @@ impl Validate for PartialDependency { target, url: git, version, - version_prefix: self.version_prefix, + version_prefix: version_prefix.clone(), pass_targets, }), // Git dependencies with revisions, e.g.: @@ -852,7 +873,18 @@ impl Validate for PartialDependency { "Invalid configuration for dependency `{}`: {cfg:?}", vctx.package_name )), + }?; + // A namespace only means something where versions are resolved from git tags, so reject + // it elsewhere rather than dropping it without a trace -- the same treatment `version` + // and `rev` get when they appear where they cannot apply. A `version`-only dependency + // resolves through the default remote and is a git version dependency, so it is fine. + if version_prefix.is_some() && !matches!(dep, Dependency::GitVersion { .. }) { + bail!( + "Dependency `{}` cannot specify `version_prefix` without a `version` requirement. Version namespaces only apply to git dependencies resolved by version.", + vctx.package_name + ); } + Ok(dep) } } @@ -2085,14 +2117,61 @@ pub fn split_version_tag(tag: &str) -> Option<(&str, semver::Version)> { None } +/// Split a version *requirement* string into an optional embedded literal +/// prefix and the semantic version requirement. +/// +/// A bare requirement — including operators and wildcards such as `1.2.*`, +/// `>=1.0.0` or `*` — yields an empty prefix. An embedded prefix is recognised +/// only when the version part begins with a number, e.g. `companyX-v1.2.*` +/// splits into (`companyX-v`, `1.2.*`). Prefixed ranges that start with an +/// operator or wildcard are not supported in this embedded form; use the +/// separate `version_prefix` field for those. +/// +/// Returns `None` if the string is not a valid (optionally prefixed) version +/// requirement. +pub fn split_version_req(s: &str) -> Option<(&str, semver::VersionReq)> { + // A bare requirement has no prefix. This also covers operator- and + // wildcard-led requirements, which an embedded prefix cannot precede. + if let Ok(req) = semver::VersionReq::parse(s) { + return Some(("", req)); + } + // Otherwise look for a literal prefix followed by a numeric requirement. + // The version part must begin with a digit that does not continue a + // longer run of digits, so we never split in the middle of a number. + let bytes = s.as_bytes(); + for i in 1..bytes.len() { + if !bytes[i].is_ascii_digit() || bytes[i - 1].is_ascii_digit() { + continue; + } + // The prefix must be a plain namespace, not part of a requirement. If it + // contains requirement syntax we would silently swallow an operator + // (e.g. read `companyX-v>=1.0.0` as prefix `companyX-v>=`, req `^1.0.0`). + // Such operator-led requirements must use the `version_prefix` field. + if s[..i] + .bytes() + .any(|b| matches!(b, b'*' | b'^' | b'~' | b'<' | b'>' | b'=' | b',' | b' ')) + { + break; + } + if let Ok(req) = semver::VersionReq::parse(&s[i..]) { + return Some((&s[..i], req)); + } + } + None +} + #[cfg(test)] mod tests { - use super::split_version_tag; + use super::{split_version_req, split_version_tag}; fn split(tag: &str) -> Option<(String, String)> { split_version_tag(tag).map(|(p, v)| (p.to_string(), v.to_string())) } + fn split_req(s: &str) -> Option<(String, String)> { + split_version_req(s).map(|(p, r)| (p.to_string(), r.to_string())) + } + #[test] fn splits_default_v_prefix() { assert_eq!(split("v1.2.3"), Some(("v".into(), "1.2.3".into()))); @@ -2139,4 +2218,40 @@ mod tests { assert_eq!(split("v1.2"), None); assert_eq!(split(""), None); } + + #[test] + fn version_req_bare_has_no_prefix() { + // Plain requirements, operators and wildcards keep the default prefix. + assert_eq!(split_req("1.2.3"), Some(("".into(), "^1.2.3".into()))); + assert_eq!(split_req("1.2.*"), Some(("".into(), "1.2.*".into()))); + assert_eq!(split_req("*"), Some(("".into(), "*".into()))); + assert_eq!( + split_req(">=1.0.0, <2.0.0"), + Some(("".into(), ">=1.0.0, <2.0.0".into())) + ); + } + + #[test] + fn version_req_embedded_prefix() { + assert_eq!( + split_req("companyX-v1.2.*"), + Some(("companyX-v".into(), "1.2.*".into())) + ); + // An explicit leading `v` is just the default prefix. + assert_eq!(split_req("v1.2.3"), Some(("v".into(), "^1.2.3".into()))); + // A digit inside the prefix must not cause a mid-token split, because + // `2-v1.0.0` is not itself a valid requirement. + assert_eq!( + split_req("company2-v1.0.0"), + Some(("company2-v".into(), "^1.0.0".into())) + ); + } + + #[test] + fn version_req_rejects_unsupported() { + // Operator/wildcard-led requirements cannot carry an embedded prefix. + assert_eq!(split_req("companyX-v*"), None); + assert_eq!(split_req("companyX-v>=1.0.0"), None); + assert_eq!(split_req("garbage"), None); + } } From a1a204f1152245756505bf2e87c881db60abda8d Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 21:02:38 +0200 Subject: [PATCH 10/12] Test and document embedded version prefixes Add integration tests covering the embedded form: `version: "companyX-v2"` resolves within the namespace and persists the prefix, and an embedded prefix that disagrees with the `version_prefix` field fails. Document both forms and the embedded-form limitations in the dependencies guide and CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- book/src/dependencies.md | 13 ++++- tests/version_namespacing.rs | 97 +++++++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 339bd423..d1883ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## 0.32.1 - 2026-07-07 ### Added -- Add `version_prefix` to Git version dependencies, allowing versioned forks to be tagged and resolved under their own namespace (e.g. `companyX-v1.2.0`). Defaults to `v` for full backwards compatibility; namespaces are strict and never mix, and the resolved prefix is recorded in `Bender.lock`. +- Add version namespaces to Git version dependencies, allowing versioned forks to be tagged and resolved under their own prefix (e.g. `companyX-v1.2.0`). The prefix may be given via the `version_prefix` field or embedded in the version string (`version: "companyX-v1.2.0"`). Defaults to `v` for full backwards compatibility, and `version_prefix: ""` selects unprefixed tags; namespaces are strict and never mix -- a dependency required under two namespaces is reported like any other conflicting requirement, offering the usual interactive choice on a terminal -- and the resolved prefix is recorded in `Bender.lock`. The field is only meaningful on git version dependencies; elsewhere it is rejected rather than dropped silently. - Add `git_submodules` config field and `--git-submodules ` flag (env `BENDER_GIT_SUBMODULES`) to control cloning of dependency submodules; defaults to `true`, the flag overrides the configured value in either direction (https://github.com/pulp-platform/bender/pull/314). ### Fixed diff --git a/book/src/dependencies.md b/book/src/dependencies.md index 43b2c1ae..d24478bf 100644 --- a/book/src/dependencies.md +++ b/book/src/dependencies.md @@ -38,7 +38,18 @@ dependencies: common_cells: { git: "...", version: "1.21.0", version_prefix: "companyX-v" } ``` -The prefix is the entire literal string preceding the semantic version, so you are free to choose any convention (`companyX-v`, `acme-`, …). A dependency without `version_prefix` keeps the default `v` prefix, so existing manifests and lockfiles are unaffected. The field only applies to Git version dependencies. +You can equivalently embed the prefix directly in the version string: + +```yaml +dependencies: + common_cells: { git: "...", version: "companyX-v1.21.0" } +``` + +The embedded form requires the version requirement to begin with a number (e.g. `companyX-v1.21.0`, `companyX-v1.*`). For operator-based ranges such as `>=1.21.0`, use the `version_prefix` field alongside a plain `version`. If both a field and an embedded prefix are given, they must agree. + +The prefix is the entire literal string preceding the semantic version, so you are free to choose any convention (`companyX-v`, `acme-`, …). Setting `version_prefix: ""` selects tags carrying no prefix at all (`1.2.0` rather than `v1.2.0`). A dependency without a prefix keeps the default `v`, so existing manifests and lockfiles are unaffected. + +Prefixes only apply to Git version dependencies. On a path or revision dependency the field has nothing to act on, so Bender rejects it rather than ignoring it silently — the same treatment `version` and `rev` get where they cannot apply. A dependency given only a `version` resolves through the default remote and is a Git version dependency, so `version_prefix` is accepted there too. Namespaces are **strict and never mix**: diff --git a/tests/version_namespacing.rs b/tests/version_namespacing.rs index 09c6fb63..14422919 100644 --- a/tests/version_namespacing.rs +++ b/tests/version_namespacing.rs @@ -78,14 +78,18 @@ fn setup_project(base: &Path, name: &str, manifest: &str) -> PathBuf { dir } -fn bender_update(root: &Path) -> Output { +fn bender(root: &Path, args: &[&str]) -> Output { cargo::cargo_bin_cmd!() - .arg("update") + .args(args) .current_dir(root) .output() .expect("failed to run bender") } +fn bender_update(root: &Path) -> Output { + bender(root, &["update"]) +} + #[test] fn resolves_default_v_namespace() { let base = fresh_dir("default_v"); @@ -199,6 +203,66 @@ fn conflicting_namespaces_fail() { ); } +#[test] +fn resolves_embedded_namespace() { + let base = fresh_dir("embedded_ns"); + let foo_url = setup_foo(&base); + // The namespace is embedded directly in the version string instead of the + // separate `version_prefix` field; `companyX-v2` means `^2` in that namespace. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"companyX-v2\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); + assert!(lock.contains("version: 2.0.0"), "lockfile:\n{lock}"); + assert!( + !lock.contains("version: 1.1.0"), + "must not leak the default `v` namespace:\n{lock}" + ); + assert!( + lock.contains("version_prefix: companyX-v"), + "embedded prefix must be persisted:\n{lock}" + ); +} + +#[test] +fn embedded_and_field_prefix_conflict_fails() { + let base = fresh_dir("embedded_conflict"); + let foo_url = setup_foo(&base); + // The embedded prefix (`companyX-v`) disagrees with the explicit field. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n \ + foo: {{ git: \"{foo_url}\", version: \"companyX-v1.0.0\", version_prefix: \"acme-\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + !out.status.success(), + "update unexpectedly succeeded:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Conflicting version prefixes"), + "expected a prefix-conflict error, got:\n{stderr}" + ); +} + /// A `bar` repo depending on `foo` under the default `v` namespace, tagged `v0.1.0`. fn setup_bar(base: &Path, foo_url: &str) -> String { let repo = base.join("bar"); @@ -325,3 +389,32 @@ fn override_resolves_namespace_conflict() { let lock = fs::read_to_string(app.join("Bender.lock")).unwrap(); assert!(lock.contains("version_prefix: companyX-v"), "{lock}"); } + +/// `version_prefix` has nothing to act on outside a git version dependency, so it is rejected +/// rather than dropped silently -- the same treatment `version` and `rev` get in a position +/// where they cannot apply. +#[test] +fn version_prefix_on_path_dependency_is_rejected() { + let base = fresh_dir("prefix_on_path"); + let leaf = setup_project(&base, "leaf", "package:\n name: leaf\n"); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n leaf: {{ path: \"{}\", version_prefix: \"companyX-v\" }}\n", + leaf.display() + ), + ); + + let out = bender(&app, &["packages"]); + assert!( + !out.status.success(), + "a misplaced version_prefix must not be accepted:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("cannot specify `version_prefix` without a `version` requirement"), + "{stderr}" + ); +} From bea66b67e01182dccadd6dcb13ccfc62bb3f9bad Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Thu, 18 Jun 2026 21:09:04 +0200 Subject: [PATCH 11/12] Align audit bump suggestions to the resolved namespace `bender audit` previously only ever considered the default `v` namespace when computing version-bump suggestions. It now filters candidate versions to the namespace the dependency is currently resolved under, falling back to the default `v` when the current checkout is not a version (path or revision). Parent version requirements are read from `DependencyConstraint`'s display, which annotates non-default namespaces as ` (prefix `

`)`; the audit now strips that annotation before parsing, since the namespace is already fixed by resolution. Add an integration test covering a namespaced bump suggestion. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + src/cmd/audit.rs | 65 ++++++---- src/cmd/parents.rs | 224 +++++++++++++++-------------------- tests/version_namespacing.rs | 71 +++++++++++ 4 files changed, 212 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1883ece..b54c5f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## 0.32.1 - 2026-07-07 ### Added - Add version namespaces to Git version dependencies, allowing versioned forks to be tagged and resolved under their own prefix (e.g. `companyX-v1.2.0`). The prefix may be given via the `version_prefix` field or embedded in the version string (`version: "companyX-v1.2.0"`). Defaults to `v` for full backwards compatibility, and `version_prefix: ""` selects unprefixed tags; namespaces are strict and never mix -- a dependency required under two namespaces is reported like any other conflicting requirement, offering the usual interactive choice on a terminal -- and the resolved prefix is recorded in `Bender.lock`. The field is only meaningful on git version dependencies; elsewhere it is rejected rather than dropped silently. +- `bender audit` now aligns its version-bump suggestions to the namespace a dependency is currently resolved under, falling back to the default `v` namespace when the current checkout is not a version, and names that namespace in its output when it is not the default. - Add `git_submodules` config field and `--git-submodules ` flag (env `BENDER_GIT_SUBMODULES`) to control cloning of dependency submodules; defaults to `true`, the flag overrides the configured value in either direction (https://github.com/pulp-platform/bender/pull/314). ### Fixed diff --git a/src/cmd/audit.rs b/src/cmd/audit.rs index 5db02c79..ea0f94dc 100644 --- a/src/cmd/audit.rs +++ b/src/cmd/audit.rs @@ -9,13 +9,12 @@ use std::io::Write; use clap::Args; use futures::future::join_all; use miette::IntoDiagnostic as _; -use semver::VersionReq; use tabwriter::TabWriter; use tokio::runtime::Runtime; use crate::Result; -use crate::cmd::parents::get_parent_array; -use crate::sess::{DependencyVersions, Session, SessionIo}; +use crate::cmd::parents::get_parent_requirements; +use crate::sess::{DependencyConstraint, DependencyVersions, Session, SessionIo}; /// Get information about version conflicts and possible updates. #[derive(Args, Debug)] @@ -77,7 +76,7 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { for pkg in pkgs { let pkg_name = sess.dependency_name(*pkg); - let parent_array = get_parent_array(sess, &rt, &io, pkg_name, false)?; + let parents = get_parent_requirements(sess, &rt, &io, pkg_name)?; let current_version = sess.dependency(*pkg).version.clone(); let current_version_unwrapped = current_version .as_ref() @@ -85,11 +84,30 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { .unwrap_or_default(); let current_revision = sess.dependency(*pkg).revision.clone(); let current_revision_unwrapped = current_revision.as_deref().unwrap_or_default(); + // Align update suggestions with the namespace the dependency is + // currently resolved under. When the current checkout is not a version + // (e.g. a path or revision), fall back to the default `v` namespace. + let current_prefix = if current_version.is_some() { + sess.dependency(*pkg) + .version_prefix + .as_deref() + .unwrap_or(crate::config::DEFAULT_VERSION_PREFIX) + } else { + crate::config::DEFAULT_VERSION_PREFIX + }; + // Every version on this package's lines comes from `current_prefix`, so name the + // namespace once rather than prefixing each number. Left off for the default `v`, which + // keeps existing output unchanged. + let namespace_note = match current_prefix { + crate::config::DEFAULT_VERSION_PREFIX => String::new(), + "" => " (unprefixed namespace)".to_string(), + prefix => format!(" (namespace `{}`)", prefix), + }; let available_versions = match dep_versions.get(pkg).unwrap() { DependencyVersions::Git(versions) => versions .versions .iter() - .filter(|tv| tv.prefix == crate::config::DEFAULT_VERSION_PREFIX) + .filter(|tv| tv.prefix == current_prefix) .map(|tv| tv.version.clone()) .collect(), _ => vec![], @@ -99,24 +117,25 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { let mut conflicting = false; let mut version_req_exists = false; let mut compatible_versions = available_versions.clone(); - let (default_version, url) = parent_array + let (default_constraint, url) = parents .values() .next() - .map(|v| (v[0].clone(), v[1].clone())) - .unwrap_or_else(|| ("".to_string(), "".to_string())); - for parent in parent_array.values() { - match VersionReq::parse(&parent[0]) { - Ok(parent_version) => { - compatible_versions.retain(|v| parent_version.matches(v)); + .map(|p| (Some(p.constraint.clone()), p.source.clone())) + .unwrap_or((None, String::new())); + for parent in parents.values() { + // The namespace is already fixed by resolution, so only the requirement matters here. + match &parent.constraint { + DependencyConstraint::Version { req, .. } => { + compatible_versions.retain(|v| req.matches(v)); version_req_exists = true; } - Err(_) => { - if parent[0] != default_version { + other => { + if Some(other) != default_constraint.as_ref() { conflicting = true; } } } - if parent[1] != url && !args.ignore_url_conflict { + if parent.source != url && !args.ignore_url_conflict { conflicting = true; } } @@ -146,8 +165,8 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { )); if let Some(highest_version) = highest_version { audit_str.push_str(&format!( - "\t\x1B[31;1m\x1B[m\thighest version: {}\n", - highest_version + "\t\x1B[31;1m\x1B[m\thighest version: {}{}\n", + highest_version, namespace_note )); } } @@ -160,8 +179,8 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { && !args.only_update { audit_str.push_str(&format!( - " is \x1B[32;1mUp-to-date\x1B[m:\t@ {}\n", - current_version_unwrapped + " is \x1B[32;1mUp-to-date\x1B[m:\t@ {}{}\n", + current_version_unwrapped, namespace_note )); } @@ -172,8 +191,8 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { && *max_compatible > *current_version { audit_str.push_str(&format!( - "can \x1B[32;1mAuto-update\x1B[m:\t{} -> {}\n", - current_version_unwrapped, max_compatible + "can \x1B[32;1mAuto-update\x1B[m:\t{} -> {}{}\n", + current_version_unwrapped, max_compatible, namespace_note )); } @@ -185,8 +204,8 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { && (max_compatible.is_none() || *max_compatible.unwrap() < *highest_version) { audit_str.push_str(&format!( - " can \x1B[33;1mUpdate\x1B[m:\t{} -> {}\n", - current_version_unwrapped, highest_version + " can \x1B[33;1mUpdate\x1B[m:\t{} -> {}{}\n", + current_version_unwrapped, highest_version, namespace_note )); } } diff --git a/src/cmd/parents.rs b/src/cmd/parents.rs index 5d4e6449..34c45c61 100644 --- a/src/cmd/parents.rs +++ b/src/cmd/parents.rs @@ -59,11 +59,10 @@ pub fn run(sess: &Session, args: &ParentsArgs) -> Result<()> { let rt = Runtime::new().into_diagnostic()?; let io = SessionIo::new(sess); - let parent_array = get_parent_array(sess, &rt, &io, dep, args.targets)?; - if args.targets { + let parent_targets = get_parent_targets(sess, &rt, &io, dep)?; let mut res = String::from(""); - for (k, v) in parent_array.iter() { + for (k, v) in parent_targets.iter() { res.push_str(&format!( " {}\tfilters: {}\tpasses: {:?}\n", k, @@ -78,26 +77,25 @@ pub fn run(sess: &Session, args: &ParentsArgs) -> Result<()> { return Ok(()); } - if parent_array.is_empty() { + let parents = get_parent_requirements(sess, &rt, &io, dep)?; + + if parents.is_empty() { let _ = writeln!(std::io::stdout(), "No parents found for {}.", dep); } else { let _ = writeln!(std::io::stdout(), "Parents found:"); - let source = &parent_array.values().next().unwrap()[1]; - let mut constant_source = true; - for (_, v) in parent_array.iter() { - if &v[1] != source { - constant_source = false; - break; - } - } + let source = &parents.values().next().unwrap().source; + let constant_source = parents.values().all(|p| &p.source == source); let mut res = String::from(""); if constant_source { - for (k, v) in parent_array.iter() { - res.push_str(&format!(" {}\trequires: {}\n", k, v[0])); + for (k, p) in parents.iter() { + res.push_str(&format!(" {}\trequires: {}\n", k, p.constraint)); } } else { - for (k, v) in parent_array.iter() { - res.push_str(&format!(" {}\trequires: {}\tat {}\n", k, v[0], v[1])); + for (k, p) in parents.iter() { + res.push_str(&format!( + " {}\trequires: {}\tat {}\n", + k, p.constraint, p.source + )); } } let mut tw = TabWriter::new(vec![]); @@ -169,127 +167,101 @@ pub fn run(sess: &Session, args: &ParentsArgs) -> Result<()> { } /// Get parents array -pub fn get_parent_array( +/// What one parent requires of a dependency. +pub struct ParentRequirement { + /// The constraint the parent imposes. + pub constraint: DependencyConstraint, + /// Where the parent pulls the dependency from, formatted for display. + pub source: String, +} + +/// Map every parent that depends on `dep` through `f`, which receives the dependency entry the +/// parent declares and the directory that entry's paths are relative to. +fn map_parents( sess: &Session, rt: &Runtime, io: &SessionIo, dep: &str, - targets: bool, -) -> Result>> { - let mut map = IndexMap::>::new(); - if sess.manifest.dependencies.contains_key(dep) { - if targets { - map.insert( - sess.manifest.package.name.clone(), - match sess.manifest.dependencies.get(dep).unwrap() { - Dependency::Version { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::Path { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::GitRevision { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::GitVersion { - target: targetspec, - pass_targets: tgts, - .. - } => { - let mut tgts = tgts.iter().map(|t| t.to_string()).collect::>(); - tgts.insert(0, targetspec.to_string()); - tgts - } - }, - ); - } else { - let dep_str = format!( - "{}", - DependencyConstraint::from(&sess.manifest.dependencies[dep]) - ); - let source = DependencySource::from(&sess.manifest.dependencies[dep]); - let dep_source = format_dep_source(&source, sess.root); - map.insert( - sess.manifest.package.name.clone(), - vec![dep_str, dep_source], - ); - } + mut f: impl FnMut(&Dependency, &Path) -> T, +) -> Result> { + let mut map = IndexMap::new(); + if let Some(entry) = sess.manifest.dependencies.get(dep) { + map.insert(sess.manifest.package.name.clone(), f(entry, sess.root)); } for (&pkg, deps) in sess.graph().iter() { let pkg_name = sess.dependency_name(pkg); - let all_deps = deps.iter().map(|&id| sess.dependency(id)); - for current_dep in all_deps { - if dep == current_dep.name.as_str() { - let dep_manifest = rt.block_on(io.dependency_manifest(pkg, false, &[]))?; - // Filter out dependencies without a manifest - if dep_manifest.is_none() { - Warnings::IncludeDepManifestMismatch { - pkg: pkg_name.to_string(), - } - .emit(); - continue; + for current_dep in deps.iter().map(|&id| sess.dependency(id)) { + if dep != current_dep.name.as_str() { + continue; + } + let mismatch = || { + Warnings::IncludeDepManifestMismatch { + pkg: pkg_name.to_string(), } - let dep_manifest = dep_manifest.unwrap(); - if dep_manifest.dependencies.contains_key(dep) { - if targets { - map.insert( - pkg_name.to_string(), - match dep_manifest.dependencies.get(dep).unwrap() { - Dependency::Version { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::Path { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::GitRevision { - target: targetspec, - pass_targets: tgts, - .. - } - | Dependency::GitVersion { - target: targetspec, - pass_targets: tgts, - .. - } => { - let mut tgts = - tgts.iter().map(|t| t.to_string()).collect::>(); - tgts.insert(0, targetspec.to_string()); - tgts - } - }, - ); - } else { - let source = DependencySource::from(&dep_manifest.dependencies[dep]); - let pkg_path = sess.get_package_path(pkg); - map.insert( - pkg_name.to_string(), - vec![ - format!( - "{}", - DependencyConstraint::from(&dep_manifest.dependencies[dep]) - ), - format_dep_source(&source, &pkg_path), - ], - ); - } - } else { - Warnings::IncludeDepManifestMismatch { - pkg: pkg_name.to_string(), - } - .emit(); + .emit() + }; + // Filter out dependencies without a manifest. + let Some(dep_manifest) = rt.block_on(io.dependency_manifest(pkg, false, &[]))? else { + mismatch(); + continue; + }; + match dep_manifest.dependencies.get(dep) { + Some(entry) => { + let pkg_path = sess.get_package_path(pkg); + map.insert(pkg_name.to_string(), f(entry, &pkg_path)); } + None => mismatch(), } } } Ok(map) } + +/// The constraint every parent of `dep` imposes on it. +pub fn get_parent_requirements( + sess: &Session, + rt: &Runtime, + io: &SessionIo, + dep: &str, +) -> Result> { + map_parents(sess, rt, io, dep, |entry, base| ParentRequirement { + constraint: DependencyConstraint::from(entry), + source: format_dep_source(&DependencySource::from(entry), base), + }) +} + +/// The target filter each parent of `dep` applies, followed by the targets it passes down. +pub fn get_parent_targets( + sess: &Session, + rt: &Runtime, + io: &SessionIo, + dep: &str, +) -> Result>> { + map_parents(sess, rt, io, dep, |entry, _| { + let (targetspec, tgts) = match entry { + Dependency::Version { + target, + pass_targets, + .. + } + | Dependency::Path { + target, + pass_targets, + .. + } + | Dependency::GitRevision { + target, + pass_targets, + .. + } + | Dependency::GitVersion { + target, + pass_targets, + .. + } => (target, pass_targets), + }; + let mut tgts = tgts.iter().map(|t| t.to_string()).collect::>(); + tgts.insert(0, targetspec.to_string()); + tgts + }) +} diff --git a/tests/version_namespacing.rs b/tests/version_namespacing.rs index 14422919..2ee89536 100644 --- a/tests/version_namespacing.rs +++ b/tests/version_namespacing.rs @@ -263,6 +263,48 @@ fn embedded_and_field_prefix_conflict_fails() { ); } +#[test] +fn audit_aligns_suggestions_to_namespace() { + let base = fresh_dir("audit_ns"); + let foo_url = setup_foo(&base); + // Pin foo to `companyX-v1.0.0`. That namespace also has `companyX-v2.0.0`, + // while the default `v` namespace's highest is `v1.1.0`. + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"companyX-v1.0.0\" }}\n" + ), + ); + + let out = bender_update(&app); + assert!( + out.status.success(), + "update failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let out = bender(&app, &["audit"]); + assert!( + out.status.success(), + "audit failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // The bump target is the highest version in the checked-out namespace... + assert!(stdout.contains("2.0.0"), "audit:\n{stdout}"); + // ...not the highest version of the unrelated default `v` namespace. + assert!( + !stdout.contains("1.1.0"), + "audit leaked the default namespace:\n{stdout}" + ); + // Bare version numbers alone would not say which namespace they belong to. + assert!( + stdout.contains("(namespace `companyX-v`)"), + "audit must name the namespace it is reporting on:\n{stdout}" + ); +} + /// A `bar` repo depending on `foo` under the default `v` namespace, tagged `v0.1.0`. fn setup_bar(base: &Path, foo_url: &str) -> String { let repo = base.join("bar"); @@ -418,3 +460,32 @@ fn version_prefix_on_path_dependency_is_rejected() { "{stderr}" ); } + +/// A default-namespace dependency reports exactly as it always has: the annotation would be +/// noise on the `v` namespace, and `audit` output is covered by the golden CLI regression suite. +#[test] +fn audit_leaves_default_namespace_unannotated() { + let base = fresh_dir("audit_default_ns"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + ), + ); + assert!(bender_update(&app).status.success()); + + let out = bender(&app, &["audit"]); + assert!( + out.status.success(), + "audit failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("1.1.0"), "audit:\n{stdout}"); + assert!( + !stdout.contains("namespace"), + "the default namespace must not be annotated:\n{stdout}" + ); +} From a278d76f05333d291ba8df8c61f234bbfbfe40c6 Mon Sep 17 00:00:00 2001 From: Michael Rogenmoser Date: Fri, 11 Sep 2026 12:07:46 +0200 Subject: [PATCH 12/12] Report newer default-namespace releases in audit A dependency pinned to a custom namespace never sees releases in the default `v` one, by design, so `bender audit` calls a fork that has fallen behind upstream `Up-to-date` -- true within its namespace, and quietly misleading. `bender audit --check-upstream` reports the highest default-namespace release when it carries a higher version number than the pinned one. It is opt-in, reported as context below the package's own status, and deliberately does not feed the bump suggestion: bender will not cross namespaces to update, so proposing such a version would be unreachable. The comparison is numeric, so it assumes the fork tracks upstream's version numbering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYVvHSuKCtahd9vaqoMRFz --- CHANGELOG.md | 2 +- book/src/dependencies.md | 1 + src/cmd/audit.rs | 33 +++++++++++++++ tests/version_namespacing.rs | 80 ++++++++++++++++++++++++++++++++---- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54c5f91..74374699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## 0.32.1 - 2026-07-07 ### Added - Add version namespaces to Git version dependencies, allowing versioned forks to be tagged and resolved under their own prefix (e.g. `companyX-v1.2.0`). The prefix may be given via the `version_prefix` field or embedded in the version string (`version: "companyX-v1.2.0"`). Defaults to `v` for full backwards compatibility, and `version_prefix: ""` selects unprefixed tags; namespaces are strict and never mix -- a dependency required under two namespaces is reported like any other conflicting requirement, offering the usual interactive choice on a terminal -- and the resolved prefix is recorded in `Bender.lock`. The field is only meaningful on git version dependencies; elsewhere it is rejected rather than dropped silently. -- `bender audit` now aligns its version-bump suggestions to the namespace a dependency is currently resolved under, falling back to the default `v` namespace when the current checkout is not a version, and names that namespace in its output when it is not the default. +- `bender audit` now aligns its version-bump suggestions to the namespace a dependency is currently resolved under, falling back to the default `v` namespace when the current checkout is not a version, and names that namespace in its output when it is not the default. `bender audit --check-upstream` additionally reports the highest release in the default `v` namespace when a dependency pinned to a custom namespace has fallen behind it numerically. - Add `git_submodules` config field and `--git-submodules ` flag (env `BENDER_GIT_SUBMODULES`) to control cloning of dependency submodules; defaults to `true`, the flag overrides the configured value in either direction (https://github.com/pulp-platform/bender/pull/314). ### Fixed diff --git a/book/src/dependencies.md b/book/src/dependencies.md index d24478bf..ed380bdd 100644 --- a/book/src/dependencies.md +++ b/book/src/dependencies.md @@ -54,6 +54,7 @@ Prefixes only apply to Git version dependencies. On a path or revision dependenc Namespaces are **strict and never mix**: - A dependency resolves *only* tags carrying its own prefix. There is no fallback to the default `v` namespace (or any other). +- A dependency pinned to a custom namespace never sees releases in the default one. `bender audit --check-upstream` reports the highest default-namespace release when it carries a higher version number, so a fork that has fallen behind upstream stays visible. - If the same dependency is required with two different prefixes anywhere in the dependency tree, Bender never guesses. It reports the clash like any other conflicting requirement: on a terminal it asks you to pick one of the requirements, and without one (in CI, say) it fails. To settle it permanently, add an [`overrides`](./configuration.md) entry pinning the dependency to a single namespace — note that overrides live in `.bender.yml`, not in `Bender.yml`: ```yaml diff --git a/src/cmd/audit.rs b/src/cmd/audit.rs index ea0f94dc..a85e2757 100644 --- a/src/cmd/audit.rs +++ b/src/cmd/audit.rs @@ -30,6 +30,11 @@ pub struct AuditArgs { /// Ignore URL conflicts when auditing. #[arg(long)] pub ignore_url_conflict: bool, + + /// For dependencies pinned to a custom version namespace, also report the highest release in + /// the default `v` namespace when it carries a higher version number. + #[arg(long)] + pub check_upstream: bool, } /// Execute the `audit` subcommand. @@ -114,6 +119,25 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { }; let highest_version = available_versions.iter().max(); + // `--check-upstream`: a dependency pinned to a fork's namespace never sees releases in + // the default one, by design. Surface the highest of those so a fork that has fallen + // behind is visible, without letting it influence the suggestion itself. + let upstream_version = + if args.check_upstream && current_prefix != crate::config::DEFAULT_VERSION_PREFIX { + match dep_versions.get(pkg).unwrap() { + DependencyVersions::Git(versions) => versions + .versions + .iter() + .filter(|tv| tv.prefix == crate::config::DEFAULT_VERSION_PREFIX) + .map(|tv| &tv.version) + .max() + .filter(|upstream| Some(*upstream) > current_version.as_ref()), + _ => None, + } + } else { + None + }; + let mut conflicting = false; let mut version_req_exists = false; let mut compatible_versions = available_versions.clone(); @@ -208,6 +232,15 @@ pub fn run(sess: &Session, args: &AuditArgs) -> Result<()> { current_version_unwrapped, highest_version, namespace_note )); } + + // Reported after the package's own status, since it is context rather than a suggestion: + // the two namespaces are separate release lines and bender will not cross between them. + if let Some(upstream_version) = upstream_version { + audit_str.push_str(&format!( + "\t has \x1B[36;1mUpstream\x1B[m:\t{} in the default `v` namespace\n", + upstream_version + )); + } } let mut tw = TabWriter::new(vec![]); diff --git a/tests/version_namespacing.rs b/tests/version_namespacing.rs index 2ee89536..81976b7b 100644 --- a/tests/version_namespacing.rs +++ b/tests/version_namespacing.rs @@ -305,6 +305,35 @@ fn audit_aligns_suggestions_to_namespace() { ); } +/// A default-namespace dependency reports exactly as it always has: the annotation would be +/// noise on the `v` namespace, and `audit` output is covered by the golden CLI regression suite. +#[test] +fn audit_leaves_default_namespace_unannotated() { + let base = fresh_dir("audit_default_ns"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + ), + ); + assert!(bender_update(&app).status.success()); + + let out = bender(&app, &["audit"]); + assert!( + out.status.success(), + "audit failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("1.1.0"), "audit:\n{stdout}"); + assert!( + !stdout.contains("namespace"), + "the default namespace must not be annotated:\n{stdout}" + ); +} + /// A `bar` repo depending on `foo` under the default `v` namespace, tagged `v0.1.0`. fn setup_bar(base: &Path, foo_url: &str) -> String { let repo = base.join("bar"); @@ -461,31 +490,64 @@ fn version_prefix_on_path_dependency_is_rejected() { ); } -/// A default-namespace dependency reports exactly as it always has: the annotation would be -/// noise on the `v` namespace, and `audit` output is covered by the golden CLI regression suite. +/// `--check-upstream` surfaces a release in the default `v` namespace that carries a higher +/// version than the pinned fork, which strict namespacing otherwise hides entirely. +/// +/// `foo` is pinned to `companyX-v1.0.0`; the default namespace has `v1.1.0`. #[test] -fn audit_leaves_default_namespace_unannotated() { - let base = fresh_dir("audit_default_ns"); +fn audit_check_upstream_reports_newer_default_release() { + let base = fresh_dir("audit_upstream"); let foo_url = setup_foo(&base); let app = setup_project( &base, "app", &format!( - "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"=1.0.0\", version_prefix: \"companyX-v\" }}\n" ), ); assert!(bender_update(&app).status.success()); - let out = bender(&app, &["audit"]); + // Off by default: the extra line must not appear unasked. + let plain = String::from_utf8_lossy(&bender(&app, &["audit"]).stdout).into_owned(); + assert!( + !plain.contains("Upstream"), + "upstream line must be opt-in:\n{plain}" + ); + + let out = bender(&app, &["audit", "--check-upstream"]); assert!( out.status.success(), "audit failed:\n{}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("1.1.0"), "audit:\n{stdout}"); + assert!(stdout.contains("Upstream"), "audit:\n{stdout}"); assert!( - !stdout.contains("namespace"), - "the default namespace must not be annotated:\n{stdout}" + stdout.contains("1.1.0 in the default `v` namespace"), + "audit must name the upstream version and namespace:\n{stdout}" + ); +} + +/// A dependency already in the default namespace has no separate upstream to compare against, so +/// the flag is a no-op for it. +#[test] +fn audit_check_upstream_noop_in_default_namespace() { + let base = fresh_dir("audit_upstream_default"); + let foo_url = setup_foo(&base); + let app = setup_project( + &base, + "app", + &format!( + "package:\n name: app\ndependencies:\n foo: {{ git: \"{foo_url}\", version: \"1.0.0\" }}\n" + ), + ); + assert!(bender_update(&app).status.success()); + + let out = bender(&app, &["audit", "--check-upstream"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains("Upstream"), + "nothing to report for a default-namespace dependency:\n{stdout}" ); }