Skip to main content

aube_lockfile/
io.rs

1use crate::{LockedPackage, LockfileGraph, bun, npm, pnpm, yarn};
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5/// Which source lockfile format was parsed.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LockfileKind {
8    /// `aube-lock.yaml` — aube's default lockfile when no existing
9    /// lockfile is present. Same on-disk format as pnpm v9 for now
10    /// (we piggyback on pnpm::read/write).
11    Aube,
12    /// `pnpm-lock.yaml` — pnpm v9 format. If this is the existing
13    /// project lockfile, aube reads and writes it in place.
14    Pnpm,
15    Npm,
16    /// `yarn.lock` v1 (classic yarn). Line-based text format with
17    /// 2-space indented fields.
18    Yarn,
19    /// `yarn.lock` v2+ (yarn berry). YAML format with `__metadata:`
20    /// header, `resolution:` / `checksum:` fields, and
21    /// `languageName` / `linkType`. Same filename as `Yarn`; detection
22    /// peeks at the content for the `__metadata:` marker to pick
23    /// between the two.
24    YarnBerry,
25    NpmShrinkwrap,
26    Bun,
27}
28
29impl LockfileKind {
30    pub fn filename(self) -> &'static str {
31        match self {
32            LockfileKind::Aube => "aube-lock.yaml",
33            LockfileKind::Pnpm => "pnpm-lock.yaml",
34            LockfileKind::Npm => "package-lock.json",
35            LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
36            LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
37            LockfileKind::Bun => "bun.lock",
38        }
39    }
40}
41
42/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
43/// over the target. Every format writer goes through this so a
44/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
45/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
46/// the same guarantee post Win10. Caller passes the serialized
47/// bytes already formatted, this just handles the IO layer.
48pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
49    aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
50}
51
52/// Write a lockfile to the given project directory using aube's default
53/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
54/// lockfiles are enabled).
55pub fn write_lockfile(
56    project_dir: &Path,
57    graph: &LockfileGraph,
58    manifest: &aube_manifest::PackageJson,
59) -> Result<(), Error> {
60    write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
61    Ok(())
62}
63
64/// Collapse peer-context variants from `graph` into a single map keyed
65/// by `"name@version"`, pointing at the first-seen package. Several
66/// writers (npm, yarn, …) share this shape: one canonical entry per
67/// `(name, version)` pair regardless of how many peer suffixes the
68/// full graph emits.
69pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
70    let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
71    for pkg in graph.packages.values() {
72        canonical.entry(pkg.spec_key()).or_insert(pkg);
73    }
74    canonical
75}
76
77/// Write a lockfile using the existing project lockfile kind, or
78/// `aube-lock.yaml` when the project does not have one yet.
79///
80/// This is the default write path for commands that mutate the active
81/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
82pub fn write_lockfile_preserving_existing(
83    project_dir: &Path,
84    graph: &LockfileGraph,
85    manifest: &aube_manifest::PackageJson,
86) -> Result<PathBuf, Error> {
87    let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
88    write_lockfile_as(project_dir, graph, manifest, kind)
89}
90
91/// Write `graph` in the requested lockfile format into `project_dir`.
92///
93/// Returns the path that was actually written (useful for logging
94/// since `Aube` may resolve to a branch-specific filename). Callers
95/// that want to preserve whatever format was already on disk should
96/// pair this with [`detect_existing_lockfile_kind`].
97///
98/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
99/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
100/// exists in the project; callers should pass `Aube` only when no
101/// lockfile exists yet. See each writer module's doc comment for
102/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
103pub fn write_lockfile_as(
104    project_dir: &Path,
105    graph: &LockfileGraph,
106    manifest: &aube_manifest::PackageJson,
107    kind: LockfileKind,
108) -> Result<PathBuf, Error> {
109    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
110        .with_meta_fn(|| {
111            format!(
112                r#"{{"kind":{},"packages":{}}}"#,
113                aube_util::diag::jstr(&format!("{:?}", kind)),
114                graph.packages.len()
115            )
116        });
117    let filename = match kind {
118        LockfileKind::Aube => aube_lock_filename(project_dir),
119        LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
120        other => other.filename().to_string(),
121    };
122    let path = project_dir.join(&filename);
123    match kind {
124        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
125        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
126        LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
127        LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
128        LockfileKind::Bun => bun::write(&path, graph, manifest)?,
129    }
130    Ok(path)
131}
132
133/// Return the [`LockfileKind`] of the lockfile already on disk in
134/// `project_dir`, if any. Follows the same precedence as
135/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
136/// npm-shrinkwrap > npm). Used by install to preserve a project's
137/// existing lockfile format when rewriting after a re-resolve — a
138/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
139/// supported lockfile gets that file written back, not a surprise
140/// `aube-lock.yaml` alongside it.
141pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
142    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
143        if path.exists() {
144            return Some(refine_yarn_kind(&path, kind));
145        }
146    }
147    None
148}
149
150/// Return true when the active lockfile contains Git conflict markers.
151///
152/// Used by install's prefer-frozen path to distinguish a merge/rebase
153/// artifact from an arbitrary parse error: conflict markers can be
154/// repaired by regenerating from the already-resolved `package.json`,
155/// while other parse failures should stay loud.
156pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
157    for (path, _) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
158        if !path.exists() {
159            continue;
160        }
161        return read_lockfile(&path)
162            .map(|content| has_conflict_markers(&content))
163            .unwrap_or(false);
164    }
165    false
166}
167
168fn has_conflict_markers(content: &str) -> bool {
169    content.lines().any(|line| {
170        line.starts_with("<<<<<<< ")
171            || line.trim_end_matches('\r') == "======="
172            || line.starts_with(">>>>>>> ")
173    })
174}
175
176/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
177///
178/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
179/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
180/// project is inside a git checkout with a current branch. Forward
181/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
182/// back to plain `aube-lock.yaml` in every other case.
183///
184/// Memoized per `project_dir` for the lifetime of the process: a
185/// single install resolves this 3–5 times (lockfile_candidates,
186/// write_lockfile, debug log, state read/write), and
187/// `check_needs_install` runs on every `aube run`/`aube exec` via
188/// `ensure_installed`. Without caching, every command would pay for a
189/// YAML parse + a `git branch --show-current` subprocess just to
190/// recompute a value that can't change mid-process.
191pub fn aube_lock_filename(project_dir: &Path) -> String {
192    use std::sync::{Mutex, OnceLock};
193    static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
194    let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
195    if let Ok(map) = cache.lock()
196        && let Some(hit) = map.get(project_dir)
197    {
198        return hit.clone();
199    }
200    let resolved = if !git_branch_lockfile_enabled(project_dir) {
201        "aube-lock.yaml".to_string()
202    } else {
203        match current_git_branch(project_dir) {
204            Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
205            None => "aube-lock.yaml".to_string(),
206        }
207    };
208    if let Ok(mut map) = cache.lock() {
209        map.insert(project_dir.to_path_buf(), resolved.clone());
210    }
211    resolved
212}
213
214/// Resolve the pnpm lockfile filename for `project_dir`.
215///
216/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
217/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
218/// keep writing to pnpm's file.
219pub fn pnpm_lock_filename(project_dir: &Path) -> String {
220    let aube_name = aube_lock_filename(project_dir);
221    // `aube_lock_filename` always returns "aube-lock.<rest>", so strip_prefix
222    // always succeeds. The fallback is purely defensive.
223    aube_name
224        .strip_prefix("aube-lock.")
225        .map(|rest| format!("pnpm-lock.{rest}"))
226        .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
227}
228
229fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
230    // Goes through the build-time-generated typed accessor in
231    // `aube_settings::resolved` so the alias list is driven off
232    // `settings.toml` — no hand-maintained typed field. This path
233    // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
234    // scope here because aube-lockfile doesn't want a dependency on
235    // aube-registry just to load npmrc (and the historical behavior
236    // never read `.npmrc` either).
237    let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
238        return false;
239    };
240    let npmrc: Vec<(String, String)> = Vec::new();
241    let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
242    aube_settings::resolved::git_branch_lockfile(&ctx)
243}
244
245pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
246    let out = std::process::Command::new("git")
247        .args(["-C"])
248        .arg(project_dir)
249        .args(["branch", "--show-current"])
250        .output()
251        .ok()?;
252    if !out.status.success() {
253        return None;
254    }
255    let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
256    if branch.is_empty() {
257        None
258    } else {
259        Some(branch)
260    }
261}
262
263/// Detect and parse the lockfile in the given project directory.
264///
265/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
266/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
267/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
268///
269/// `manifest` is needed to classify direct vs transitive deps when
270/// reading yarn.lock (which has no notion of that distinction).
271pub fn parse_lockfile(
272    project_dir: &Path,
273    manifest: &aube_manifest::PackageJson,
274) -> Result<LockfileGraph, Error> {
275    let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
276    Ok(graph)
277}
278
279/// Like [`parse_lockfile`] but also returns which format was read.
280pub fn parse_lockfile_with_kind(
281    project_dir: &Path,
282    manifest: &aube_manifest::PackageJson,
283) -> Result<(LockfileGraph, LockfileKind), Error> {
284    reject_bun_binary(project_dir)?;
285    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
286        if !path.exists() {
287            continue;
288        }
289        let kind = refine_yarn_kind(&path, kind);
290        let graph = parse_one(&path, kind, manifest)?;
291        return Ok((graph, kind));
292    }
293    Err(Error::NotFound(project_dir.to_path_buf()))
294}
295
296/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
297///
298/// Skips `aube-lock.yaml` — if the project already has one, there's
299/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
300/// point of `aube import` is to convert a foreign lockfile (including
301/// pnpm's) into `aube-lock.yaml`.
302pub fn parse_for_import(
303    project_dir: &Path,
304    manifest: &aube_manifest::PackageJson,
305) -> Result<(LockfileGraph, LockfileKind), Error> {
306    reject_bun_binary(project_dir)?;
307    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ false) {
308        if !path.exists() {
309            continue;
310        }
311        let kind = refine_yarn_kind(&path, kind);
312        let graph = parse_one(&path, kind, manifest)?;
313        return Ok((graph, kind));
314    }
315    Err(Error::NotFound(project_dir.to_path_buf()))
316}
317
318/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
319/// actionable error instead of silently falling through to another format.
320fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
321    let lockb = project_dir.join("bun.lockb");
322    let text = project_dir.join("bun.lock");
323    if lockb.exists() && !text.exists() {
324        return Err(Error::parse(
325            &lockb,
326            "bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default",
327        ));
328    }
329    Ok(())
330}
331
332fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
333    let mut out = Vec::new();
334    if include_aube {
335        // Prefer the branch-specific lockfile (if `gitBranchLockfile` is on
336        // and we resolve a branch); fall through to plain `aube-lock.yaml`
337        // so a freshly-enabled branch still picks up the base lockfile.
338        let branch_name = aube_lock_filename(project_dir);
339        if branch_name != "aube-lock.yaml" {
340            out.push((project_dir.join(&branch_name), LockfileKind::Aube));
341        }
342        out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
343    }
344    // Preserve pnpm lockfiles in place. Branch-specific
345    // `pnpm-lock.<branch>.yaml` mirrors the aube branch lockfile naming
346    // logic, so a project that already uses pnpm branch lockfiles keeps
347    // writing through that file.
348    let pnpm_branch = {
349        let mut s = aube_lock_filename(project_dir);
350        if let Some(rest) = s.strip_prefix("aube-lock.") {
351            s = format!("pnpm-lock.{rest}");
352        }
353        s
354    };
355    if pnpm_branch != "pnpm-lock.yaml" {
356        out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
357    }
358    out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
359    out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
360    out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
361    out.push((
362        project_dir.join("npm-shrinkwrap.json"),
363        LockfileKind::NpmShrinkwrap,
364    ));
365    out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
366    out
367}
368
369fn parse_one(
370    path: &Path,
371    kind: LockfileKind,
372    manifest: &aube_manifest::PackageJson,
373) -> Result<LockfileGraph, Error> {
374    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
375        .with_meta_fn(|| {
376            // Emit only the file name (e.g. `aube-lock.yaml`) so traces
377            // do not leak absolute project paths.
378            let display = path
379                .file_name()
380                .map(|n| n.to_string_lossy().into_owned())
381                .unwrap_or_default();
382            format!(
383                r#"{{"kind":{},"path":{}}}"#,
384                aube_util::diag::jstr(&format!("{:?}", kind)),
385                aube_util::diag::jstr(&display)
386            )
387        });
388    match kind {
389        // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
390        // now — same parser, same writer — so we piggyback on the pnpm
391        // module. Keeping the variant distinct lets detection/import
392        // treat the two differently even though the bytes are the same.
393        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
394        // yarn.rs::parse peeks the file for `__metadata:` and
395        // dispatches between classic (v1) and berry (v2+) internally,
396        // so we can hand both kinds to the same entry point. The
397        // caller keeps the kind label it resolved from
398        // `refine_yarn_kind` for downstream write-back.
399        LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
400        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
401        LockfileKind::Bun => bun::parse(path),
402    }
403}
404
405/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
406/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
407/// kinds pass through unchanged.
408///
409/// `lockfile_candidates` only knows filenames, not content, so the
410/// yarn entry is always tagged `Yarn`. Callers that need the precise
411/// variant (install write-back, import conversions, drift logging)
412/// funnel through this helper after confirming the candidate exists.
413fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
414    if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
415        LockfileKind::YarnBerry
416    } else {
417        kind
418    }
419}
420
421#[derive(Debug, thiserror::Error, miette::Diagnostic)]
422pub enum Error {
423    #[error("no lockfile found in {0}")]
424    #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
425    NotFound(std::path::PathBuf),
426    #[error("unsupported lockfile format: {0}")]
427    #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
428    UnsupportedFormat(String),
429    #[error("failed to read lockfile {0}: {1}")]
430    Io(std::path::PathBuf, std::io::Error),
431    /// Structural/serialization lockfile errors that have no source
432    /// location — shape checks (`must be a mapping`), version guards
433    /// (`lockfileVersion N unsupported`), and `yaml_serde::to_string`
434    /// failures during write.
435    #[error("failed to parse lockfile {0}: {1}")]
436    #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
437    Parse(std::path::PathBuf, String),
438    /// Deserialization failure with a byte offset into the source
439    /// content, so miette's `fancy` handler can draw a pointer at the
440    /// offending byte of the lockfile. Reuses `aube_manifest`'s
441    /// `ParseError` — identical shape, identical rendering — via the
442    /// same `ParseDiag` pattern `aube-workspace` uses.
443    #[error(transparent)]
444    #[diagnostic(transparent)]
445    ParseDiag(Box<aube_manifest::ParseError>),
446}
447
448/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
449pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
450    std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
451}
452
453/// Parse a JSON lockfile document, attaching a miette source span on
454/// failure so the fancy handler can point at the offending byte.
455pub fn parse_json<T: serde::de::DeserializeOwned>(
456    path: &std::path::Path,
457    content: String,
458) -> Result<T, Error> {
459    // sonic-rs takes an immutable &[u8], so the original `content`
460    // bytes stay intact for the serde_json fallback's diagnostic.
461    match sonic_rs::from_slice(content.as_bytes()) {
462        Ok(v) => Ok(v),
463        Err(_) => match serde_json::from_str(&content) {
464            Ok(v) => Ok(v),
465            Err(e) => Err(Error::parse_json_err(path, content, &e)),
466        },
467    }
468}
469
470impl Error {
471    pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
472        Error::Parse(path.to_path_buf(), msg.into())
473    }
474
475    pub fn parse_json_err(
476        path: &std::path::Path,
477        content: String,
478        err: &serde_json::Error,
479    ) -> Self {
480        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
481            path, content, err,
482        )))
483    }
484
485    pub fn parse_yaml_err(
486        path: &std::path::Path,
487        content: String,
488        err: &yaml_serde::Error,
489    ) -> Self {
490        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
491            path, content, err,
492        )))
493    }
494}
495
496#[cfg(test)]
497mod parse_diag_tests {
498    use super::*;
499    use std::path::Path;
500
501    /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
502    /// helper attaches a `NamedSource` pointed at the lockfile path and
503    /// the span stays in bounds so miette can render a pointer.
504    #[test]
505    fn parse_json_attaches_span_for_bad_input() {
506        let path = Path::new("package-lock.json");
507        let content = r#"{"name":"x","#.to_string();
508        let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
509        else {
510            panic!("parse_json must produce ParseDiag on malformed input");
511        };
512        let offset: usize = pe.span.offset();
513        let len: usize = pe.span.len();
514        assert!(offset + len <= content.len());
515        assert_eq!(pe.path, path);
516    }
517
518    /// Same story for YAML — yaml_serde reports a `Location` with a
519    /// byte index directly, so no line/col conversion is exercised
520    /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
521    /// `Error::parse_yaml_err` directly (one iterates multiple YAML
522    /// documents, the other has only borrowed content), so that's the
523    /// entry point this test locks down.
524    #[test]
525    fn parse_yaml_err_attaches_span_for_bad_input() {
526        let path = Path::new("yarn.lock");
527        let content = "packages:\n\t- pkg\n".to_string();
528        let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
529            .expect_err("tab-indented YAML must fail");
530        let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
531            panic!("parse_yaml_err must produce ParseDiag");
532        };
533        let offset: usize = pe.span.offset();
534        let len: usize = pe.span.len();
535        assert!(offset + len <= content.len());
536        assert_eq!(pe.path, path);
537    }
538}
539
540#[cfg(test)]
541mod filename_tests {
542    use super::*;
543
544    #[test]
545    fn defaults_to_plain_lockfile_when_setting_absent() {
546        let dir = tempfile::tempdir().unwrap();
547        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
548        assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
549    }
550
551    #[test]
552    fn defaults_to_plain_lockfile_when_setting_explicit_false() {
553        let dir = tempfile::tempdir().unwrap();
554        std::fs::write(
555            dir.path().join("pnpm-workspace.yaml"),
556            "gitBranchLockfile: false\n",
557        )
558        .unwrap();
559        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
560    }
561
562    #[test]
563    fn uses_branch_filename_when_enabled_inside_git_repo() {
564        let dir = tempfile::tempdir().unwrap();
565        std::fs::write(
566            dir.path().join("pnpm-workspace.yaml"),
567            "gitBranchLockfile: true\n",
568        )
569        .unwrap();
570        // git init + checkout a branch with a `/` so we exercise the
571        // pnpm-style `!` encoding.
572        let run = |args: &[&str]| {
573            std::process::Command::new("git")
574                .args(["-C"])
575                .arg(dir.path())
576                .args(args)
577                .output()
578                .unwrap()
579        };
580        if run(&["init", "-q"]).status.success() {
581            run(&["checkout", "-q", "-b", "feature/x"]);
582            assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
583            assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
584        }
585    }
586}