aube-lockfile 1.5.0

Multi-format lockfile reader/writer for Aube (aube-lock, pnpm-lock, package-lock, yarn.lock, bun.lock)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
//! Branch-lockfile merge.
//!
//! Implements pnpm's `merge-git-branch-lockfiles` workflow for aube.
//! When `gitBranchLockfile: true` is set, each branch writes its
//! lockfile to `aube-lock.<branch>.yaml`. When the user lands on a
//! collapse branch (e.g. `main` or `release/*`, configured via
//! `mergeGitBranchLockfilesBranchPattern`), or when they pass
//! `--merge-git-branch-lockfiles`, aube globs the branch-specific
//! files, unions their package graphs into `aube-lock.yaml`, and
//! deletes the branch files.
//!
//! Conflict rule: when two branch files record the same `dep_path`
//! with different `version`/`integrity`, the entry whose `version`
//! parses as the higher semver wins and a warning is logged to
//! `tracing`. Stable tie-breaking: equal semver keeps the base file
//! value (or the first branch file in sorted-filename order).

use crate::{DirectDep, LockfileGraph, pnpm};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// Summary of one merge pass, surfaced to callers so they can log it
/// through their preferred UI (aube uses `progress::println`).
#[derive(Debug, Default, Clone)]
pub struct MergeReport {
    /// Branch-lockfile paths that were parsed and merged, then deleted.
    pub merged_files: Vec<PathBuf>,
    /// `dep_path`s where two branch files recorded different
    /// `integrity` or `version`. Populated with the message for each
    /// conflict; the actual resolution is already applied to the
    /// merged graph.
    pub conflicts: Vec<String>,
}

/// Glob all `aube-lock.*.yaml` files in `project_dir` (excluding plain
/// `aube-lock.yaml`), parse each, merge them into the base
/// `aube-lock.yaml` (or an empty graph if no base exists), write the
/// merged result, and delete each successfully-merged branch file.
///
/// Returns a [`MergeReport`] describing what happened. If no branch
/// files are found, the report is empty and no files are written.
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-lock.yaml");
    let mut merged = if base_path.exists() {
        pnpm::parse(&base_path)?
    } else {
        LockfileGraph::default()
    };

    // Sorted-filename order gives deterministic output. Parse first,
    // then delete — a parse failure on any file aborts the whole
    // merge and leaves every file in place.
    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);
    }

    // Write out the combined graph as `aube-lock.yaml` (plain filename,
    // not branch-scoped).
    pnpm::write(&base_path, &merged, manifest)?;

    for path in &report.merged_files {
        if let Err(err) = std::fs::remove_file(path) {
            // Non-fatal: the merged graph is already written. Surface
            // a warning so the user can clean up manually if needed.
            tracing::warn!(
                "failed to remove merged branch lockfile {}: {err}",
                path.display()
            );
        }
    }

    Ok(report)
}

/// Return whether the current git branch (if any) matches the
/// user-provided pattern list. A match occurs when *any* positive
/// pattern matches AND *no* negative (`!`-prefixed) pattern matches.
/// Returns `false` if we can't determine a branch (no git, detached
/// HEAD, etc.) or the pattern list is empty.
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)
}

/// Pattern-matching logic split out so we can unit-test it without a
/// real git repo.
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)
            {
                // Explicit negation wins.
                return false;
            }
        } else {
            any_positive = true;
            if let Ok(pat) = glob::Pattern::new(raw)
                && pat.matches(branch)
            {
                any_positive_match = true;
            }
        }
    }
    // "Only negations" (no positives) is treated as no match, matching
    // pnpm's behavior — the setting is opt-in, so at least one
    // positive pattern is required to enable merging.
    any_positive && any_positive_match
}

fn discover_branch_lockfiles(project_dir: &Path) -> Vec<PathBuf> {
    // `glob` needs a string pattern. Project dirs with non-UTF-8
    // segments can't be matched; fall back to empty (aube doesn't
    // support non-UTF-8 project roots elsewhere either).
    let Some(dir_str) = project_dir.to_str() else {
        return Vec::new();
    };
    let pattern = format!("{dir_str}/aube-lock.*.yaml");
    let mut out: Vec<PathBuf> = glob::glob(&pattern)
        .ok()
        .into_iter()
        .flatten()
        .filter_map(|entry| entry.ok())
        .filter(|p| {
            // `aube-lock.*.yaml` also matches `aube-lock.yaml` itself
            // on some implementations; filter it out explicitly.
            p.file_name().and_then(|n| n.to_str()) != Some("aube-lock.yaml")
        })
        .collect();
    out.sort();
    out
}

/// Merge `src` into `dst`. Conflicts on `packages` are recorded into
/// `report.conflicts` and resolved by keeping the higher-semver entry.
fn merge_into(dst: &mut LockfileGraph, src: LockfileGraph, report: &mut MergeReport) {
    // Packages: same `dep_path` with different `version` or `integrity`
    // is a conflict; resolve by higher semver.
    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 {
                    // Integrity mismatch on same version is a real
                    // supply chain signal. Means one branch fetched
                    // a tarball with different bytes than the other.
                    // Registry re-publish, mirror replay, or worse.
                    // Flag it LOUDER than a plain version conflict
                    // so the user actually investigates instead of
                    // just accepting "higher semver wins" silently.
                    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!("merge conflict on {dep_path}: {reason}");
                    dst.packages.insert(dep_path, chosen);
                } else {
                    // Identical. Put existing one back.
                    dst.packages.insert(dep_path, existing);
                }
            }
            None => {
                dst.packages.insert(dep_path, incoming);
            }
        }
    }

    // Importers: union by importer key. Same DirectDep name → keep the
    // one whose `dep_path` sorts higher by semver; same DirectDep name
    // with identical dep_path is a no-op.
    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);
    }

    // Overrides / ignored / skipped / times / catalogs: union where
    // straightforward. Preserve base's `settings` header to keep
    // round-trip stability (the primary lockfile is authoritative for
    // header fields like `auto_install_peers`).
    for (k, v) in src.overrides {
        // Old code was or_insert which silently picked base on a
        // collision. User intent divergence dropped without a
        // peep. Now record the conflict when values differ, still
        // pick base for determinism but tell the user the other
        // branch wanted something else.
        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
                    ));
                }
            }
        }
    }
    let mut seen: rustc_hash::FxHashSet<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 {
        // Prefer the lexicographically-larger ISO-8601 timestamp —
        // matches "latest wins" without parsing.
        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 {
        // Catalog merge used to be silent first-write-wins. Two
        // branches bumping the same catalog pin (`react: ^18` vs
        // `^19`) left base untouched with zero user feedback.
        // Catalog drift is a root cause of "works on my branch,
        // fails in CI" since the bumped version never reaches the
        // merged lockfile. Record conflicts now.
        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) => {
                // Record the conflict when the user's declared
                // range differs between branches. Old code picked
                // the entry with the higher resolved dep_path
                // version silently, which overwrote the user's
                // manifest intent. If branch-A had "^1" and
                // branch-B had "^2", branch-B won but the user on
                // branch-A never learned their pin got clobbered
                // on merge.
                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());
}

/// Extract the canonical `version` portion from a DirectDep's
/// `dep_path`. Used purely for "higher wins" tie-breaking during
/// direct-dep merging, so a best-effort parse is fine.
fn existing_version_from_dep_path(dep: &DirectDep) -> &str {
    // dep_path shape: `name@version` possibly followed by `(peer)...`
    // or `_<hashed-suffix>`. We want just the `version` portion
    // between the last `@` (there may be two for scoped packages) and
    // any trailing peer/hash marker.
    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,
    };
    // Strip peer suffix first (everything from the first `(`), then
    // the hashed marker if present.
    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)
}

/// Return `true` if `a` should be preferred over `b` (i.e. keep the
/// existing entry). Uses semver comparison; unparseable versions
/// fall back to string comparison so behavior is deterministic and
/// never panics.
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() {
        // A list with only `!x` patterns means "never merge" — matches
        // pnpm, which requires at least one positive pattern.
        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();
        // Same dep_path key, different version + integrity.
        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"));
        // Fallback: string compare for non-semver tails.
        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
        );
    }
}