1use crate::{DirectDep, LockfileGraph, pnpm};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Default, Clone)]
25pub struct MergeReport {
26 pub merged_files: Vec<PathBuf>,
28 pub conflicts: Vec<String>,
33}
34
35pub fn merge_branch_lockfiles(
43 project_dir: &Path,
44 manifest: &aube_manifest::PackageJson,
45) -> Result<MergeReport, crate::Error> {
46 let mut report = MergeReport::default();
47
48 let branch_paths = discover_branch_lockfiles(project_dir);
49 if branch_paths.is_empty() {
50 return Ok(report);
51 }
52
53 let base_path = project_dir.join(aube_util::embedder().lockfile_basename);
54 let mut merged = if base_path.exists() {
55 pnpm::parse(&base_path)?
56 } else {
57 LockfileGraph::default()
58 };
59
60 let mut parsed: Vec<(PathBuf, LockfileGraph)> = Vec::with_capacity(branch_paths.len());
64 for path in &branch_paths {
65 let graph = pnpm::parse(path)?;
66 parsed.push((path.clone(), graph));
67 }
68
69 for (path, graph) in parsed {
70 merge_into(&mut merged, graph, &mut report);
71 report.merged_files.push(path);
72 }
73
74 pnpm::write(&base_path, &merged, manifest)?;
77
78 for path in &report.merged_files {
79 if let Err(err) = std::fs::remove_file(path) {
80 tracing::warn!(
83 code = aube_codes::warnings::WARN_AUBE_LOCKFILE_MERGE_CLEANUP_FAILED,
84 "failed to remove merged branch lockfile {}: {err}",
85 path.display()
86 );
87 }
88 }
89
90 Ok(report)
91}
92
93pub fn current_branch_matches(project_dir: &Path, patterns: &[String]) -> bool {
99 if patterns.is_empty() {
100 return false;
101 }
102 let Some(branch) = crate::current_git_branch(project_dir) else {
103 return false;
104 };
105 branch_matches_patterns(&branch, patterns)
106}
107
108fn branch_matches_patterns(branch: &str, patterns: &[String]) -> bool {
111 let mut any_positive = false;
112 let mut any_positive_match = false;
113 for raw in patterns {
114 if let Some(neg) = raw.strip_prefix('!') {
115 if let Ok(pat) = glob::Pattern::new(neg)
116 && pat.matches(branch)
117 {
118 return false;
120 }
121 } else {
122 any_positive = true;
123 if let Ok(pat) = glob::Pattern::new(raw)
124 && pat.matches(branch)
125 {
126 any_positive_match = true;
127 }
128 }
129 }
130 any_positive && any_positive_match
134}
135
136fn discover_branch_lockfiles(project_dir: &Path) -> Vec<PathBuf> {
137 let Some(dir_str) = project_dir.to_str() else {
141 return Vec::new();
142 };
143 let basename = aube_util::embedder().lockfile_basename;
144 let (stem, ext) = basename.rsplit_once('.').unwrap_or((basename, "yaml"));
145 let pattern = format!("{dir_str}/{stem}.*.{ext}");
146 let mut out: Vec<PathBuf> = glob::glob(&pattern)
147 .ok()
148 .into_iter()
149 .flatten()
150 .filter_map(|entry| entry.ok())
151 .filter(|p| {
152 p.file_name().and_then(|n| n.to_str()) != Some(basename)
155 })
156 .collect();
157 out.sort();
158 out
159}
160
161fn merge_into(dst: &mut LockfileGraph, src: LockfileGraph, report: &mut MergeReport) {
164 for (dep_path, incoming) in src.packages {
167 match dst.packages.remove(&dep_path) {
168 Some(existing) => {
169 let version_diff = existing.version != incoming.version;
170 let integrity_diff = existing.integrity != incoming.integrity;
171 if version_diff || integrity_diff {
172 let keep_existing = prefer_higher_version(&existing.version, &incoming.version);
180 let chosen = if keep_existing { existing } else { incoming };
181 let reason = if !version_diff && integrity_diff {
182 format!(
183 "INTEGRITY MISMATCH on same version {} (one branch may have \
184 a tampered or re-published tarball, investigate before \
185 trusting the merged lockfile)",
186 chosen.version
187 )
188 } else if version_diff && integrity_diff {
189 format!(
190 "version and integrity both differ, kept version {}",
191 chosen.version
192 )
193 } else {
194 format!("version differs, kept {}", chosen.version)
195 };
196 report.conflicts.push(format!("{dep_path}: {reason}"));
197 tracing::warn!(
198 code = aube_codes::warnings::WARN_AUBE_LOCKFILE_MERGE_CONFLICT,
199 "merge conflict on {dep_path}: {reason}"
200 );
201 dst.packages.insert(dep_path, chosen);
202 } else {
203 dst.packages.insert(dep_path, existing);
205 }
206 }
207 None => {
208 dst.packages.insert(dep_path, incoming);
209 }
210 }
211 }
212
213 for (importer_key, incoming_deps) in src.importers {
217 let entry = dst.importers.entry(importer_key.clone()).or_default();
218 merge_direct_deps(entry, incoming_deps, &importer_key, report);
219 }
220
221 for (k, v) in src.overrides {
226 use std::collections::btree_map::Entry;
232 match dst.overrides.entry(k) {
233 Entry::Vacant(slot) => {
234 slot.insert(v);
235 }
236 Entry::Occupied(slot) => {
237 if slot.get() != &v {
238 report.conflicts.push(format!(
239 "override `{}`: kept {} over {}",
240 slot.key(),
241 slot.get(),
242 v
243 ));
244 }
245 }
246 }
247 }
248 for name in src.ignored_optional_dependencies {
249 dst.ignored_optional_dependencies.insert(name);
250 }
251 for (key, incoming) in src.patched_dependencies {
252 use std::collections::btree_map::Entry;
253 match dst.patched_dependencies.entry(key) {
254 Entry::Vacant(slot) => {
255 slot.insert(incoming);
256 }
257 Entry::Occupied(slot) => {
258 if slot.get() != &incoming {
259 report.conflicts.push(format!(
260 "patched dependency `{}`: kept {} over {}",
261 slot.key(),
262 slot.get(),
263 incoming
264 ));
265 }
266 }
267 }
268 }
269 for (name, incoming) in src.runtimes {
270 use std::collections::btree_map::Entry;
271 match dst.runtimes.entry(name) {
272 Entry::Vacant(slot) => {
273 slot.insert(incoming);
274 }
275 Entry::Occupied(mut slot) => {
276 if slot.get().version != incoming.version {
277 report.conflicts.push(format!(
278 "runtime `{}`: kept {} over {}",
279 slot.key(),
280 slot.get().version,
281 incoming.version
282 ));
283 continue;
284 }
285 if slot.get().specifier != incoming.specifier {
291 report.conflicts.push(format!(
292 "runtime `{}` specifier: kept {} over {}",
293 slot.key(),
294 slot.get().specifier,
295 incoming.specifier
296 ));
297 }
298 let dst_pin = slot.get_mut();
299 for variant in incoming.variants {
300 let fully_covered = variant.targets.iter().all(|t| {
304 dst_pin
305 .variants
306 .iter()
307 .any(|dv| dv.targets.iter().any(|dt| dt == t))
308 });
309 if !fully_covered {
310 dst_pin.variants.push(variant);
311 }
312 }
313 }
314 }
315 }
316 let mut seen: aube_util::collections::FxSet<String> =
317 dst.trusted_dependencies.iter().cloned().collect();
318 for name in src.trusted_dependencies {
319 if seen.insert(name.clone()) {
320 dst.trusted_dependencies.push(name);
321 }
322 }
323 for (importer_key, entries) in src.skipped_optional_dependencies {
324 let merged = dst
325 .skipped_optional_dependencies
326 .entry(importer_key)
327 .or_default();
328 for (name, spec) in entries {
329 merged.entry(name).or_insert(spec);
330 }
331 }
332 for (key, incoming_time) in src.times {
333 dst.times
336 .entry(key)
337 .and_modify(|existing| {
338 if incoming_time > *existing {
339 *existing = incoming_time.clone();
340 }
341 })
342 .or_insert(incoming_time);
343 }
344 for (cat_name, entries) in src.catalogs {
345 use std::collections::btree_map::Entry;
352 let cat_label = cat_name.clone();
353 let merged = dst.catalogs.entry(cat_name).or_default();
354 for (name, entry) in entries {
355 match merged.entry(name) {
356 Entry::Vacant(slot) => {
357 slot.insert(entry);
358 }
359 Entry::Occupied(slot) => {
360 if slot.get().specifier != entry.specifier {
361 report.conflicts.push(format!(
362 "catalog `{}` entry `{}`: kept {} over {}",
363 cat_label,
364 slot.key(),
365 slot.get().specifier,
366 entry.specifier
367 ));
368 }
369 }
370 }
371 }
372 }
373}
374
375fn merge_direct_deps(
376 dst: &mut Vec<DirectDep>,
377 incoming: Vec<DirectDep>,
378 importer_key: &str,
379 report: &mut MergeReport,
380) {
381 let mut by_name: BTreeMap<String, DirectDep> =
382 dst.drain(..).map(|d| (d.name.clone(), d)).collect();
383 for dep in incoming {
384 match by_name.remove(&dep.name) {
385 Some(existing) => {
386 if existing.specifier != dep.specifier {
395 let importer_label = if importer_key.is_empty() {
396 "<root>".to_string()
397 } else {
398 importer_key.to_string()
399 };
400 let a = existing.specifier.as_deref().unwrap_or("<none>");
401 let b = dep.specifier.as_deref().unwrap_or("<none>");
402 report.conflicts.push(format!(
403 "importer `{importer_label}` dep `{}`: branches disagreed on \
404 specifier ({a} vs {b}), kept the one resolving to higher version",
405 dep.name
406 ));
407 }
408 let keep_existing = prefer_higher_version(
409 existing_version_from_dep_path(&existing),
410 existing_version_from_dep_path(&dep),
411 );
412 by_name.insert(dep.name.clone(), if keep_existing { existing } else { dep });
413 }
414 None => {
415 by_name.insert(dep.name.clone(), dep);
416 }
417 }
418 }
419 dst.extend(by_name.into_values());
420}
421
422fn existing_version_from_dep_path(dep: &DirectDep) -> &str {
426 let after_at = match dep
431 .dep_path
432 .strip_prefix(&format!("{}@", dep.name))
433 .or_else(|| dep.dep_path.rsplit_once('@').map(|(_, v)| v))
434 {
435 Some(rest) => rest,
436 None => return &dep.dep_path,
437 };
438 let without_peer = after_at.split_once('(').map(|(v, _)| v).unwrap_or(after_at);
441 without_peer
442 .split_once('_')
443 .map(|(v, _)| v)
444 .unwrap_or(without_peer)
445}
446
447fn prefer_higher_version(a: &str, b: &str) -> bool {
452 match (
453 node_semver::Version::parse(a),
454 node_semver::Version::parse(b),
455 ) {
456 (Ok(va), Ok(vb)) => va >= vb,
457 _ => a >= b,
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::LockedPackage;
465
466 #[test]
467 fn branch_matches_patterns_basic() {
468 let patterns = vec!["main".to_string(), "release/*".to_string()];
469 assert!(branch_matches_patterns("main", &patterns));
470 assert!(branch_matches_patterns("release/v1", &patterns));
471 assert!(!branch_matches_patterns("feature/x", &patterns));
472 }
473
474 #[test]
475 fn branch_matches_patterns_negation_wins() {
476 let patterns = vec![
477 "main".to_string(),
478 "release/*".to_string(),
479 "!release/legacy-*".to_string(),
480 ];
481 assert!(branch_matches_patterns("release/v1", &patterns));
482 assert!(!branch_matches_patterns("release/legacy-v0", &patterns));
483 assert!(branch_matches_patterns("main", &patterns));
484 }
485
486 #[test]
487 fn branch_matches_patterns_only_negations_is_false() {
488 let patterns = vec!["!feature/*".to_string()];
491 assert!(!branch_matches_patterns("main", &patterns));
492 assert!(!branch_matches_patterns("feature/x", &patterns));
493 }
494
495 #[test]
496 fn branch_matches_patterns_empty_is_false() {
497 assert!(!branch_matches_patterns("main", &[]));
498 }
499
500 #[test]
501 fn existing_version_from_dep_path_handles_forms() {
502 let plain = DirectDep {
503 name: "react".into(),
504 dep_path: "react@18.2.0".into(),
505 dep_type: crate::DepType::Production,
506 specifier: None,
507 };
508 assert_eq!(existing_version_from_dep_path(&plain), "18.2.0");
509
510 let nested = DirectDep {
511 name: "react-dom".into(),
512 dep_path: "react-dom@18.2.0(react@18.2.0)".into(),
513 dep_type: crate::DepType::Production,
514 specifier: None,
515 };
516 assert_eq!(existing_version_from_dep_path(&nested), "18.2.0");
517
518 let hashed = DirectDep {
519 name: "huge".into(),
520 dep_path: "huge@1.0.0_abcdef0123".into(),
521 dep_type: crate::DepType::Production,
522 specifier: None,
523 };
524 assert_eq!(existing_version_from_dep_path(&hashed), "1.0.0");
525 }
526
527 #[test]
528 fn merge_into_unions_disjoint_packages() {
529 let mut dst = LockfileGraph::default();
530 dst.packages.insert(
531 "a@1.0.0".into(),
532 LockedPackage {
533 name: "a".into(),
534 version: "1.0.0".into(),
535 ..Default::default()
536 },
537 );
538 let mut src = LockfileGraph::default();
539 src.packages.insert(
540 "b@2.0.0".into(),
541 LockedPackage {
542 name: "b".into(),
543 version: "2.0.0".into(),
544 ..Default::default()
545 },
546 );
547 let mut report = MergeReport::default();
548 merge_into(&mut dst, src, &mut report);
549 assert!(dst.packages.contains_key("a@1.0.0"));
550 assert!(dst.packages.contains_key("b@2.0.0"));
551 assert!(report.conflicts.is_empty());
552 }
553
554 #[test]
555 fn merge_into_picks_higher_version_on_conflict() {
556 let mut dst = LockfileGraph::default();
557 dst.packages.insert(
558 "pkg@1.0.0".into(),
559 LockedPackage {
560 name: "pkg".into(),
561 version: "1.0.0".into(),
562 integrity: Some("sha512-aaa".into()),
563 ..Default::default()
564 },
565 );
566 let mut src = LockfileGraph::default();
567 src.packages.insert(
569 "pkg@1.0.0".into(),
570 LockedPackage {
571 name: "pkg".into(),
572 version: "2.0.0".into(),
573 integrity: Some("sha512-bbb".into()),
574 ..Default::default()
575 },
576 );
577 let mut report = MergeReport::default();
578 merge_into(&mut dst, src, &mut report);
579 assert_eq!(dst.packages["pkg@1.0.0"].version, "2.0.0");
580 assert_eq!(report.conflicts.len(), 1);
581 assert!(report.conflicts[0].contains("2.0.0"));
582 }
583
584 #[test]
585 fn prefer_higher_version_semver_order() {
586 assert!(prefer_higher_version("2.0.0", "1.0.0"));
587 assert!(!prefer_higher_version("1.0.0", "2.0.0"));
588 assert!(prefer_higher_version("workspace:z", "workspace:a"));
590 }
591
592 #[test]
593 fn merge_into_preserves_patched_dependencies() {
594 let mut dst = LockfileGraph::default();
595 let mut src = LockfileGraph::default();
596 src.patched_dependencies.insert(
597 "lodash@4.17.21".into(),
598 "patches/lodash@4.17.21.patch".into(),
599 );
600 let mut report = MergeReport::default();
601 merge_into(&mut dst, src, &mut report);
602 assert!(
603 dst.patched_dependencies.contains_key("lodash@4.17.21"),
604 "patched_dependencies entry was dropped on merge: {:?}",
605 dst.patched_dependencies
606 );
607 }
608
609 #[test]
610 fn merge_into_preserves_trusted_dependencies() {
611 let mut dst = LockfileGraph::default();
612 let mut src = LockfileGraph::default();
613 src.trusted_dependencies.push("esbuild".into());
614 let mut report = MergeReport::default();
615 merge_into(&mut dst, src, &mut report);
616 assert!(
617 dst.trusted_dependencies.iter().any(|n| n == "esbuild"),
618 "trusted_dependencies was dropped on merge: {:?}",
619 dst.trusted_dependencies
620 );
621 }
622}