Skip to main content

aube_lockfile/
merge.rs

1//! Branch-lockfile merge.
2//!
3//! Implements pnpm's `merge-git-branch-lockfiles` workflow for aube.
4//! When `gitBranchLockfile: true` is set, each branch writes its
5//! lockfile to `aube-lock.<branch>.yaml`. When the user lands on a
6//! collapse branch (e.g. `main` or `release/*`, configured via
7//! `mergeGitBranchLockfilesBranchPattern`), or when they pass
8//! `--merge-git-branch-lockfiles`, aube globs the branch-specific
9//! files, unions their package graphs into `aube-lock.yaml`, and
10//! deletes the branch files.
11//!
12//! Conflict rule: when two branch files record the same `dep_path`
13//! with different `version`/`integrity`, the entry whose `version`
14//! parses as the higher semver wins and a warning is logged to
15//! `tracing`. Stable tie-breaking: equal semver keeps the base file
16//! value (or the first branch file in sorted-filename order).
17
18use crate::{DirectDep, LockfileGraph, pnpm};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22/// Summary of one merge pass, surfaced to callers so they can log it
23/// through their preferred UI (aube uses `progress::println`).
24#[derive(Debug, Default, Clone)]
25pub struct MergeReport {
26    /// Branch-lockfile paths that were parsed and merged, then deleted.
27    pub merged_files: Vec<PathBuf>,
28    /// `dep_path`s where two branch files recorded different
29    /// `integrity` or `version`. Populated with the message for each
30    /// conflict; the actual resolution is already applied to the
31    /// merged graph.
32    pub conflicts: Vec<String>,
33}
34
35/// Glob all `aube-lock.*.yaml` files in `project_dir` (excluding plain
36/// `aube-lock.yaml`), parse each, merge them into the base
37/// `aube-lock.yaml` (or an empty graph if no base exists), write the
38/// merged result, and delete each successfully-merged branch file.
39///
40/// Returns a [`MergeReport`] describing what happened. If no branch
41/// files are found, the report is empty and no files are written.
42pub 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    // Sorted-filename order gives deterministic output. Parse first,
61    // then delete — a parse failure on any file aborts the whole
62    // merge and leaves every file in place.
63    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    // Write out the combined graph as `aube-lock.yaml` (plain filename,
75    // not branch-scoped).
76    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            // Non-fatal: the merged graph is already written. Surface
81            // a warning so the user can clean up manually if needed.
82            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
93/// Return whether the current git branch (if any) matches the
94/// user-provided pattern list. A match occurs when *any* positive
95/// pattern matches AND *no* negative (`!`-prefixed) pattern matches.
96/// Returns `false` if we can't determine a branch (no git, detached
97/// HEAD, etc.) or the pattern list is empty.
98pub 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
108/// Pattern-matching logic split out so we can unit-test it without a
109/// real git repo.
110fn 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                // Explicit negation wins.
119                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    // "Only negations" (no positives) is treated as no match, matching
131    // pnpm's behavior — the setting is opt-in, so at least one
132    // positive pattern is required to enable merging.
133    any_positive && any_positive_match
134}
135
136fn discover_branch_lockfiles(project_dir: &Path) -> Vec<PathBuf> {
137    // `glob` needs a string pattern. Project dirs with non-UTF-8
138    // segments can't be matched; fall back to empty (aube doesn't
139    // support non-UTF-8 project roots elsewhere either).
140    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            // `<stem>.*.<ext>` also matches the plain canonical lockfile
153            // itself on some implementations; filter it out explicitly.
154            p.file_name().and_then(|n| n.to_str()) != Some(basename)
155        })
156        .collect();
157    out.sort();
158    out
159}
160
161/// Merge `src` into `dst`. Conflicts on `packages` are recorded into
162/// `report.conflicts` and resolved by keeping the higher-semver entry.
163fn merge_into(dst: &mut LockfileGraph, src: LockfileGraph, report: &mut MergeReport) {
164    // Packages: same `dep_path` with different `version` or `integrity`
165    // is a conflict; resolve by higher semver.
166    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                    // Integrity mismatch on same version is a real
173                    // supply chain signal. Means one branch fetched
174                    // a tarball with different bytes than the other.
175                    // Registry re-publish, mirror replay, or worse.
176                    // Flag it LOUDER than a plain version conflict
177                    // so the user actually investigates instead of
178                    // just accepting "higher semver wins" silently.
179                    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                    // Identical. Put existing one back.
204                    dst.packages.insert(dep_path, existing);
205                }
206            }
207            None => {
208                dst.packages.insert(dep_path, incoming);
209            }
210        }
211    }
212
213    // Importers: union by importer key. Same DirectDep name → keep the
214    // one whose `dep_path` sorts higher by semver; same DirectDep name
215    // with identical dep_path is a no-op.
216    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    // Overrides / ignored / skipped / times / catalogs: union where
222    // straightforward. Preserve base's `settings` header to keep
223    // round-trip stability (the primary lockfile is authoritative for
224    // header fields like `auto_install_peers`).
225    for (k, v) in src.overrides {
226        // Old code was or_insert which silently picked base on a
227        // collision. User intent divergence dropped without a
228        // peep. Now record the conflict when values differ, still
229        // pick base for determinism but tell the user the other
230        // branch wanted something else.
231        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                // Same resolved version: a silently-kept dst would
286                // drop the incoming side's metadata. Surface specifier
287                // disagreements and union the per-platform variants
288                // (dst wins on target collisions) so a pin written on
289                // another platform keeps its artifacts after a merge.
290                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                    // Push unless *every* target is already covered —
301                    // `.any()` here would drop a multi-target variant
302                    // whose targets are only partially covered.
303                    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        // Prefer the lexicographically-larger ISO-8601 timestamp —
334        // matches "latest wins" without parsing.
335        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        // Catalog merge used to be silent first-write-wins. Two
346        // branches bumping the same catalog pin (`react: ^18` vs
347        // `^19`) left base untouched with zero user feedback.
348        // Catalog drift is a root cause of "works on my branch,
349        // fails in CI" since the bumped version never reaches the
350        // merged lockfile. Record conflicts now.
351        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                // Record the conflict when the user's declared
387                // range differs between branches. Old code picked
388                // the entry with the higher resolved dep_path
389                // version silently, which overwrote the user's
390                // manifest intent. If branch-A had "^1" and
391                // branch-B had "^2", branch-B won but the user on
392                // branch-A never learned their pin got clobbered
393                // on merge.
394                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
422/// Extract the canonical `version` portion from a DirectDep's
423/// `dep_path`. Used purely for "higher wins" tie-breaking during
424/// direct-dep merging, so a best-effort parse is fine.
425fn existing_version_from_dep_path(dep: &DirectDep) -> &str {
426    // dep_path shape: `name@version` possibly followed by `(peer)...`
427    // or `_<hashed-suffix>`. We want just the `version` portion
428    // between the last `@` (there may be two for scoped packages) and
429    // any trailing peer/hash marker.
430    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    // Strip peer suffix first (everything from the first `(`), then
439    // the hashed marker if present.
440    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
447/// Return `true` if `a` should be preferred over `b` (i.e. keep the
448/// existing entry). Uses semver comparison; unparseable versions
449/// fall back to string comparison so behavior is deterministic and
450/// never panics.
451fn 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        // A list with only `!x` patterns means "never merge" — matches
489        // pnpm, which requires at least one positive pattern.
490        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        // Same dep_path key, different version + integrity.
568        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        // Fallback: string compare for non-semver tails.
589        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}