Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ regex = "1.10"
cfg-if = "1.0"
tempfile = "3.10"

# Hashing (lockfile drift detection)
sha2 = "0.10"

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
Expand Down
65 changes: 65 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ pub struct Cli {
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Verify that anvil.lock is up to date. Re-resolves the locked
/// request set fresh and compares against the pins on disk; any
/// drift (different version, different content hash, missing or
/// extra package) fails the command. Useful in CI.
#[arg(long, global = true, conflicts_with = "frozen")]
pub locked: bool,

/// Use anvil.lock verbatim and never fall back to fresh
/// resolution. Any package the resolver would otherwise pick
/// from the package paths must already be pinned, otherwise the
/// command fails. Useful for render farms and other non-mutating
/// runs that must never silently drift.
#[arg(long, global = true, conflicts_with = "locked")]
pub frozen: bool,

#[command(subcommand)]
pub command: Commands,
}
Expand Down Expand Up @@ -104,6 +119,20 @@ pub enum Commands {
/// Re-resolve even if anvil.lock already exists
#[arg(long)]
update: bool,

/// Resolve for every supported platform (linux, macos, windows)
/// and union the results, so a single lockfile is correct on
/// any of them. Variant-specific `requires:` are recorded under
/// the relevant platform.
#[arg(long)]
all_platforms: bool,

/// Re-resolve only this package name (repeatable), keeping
/// every other existing pin untouched. Without this flag,
/// `anvil lock` re-resolves every package; with it, the
/// lockfile is updated surgically.
#[arg(long = "upgrade-package", value_name = "NAME")]
upgrade_packages: Vec<String>,
},

/// Save and restore complete resolved environments
Expand All @@ -112,6 +141,42 @@ pub enum Commands {
action: ContextAction,
},

/// Verify that every pin in anvil.lock is reachable, hash-matching,
/// and that each pinned package's commands resolve to existing
/// executables. Read-only; useful before farm jobs that must not
/// fail mid-run on a missing alias.
Sync,

/// Print the dependency tree for a set of packages.
Tree {
/// Packages to resolve and visualise.
#[arg(required = true)]
packages: Vec<String>,
},

/// Add packages to the project's locked request set.
///
/// Reads anvil.lock (creating an empty request set if absent),
/// adds the given packages -- replacing any existing request for
/// the same name -- and re-resolves so the lockfile reflects the
/// new set.
Add {
/// Packages to add (e.g., maya-2024 arnold-7.2)
#[arg(required = true)]
packages: Vec<String>,
},

/// Remove packages from the project's locked request set.
///
/// Reads anvil.lock, drops every request whose package name
/// matches one of the given names, and re-resolves so the
/// lockfile reflects the smaller set.
Remove {
/// Package names to remove (e.g., arnold)
#[arg(required = true)]
names: Vec<String>,
},

/// Scaffold a new package definition (or `--config` for the global config)
Init {
/// Package name (e.g., my-tools). Omit when using `--config`.
Expand Down
113 changes: 110 additions & 3 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,99 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

// ---------------------------------------------------------------------------
// Lockfile
// ---------------------------------------------------------------------------

/// One entry in `Lockfile.pins`. Carries the version and an optional
/// content hash so drift in shared package directories is detectable.
///
/// Deserializes from either the modern map form
/// `python: { version: "3.11", content_hash: "..." }`
/// or the pre-0.5 string form
/// `python: "3.11"`
/// so older `anvil.lock` files keep working.
#[derive(Debug, Clone, Serialize)]
pub struct Pin {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
}

impl<'de> Deserialize<'de> for Pin {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum PinForm {
Legacy(String),
Modern {
version: String,
#[serde(default)]
content_hash: Option<String>,
},
}
Ok(match PinForm::deserialize(deserializer)? {
PinForm::Legacy(version) => Pin {
version,
content_hash: None,
},
PinForm::Modern {
version,
content_hash,
} => Pin {
version,
content_hash,
},
})
}
}

/// Pins package versions for reproducible resolution.
///
/// Stored as `anvil.lock` in the project directory. When present, the
/// resolver prefers pinned versions over the default "highest matching"
/// strategy.
///
/// `pins` holds packages that resolve identically on every locked
/// platform. `platform_pins` holds per-platform overrides for cases
/// where a variant's `requires:` pulls in a different version (or a
/// different package entirely) on different platforms — `anvil lock
/// --all-platforms` records those, and the reader overlays the entry
/// for its current platform on top of `pins`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
/// Original package requests that produced this lockfile.
pub requests: Vec<String>,
/// Pinned versions: package name -> exact version string.
pub pins: HashMap<String, String>,
/// Platforms this lockfile was resolved for. Empty in legacy
/// lockfiles; treat empty as "current platform only."
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
/// Pinned versions: package name -> pin entry.
pub pins: HashMap<String, Pin>,
/// Per-platform pin overrides. Keys are platform names
/// (linux/macos/windows); values overlay `pins` for that platform.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub platform_pins: HashMap<String, HashMap<String, Pin>>,
}

impl Lockfile {
/// Pins applicable to `platform`: start from common `pins`, overlay
/// any platform-specific entries. Used by the resolver to choose
/// the right pin when a single lockfile carries diffs across
/// platforms.
pub fn effective_pins(&self, platform: Option<&str>) -> HashMap<String, Pin> {
let mut out = self.pins.clone();
if let Some(p) = platform {
if let Some(over) = self.platform_pins.get(p) {
for (k, v) in over {
out.insert(k.clone(), v.clone());
}
}
}
out
}
}

impl Lockfile {
Expand All @@ -40,6 +116,37 @@ impl Lockfile {
.with_context(|| format!("Failed to write lockfile: {:?}", path))
}

/// Compare two pin maps and return a list of human-readable
/// differences. An empty Vec means they agree; otherwise each
/// entry is one drift line ("python: 3.10 -> 3.11" etc.).
pub fn diff_pins(expected: &HashMap<String, Pin>, actual: &HashMap<String, Pin>) -> Vec<String> {
let mut diffs = Vec::new();
for (name, want) in expected {
match actual.get(name) {
None => diffs.push(format!("{}: pinned {} but not produced by fresh resolve", name, want.version)),
Some(got) if got.version != want.version => diffs
.push(format!("{}: pinned {} but fresh resolve picks {}", name, want.version, got.version)),
Some(got) if want.content_hash.is_some()
&& got.content_hash.is_some()
&& got.content_hash != want.content_hash =>
{
diffs.push(format!(
"{}-{}: content hash differs (lockfile vs disk)",
name, want.version
))
}
_ => {}
}
}
for (name, got) in actual {
if !expected.contains_key(name) {
diffs.push(format!("{}: fresh resolve adds {} (not in lockfile)", name, got.version));
}
}
diffs.sort();
diffs
}

/// Search for `anvil.lock` starting from CWD and walking upward.
pub fn find() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
Expand Down
Loading
Loading