Skip to content

uv-inspired: cross-platform lockfile, hashes, sync, tree, add/remove#8

Open
voidreamer wants to merge 8 commits into
mainfrom
feat/uv-inspired-improvements
Open

uv-inspired: cross-platform lockfile, hashes, sync, tree, add/remove#8
voidreamer wants to merge 8 commits into
mainfrom
feat/uv-inspired-improvements

Conversation

@voidreamer
Copy link
Copy Markdown
Owner

Summary

Eight uv-shaped improvements to the lockfile and resolver, ordered by user-visible impact.

Resolver / lockfile (tier 1)

  • Resolver now explains version conflicts: every (requester, constraint) pair is recorded, and a mismatch produces a multi-line diagnostic naming the chosen version, every package that asked for the name, and the incompatible row. "Package not found" / "no matching version" gain the requester chain.
  • Lockfile pins record a SHA-256 of the package definition. Drift detection on shared package_paths filesystems; legacy string-form pins (python: "3.11") keep parsing via untagged-enum compat.
  • anvil lock --all-platforms resolves once per linux/macos/windows from a single cache (variant application moved from load time to resolve time). Common pins live in pins; per-platform diffs land in platform_pins; reader overlays the entry for the running platform.

CLI ergonomics (tier 2)

  • --locked (re-resolve and diff vs disk; CI gate) and --frozen (refuse anything not pinned; render farms) as global flags.
  • anvil sync — per-pin verification (existence, hash drift, command-target reachability) with green/red counts.
  • anvil tree — box-drawing dependency graph; (*) marks repeats so diamond deps don't print twice.
  • anvil lock --upgrade-package <name> — surgical bumps. Drops only the named pin(s); everything else stays exactly where it was.
  • anvil add / anvil remove — mutate the lockfile's request set and re-resolve so the project's package list is managed without hand-editing YAML.

Test plan

  • All existing tests still green (49 → 76; no regressions).
  • Resolver: conflict diagnostic lists both requesters with constraints and INCOMPATIBLE marker.
  • Resolver: missing dep error names the parent package; missing top-level version names the request.
  • Lockfile: lock records content_hash:; legacy pins: { python: "3.11" } still parses; modifying a YAML after lock fires a drift warning.
  • Cross-platform: --all-platforms writes platform_pins: overlay; single-platform lock omits it.
  • --locked passes when fresh, fails on stale lock with a readable diff, fails when no lock exists.
  • --frozen uses the lock verbatim, fails when an unpinned name is requested, fails when no lock exists.
  • anvil sync exits 0 on a healthy lockfile, non-zero when a pin's package is missing, warns (exit 0) on hash drift.
  • anvil tree renders ├── / └── connectors and (*) for diamond deps.
  • --upgrade-package python re-resolves only python and keeps every other pin.
  • anvil add / anvil remove create/append/replace/drop requests and re-lock; both refuse to write an empty lockfile.

Notes

  • Package scan cache salt is bumped (schema=v2|...) so caches written by older binaries (which pre-merged variants into Package) get rebuilt automatically.
  • README hasn't been updated to document the new flags/commands — worth a follow-up before tagging a release.

Track every (requester, constraint) pair encountered during depth-first
resolution.  When a later request is incompatible with an already-chosen
version, emit a diagnostic that names the chosen version, lists every
package that asked for the name, and marks the incompatible row.  The
"package not found" and "no matching version" messages now include the
requester (parent package id, "<request>" for top-level, or "<lockfile>"
for stale pins) so deep dependency failures are traceable at a glance.

Adds Display impls for VersionConstraint and PackageRequest so the
diagnostics round-trip the original constraint syntax.

The resolver still does not backtrack -- the first chosen version wins,
and conflicts are surfaced rather than worked around -- but every error
now identifies both sides of the conflict.
Each pin now stores a SHA-256 of the package definition file alongside
its version.  When the resolver loads a lockfile it compares each
recorded hash against the file currently on disk and emits a warning
if they differ -- the case where someone edits a shared package on a
studio filesystem after a project locked, which used to be silent.

Pin gains a custom Deserialize that accepts both the new map form
  python: { version: "3.11", content_hash: "..." }
and the legacy string form
  python: "3.11"
so existing anvil.lock files keep working without re-locking.

Adds the sha2 crate, a content_hash() helper on Package, and a
source_path field that the loader populates so the hash can be
recomputed without re-walking the package paths.
Variant application moved from package load time to the resolver: the
package cache now stores raw definitions (variants intact, requires/env
not yet merged), and the resolver merges the variant for the chosen
platform when materialising each resolved package.  This lets `anvil
lock --all-platforms` resolve the same request set as if running on
linux, macos, and windows in turn from a single scan.

The lockfile schema gains two fields, both with serde defaults so older
files still parse:
  - platforms: which platforms were resolved at lock time
  - platform_pins: per-platform overlay applied on top of common `pins`

Pins shared by every locked platform (same name + version + content
hash) live in `pins`; pins that differ per platform live under
`platform_pins[<platform>]`.  When the resolver loads a lockfile it
overlays the entry for the current running platform via the new
Lockfile::effective_pins helper, so a single anvil.lock is correct on
any locked target.

The package scan cache salt is bumped so caches written by older anvil
binaries (which stored variant-merged Packages) get re-built rather
than confusing the new resolve-time variant logic.
--locked: re-resolve the locked request set fresh and diff against
the pins on disk before running any command.  Any drift -- different
version, different content hash, missing or extra package -- aborts
with a per-line breakdown.  Fails fast when there is no lockfile.
Useful for CI to catch a forgotten `anvil lock` re-run.

--frozen: refuses to use anything not already pinned.  Every name
the resolver touches (top-level requests, transitive requires) must
appear in anvil.lock; otherwise the command fails with the offending
package and its requester.  Skipping a fresh resolve was already the
default whenever a lockfile is present, so this is purely a guard
against unintended drift on render farms or shared workstations.

The two flags are mutually exclusive (clap conflicts_with) and apply
globally to every command that constructs a Resolver.  Lockfile gains
a Lockfile::diff_pins helper that the --locked path uses to format
the failure message; the same helper will be reused by `anvil sync`.
Walks every effective pin for the current platform and, for each:
  - confirms the pinned name+version exists in the package paths
  - compares content hashes (if recorded) and reports drift
  - validates each command-alias target resolves to an executable

Prints one line per pin (ok / warn / fail) plus a summary, and exits
non-zero only when one or more pins fail outright (missing package).
Hash drift and broken command targets are warnings -- the lockfile is
intact, but the operator should know.

Useful before farm jobs that must not fail mid-run on a missing alias,
or as a smoke test after a workstation rsyncs a new package set.
Resolves the requested package set and walks the requires graph,
printing a Unicode-box ASCII tree with one root per top-level request.
Repeated nodes (diamond dependencies, shared transitive deps) are
marked `name-version (*)` after their first appearance, which keeps
the output compact and terminates cycles.
Repeatable flag.  Loads the existing lockfile, drops only the named
pins, then resolves -- so the upgraded names get the highest matching
version while every other pin stays exactly where it was.  Matches the
mid-show case where you want python to bump but every other dep must
not move.

Errors out clearly if there is no anvil.lock to start from.  Without
the flag, `anvil lock` keeps its existing always-fresh behaviour.
Both commands mutate anvil.lock's `requests` array and re-resolve so
the lockfile reflects the new project package set.  This lets users
manage the project's package list without editing YAML or repeating
themselves on the command line.

  anvil add maya-2024            -- create lockfile if missing,
                                    append maya-2024
  anvil add maya-2025            -- replace any existing request for
                                    'maya' with the new constraint
  anvil remove arnold            -- drop every request whose package
                                    name is 'arnold' (constraints OK)

Both refuse to write an empty lockfile (unusual state, probably a
mistake), and `anvil remove` refuses to run without an existing
anvil.lock to mutate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant