use crate::{DirectDep, LockfileGraph, pnpm};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone)]
pub struct MergeReport {
pub merged_files: Vec<PathBuf>,
pub conflicts: Vec<String>,
}
pub fn merge_branch_lockfiles(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<MergeReport, crate::Error> {
let mut report = MergeReport::default();
let branch_paths = discover_branch_lockfiles(project_dir);
if branch_paths.is_empty() {
return Ok(report);
}
let base_path = project_dir.join(aube_util::embedder().lockfile_basename);
let mut merged = if base_path.exists() {
pnpm::parse(&base_path)?
} else {
LockfileGraph::default()
};
let mut parsed: Vec<(PathBuf, LockfileGraph)> = Vec::with_capacity(branch_paths.len());
for path in &branch_paths {
let graph = pnpm::parse(path)?;
parsed.push((path.clone(), graph));
}
for (path, graph) in parsed {
merge_into(&mut merged, graph, &mut report);
report.merged_files.push(path);
}
pnpm::write(&base_path, &merged, manifest)?;
for path in &report.merged_files {
if let Err(err) = std::fs::remove_file(path) {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_LOCKFILE_MERGE_CLEANUP_FAILED,
"failed to remove merged branch lockfile {}: {err}",
path.display()
);
}
}
Ok(report)
}
pub fn current_branch_matches(project_dir: &Path, patterns: &[String]) -> bool {
if patterns.is_empty() {
return false;
}
let Some(branch) = crate::current_git_branch(project_dir) else {
return false;
};
branch_matches_patterns(&branch, patterns)
}
fn branch_matches_patterns(branch: &str, patterns: &[String]) -> bool {
let mut any_positive = false;
let mut any_positive_match = false;
for raw in patterns {
if let Some(neg) = raw.strip_prefix('!') {
if let Ok(pat) = glob::Pattern::new(neg)
&& pat.matches(branch)
{
return false;
}
} else {
any_positive = true;
if let Ok(pat) = glob::Pattern::new(raw)
&& pat.matches(branch)
{
any_positive_match = true;
}
}
}
any_positive && any_positive_match
}
fn discover_branch_lockfiles(project_dir: &Path) -> Vec<PathBuf> {
let Some(dir_str) = project_dir.to_str() else {
return Vec::new();
};
let basename = aube_util::embedder().lockfile_basename;
let (stem, ext) = basename.rsplit_once('.').unwrap_or((basename, "yaml"));
let pattern = format!("{dir_str}/{stem}.*.{ext}");
let mut out: Vec<PathBuf> = glob::glob(&pattern)
.ok()
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.filter(|p| {
p.file_name().and_then(|n| n.to_str()) != Some(basename)
})
.collect();
out.sort();
out
}
fn merge_into(dst: &mut LockfileGraph, src: LockfileGraph, report: &mut MergeReport) {
for (dep_path, incoming) in src.packages {
match dst.packages.remove(&dep_path) {
Some(existing) => {
let version_diff = existing.version != incoming.version;
let integrity_diff = existing.integrity != incoming.integrity;
if version_diff || integrity_diff {
let keep_existing = prefer_higher_version(&existing.version, &incoming.version);
let chosen = if keep_existing { existing } else { incoming };
let reason = if !version_diff && integrity_diff {
format!(
"INTEGRITY MISMATCH on same version {} (one branch may have \
a tampered or re-published tarball, investigate before \
trusting the merged lockfile)",
chosen.version
)
} else if version_diff && integrity_diff {
format!(
"version and integrity both differ, kept version {}",
chosen.version
)
} else {
format!("version differs, kept {}", chosen.version)
};
report.conflicts.push(format!("{dep_path}: {reason}"));
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_LOCKFILE_MERGE_CONFLICT,
"merge conflict on {dep_path}: {reason}"
);
dst.packages.insert(dep_path, chosen);
} else {
dst.packages.insert(dep_path, existing);
}
}
None => {
dst.packages.insert(dep_path, incoming);
}
}
}
for (importer_key, incoming_deps) in src.importers {
let entry = dst.importers.entry(importer_key.clone()).or_default();
merge_direct_deps(entry, incoming_deps, &importer_key, report);
}
for (k, v) in src.overrides {
use std::collections::btree_map::Entry;
match dst.overrides.entry(k) {
Entry::Vacant(slot) => {
slot.insert(v);
}
Entry::Occupied(slot) => {
if slot.get() != &v {
report.conflicts.push(format!(
"override `{}`: kept {} over {}",
slot.key(),
slot.get(),
v
));
}
}
}
}
for name in src.ignored_optional_dependencies {
dst.ignored_optional_dependencies.insert(name);
}
for (key, incoming) in src.patched_dependencies {
use std::collections::btree_map::Entry;
match dst.patched_dependencies.entry(key) {
Entry::Vacant(slot) => {
slot.insert(incoming);
}
Entry::Occupied(slot) => {
if slot.get() != &incoming {
report.conflicts.push(format!(
"patched dependency `{}`: kept {} over {}",
slot.key(),
slot.get(),
incoming
));
}
}
}
}
for (name, incoming) in src.runtimes {
use std::collections::btree_map::Entry;
match dst.runtimes.entry(name) {
Entry::Vacant(slot) => {
slot.insert(incoming);
}
Entry::Occupied(mut slot) => {
if slot.get().version != incoming.version {
report.conflicts.push(format!(
"runtime `{}`: kept {} over {}",
slot.key(),
slot.get().version,
incoming.version
));
continue;
}
if slot.get().specifier != incoming.specifier {
report.conflicts.push(format!(
"runtime `{}` specifier: kept {} over {}",
slot.key(),
slot.get().specifier,
incoming.specifier
));
}
let dst_pin = slot.get_mut();
for variant in incoming.variants {
let fully_covered = variant.targets.iter().all(|t| {
dst_pin
.variants
.iter()
.any(|dv| dv.targets.iter().any(|dt| dt == t))
});
if !fully_covered {
dst_pin.variants.push(variant);
}
}
}
}
}
let mut seen: aube_util::collections::FxSet<String> =
dst.trusted_dependencies.iter().cloned().collect();
for name in src.trusted_dependencies {
if seen.insert(name.clone()) {
dst.trusted_dependencies.push(name);
}
}
for (importer_key, entries) in src.skipped_optional_dependencies {
let merged = dst
.skipped_optional_dependencies
.entry(importer_key)
.or_default();
for (name, spec) in entries {
merged.entry(name).or_insert(spec);
}
}
for (key, incoming_time) in src.times {
dst.times
.entry(key)
.and_modify(|existing| {
if incoming_time > *existing {
*existing = incoming_time.clone();
}
})
.or_insert(incoming_time);
}
for (cat_name, entries) in src.catalogs {
use std::collections::btree_map::Entry;
let cat_label = cat_name.clone();
let merged = dst.catalogs.entry(cat_name).or_default();
for (name, entry) in entries {
match merged.entry(name) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(slot) => {
if slot.get().specifier != entry.specifier {
report.conflicts.push(format!(
"catalog `{}` entry `{}`: kept {} over {}",
cat_label,
slot.key(),
slot.get().specifier,
entry.specifier
));
}
}
}
}
}
}
fn merge_direct_deps(
dst: &mut Vec<DirectDep>,
incoming: Vec<DirectDep>,
importer_key: &str,
report: &mut MergeReport,
) {
let mut by_name: BTreeMap<String, DirectDep> =
dst.drain(..).map(|d| (d.name.clone(), d)).collect();
for dep in incoming {
match by_name.remove(&dep.name) {
Some(existing) => {
if existing.specifier != dep.specifier {
let importer_label = if importer_key.is_empty() {
"<root>".to_string()
} else {
importer_key.to_string()
};
let a = existing.specifier.as_deref().unwrap_or("<none>");
let b = dep.specifier.as_deref().unwrap_or("<none>");
report.conflicts.push(format!(
"importer `{importer_label}` dep `{}`: branches disagreed on \
specifier ({a} vs {b}), kept the one resolving to higher version",
dep.name
));
}
let keep_existing = prefer_higher_version(
existing_version_from_dep_path(&existing),
existing_version_from_dep_path(&dep),
);
by_name.insert(dep.name.clone(), if keep_existing { existing } else { dep });
}
None => {
by_name.insert(dep.name.clone(), dep);
}
}
}
dst.extend(by_name.into_values());
}
fn existing_version_from_dep_path(dep: &DirectDep) -> &str {
let after_at = match dep
.dep_path
.strip_prefix(&format!("{}@", dep.name))
.or_else(|| dep.dep_path.rsplit_once('@').map(|(_, v)| v))
{
Some(rest) => rest,
None => return &dep.dep_path,
};
let without_peer = after_at.split_once('(').map(|(v, _)| v).unwrap_or(after_at);
without_peer
.split_once('_')
.map(|(v, _)| v)
.unwrap_or(without_peer)
}
fn prefer_higher_version(a: &str, b: &str) -> bool {
match (
node_semver::Version::parse(a),
node_semver::Version::parse(b),
) {
(Ok(va), Ok(vb)) => va >= vb,
_ => a >= b,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LockedPackage;
#[test]
fn branch_matches_patterns_basic() {
let patterns = vec!["main".to_string(), "release/*".to_string()];
assert!(branch_matches_patterns("main", &patterns));
assert!(branch_matches_patterns("release/v1", &patterns));
assert!(!branch_matches_patterns("feature/x", &patterns));
}
#[test]
fn branch_matches_patterns_negation_wins() {
let patterns = vec![
"main".to_string(),
"release/*".to_string(),
"!release/legacy-*".to_string(),
];
assert!(branch_matches_patterns("release/v1", &patterns));
assert!(!branch_matches_patterns("release/legacy-v0", &patterns));
assert!(branch_matches_patterns("main", &patterns));
}
#[test]
fn branch_matches_patterns_only_negations_is_false() {
let patterns = vec!["!feature/*".to_string()];
assert!(!branch_matches_patterns("main", &patterns));
assert!(!branch_matches_patterns("feature/x", &patterns));
}
#[test]
fn branch_matches_patterns_empty_is_false() {
assert!(!branch_matches_patterns("main", &[]));
}
#[test]
fn existing_version_from_dep_path_handles_forms() {
let plain = DirectDep {
name: "react".into(),
dep_path: "react@18.2.0".into(),
dep_type: crate::DepType::Production,
specifier: None,
};
assert_eq!(existing_version_from_dep_path(&plain), "18.2.0");
let nested = DirectDep {
name: "react-dom".into(),
dep_path: "react-dom@18.2.0(react@18.2.0)".into(),
dep_type: crate::DepType::Production,
specifier: None,
};
assert_eq!(existing_version_from_dep_path(&nested), "18.2.0");
let hashed = DirectDep {
name: "huge".into(),
dep_path: "huge@1.0.0_abcdef0123".into(),
dep_type: crate::DepType::Production,
specifier: None,
};
assert_eq!(existing_version_from_dep_path(&hashed), "1.0.0");
}
#[test]
fn merge_into_unions_disjoint_packages() {
let mut dst = LockfileGraph::default();
dst.packages.insert(
"a@1.0.0".into(),
LockedPackage {
name: "a".into(),
version: "1.0.0".into(),
..Default::default()
},
);
let mut src = LockfileGraph::default();
src.packages.insert(
"b@2.0.0".into(),
LockedPackage {
name: "b".into(),
version: "2.0.0".into(),
..Default::default()
},
);
let mut report = MergeReport::default();
merge_into(&mut dst, src, &mut report);
assert!(dst.packages.contains_key("a@1.0.0"));
assert!(dst.packages.contains_key("b@2.0.0"));
assert!(report.conflicts.is_empty());
}
#[test]
fn merge_into_picks_higher_version_on_conflict() {
let mut dst = LockfileGraph::default();
dst.packages.insert(
"pkg@1.0.0".into(),
LockedPackage {
name: "pkg".into(),
version: "1.0.0".into(),
integrity: Some("sha512-aaa".into()),
..Default::default()
},
);
let mut src = LockfileGraph::default();
src.packages.insert(
"pkg@1.0.0".into(),
LockedPackage {
name: "pkg".into(),
version: "2.0.0".into(),
integrity: Some("sha512-bbb".into()),
..Default::default()
},
);
let mut report = MergeReport::default();
merge_into(&mut dst, src, &mut report);
assert_eq!(dst.packages["pkg@1.0.0"].version, "2.0.0");
assert_eq!(report.conflicts.len(), 1);
assert!(report.conflicts[0].contains("2.0.0"));
}
#[test]
fn prefer_higher_version_semver_order() {
assert!(prefer_higher_version("2.0.0", "1.0.0"));
assert!(!prefer_higher_version("1.0.0", "2.0.0"));
assert!(prefer_higher_version("workspace:z", "workspace:a"));
}
#[test]
fn merge_into_preserves_patched_dependencies() {
let mut dst = LockfileGraph::default();
let mut src = LockfileGraph::default();
src.patched_dependencies.insert(
"lodash@4.17.21".into(),
"patches/lodash@4.17.21.patch".into(),
);
let mut report = MergeReport::default();
merge_into(&mut dst, src, &mut report);
assert!(
dst.patched_dependencies.contains_key("lodash@4.17.21"),
"patched_dependencies entry was dropped on merge: {:?}",
dst.patched_dependencies
);
}
#[test]
fn merge_into_preserves_trusted_dependencies() {
let mut dst = LockfileGraph::default();
let mut src = LockfileGraph::default();
src.trusted_dependencies.push("esbuild".into());
let mut report = MergeReport::default();
merge_into(&mut dst, src, &mut report);
assert!(
dst.trusted_dependencies.iter().any(|n| n == "esbuild"),
"trusted_dependencies was dropped on merge: {:?}",
dst.trusted_dependencies
);
}
}