Feedback on `cargo-upgrade` to prepare it for merging

I'm curious to hear your thoughts on this. I've actually been meaning to propose the opposite: we should always check-in lockfiles, not just for binaries.

  • I recently had to bisect a project without a lockfile and failed to find the offending change and am left uncertain if it came from the one of many dependencies
  • Testing for MSRV is nearly impossible without one
5 Likes

Hey, I'm also interested in your argument against committing lockfiles.

My opinion is that all projects should always commit their lockfiles. The reason is reproducibility.

  • A lockfile allows you to checkout any commit and be guaranteed that the dependencies will allow you to successfully compile the project (assuming the registry is immutable, which is the case for crates.io).
  • A lockfile does not prevent you from checking what would happen with the latest dependencies: you can delete it at any time to force a refresh (or just use cargo update).
  • On the other hand, if you don't commit the lockfile, it's virtually impossible to recover the state at the time of the commit.

In my opinion the benefits of long-term reproducible builds far outweigh the need to be explicit when you want to check behavior with the latest compatible dependencies. In particular they are invaluable when investigating regressions or trouble-shooting build errors. If a crate fails to build locally, but I see a commit with a lockfile and green CI it narrows down the source of errors to factors outside of the control of cargo.

To be honest I'm still a bit baffled by this recommendation in the cargo book:

Why do binaries have Cargo.lock in version control, but not libraries?

[...]

For libraries the situation is somewhat different. A library is not only used by the library developers, but also any downstream consumers of the library. Users dependent on the library will not inspect the library’s Cargo.lock (even if it exists). This is precisely because a library should not be deterministically recompiled for all users of the library.

A Cargo.lock in a lib repo is obviously not for users but for lib devs. A library still has binaries, they're just called through cargo test instead of cargo run; why should they be non-deterministic by default? A Cargo.lock does not prevent running your tests against the latest dependency versions, it's easy. What's hard is manually resolving the dependencies at a given time when you forgot to commit the lock file in the first place.

This is also the stance of Yarn, which is IMO the most reliable package manager for Node:

Which files should be gitignored?

[...]

Yarn actually goes even further and recommends storing the Yarn version itself in the project. I recommend also reading their arguments (they have other ones, such as a missing lock file not being sufficient to ensure you use the freshest dependencies anyway). cargo can piggyback off rust-version so we probably don't need a cargo-version.


Also to have a message on the topic of this thread. A few months ago I posted a comment about a previous version of cargo upgrade complaining that it was harder to pull the latest version to test against them. Since then, cargo-edit was updated a few times and I'm very happy with the current iteration of cargo upgrade. Thank you for your work.

3 Likes

I'm a huge fan of this even though it seems relatively unpopular. Bisection is often difficult/impossible without a Cargo.lock. Even getting a clean build on a stale repo can be problematic.

This has lead to requests for a Cargo time machine that could try to do the resolution based on a historical index state, but there's a lot simpler solution: just check in Cargo.lock.

2 Likes

This is the approach Go's modules take, and I think lockfiles for libraries are maximally powerful combined with Go's notion of Minimum Version Selection (research!rsc: Minimal Version Selection (Go & Versioning, Part 4)). Obviously that's a much bigger change than simply including them.

2 Likes

I'm very curious to hear about this as well.

1 Like

cargo-edit 0.12.0 is out. I have not (yet) revamped the UI but did smaller steps

  • --recursive changed from being on by default to the default being based on if compatible upgrades are being done
  • We now choose MSRV-compatible version requirements
    • Obviously, the resolver itself is not (yet) MSRV aware
    • The message around MSRV-incompatible versions being available is not great. It was going to make the code fairly messy while in contrast I hope the UX changes will simplify the existing code also making improving this simplerr
1 Like

Committing a lockfile doesn't mean it must be used. People are always free to rm it, do cargo clean, and build from scratch.

The lockfile is more like a documentation: it last worked with this particular set of dependencies. Useful for debugging all sort of problems with dependencies, like regressions and semver violations.

1 Like

Looking to summarize a recent discussion on where to go from here. I've tried to go back and include some details from the thread but I likely missed some.

Expected user operations across cargo update and cargo upgrade

  • Selective upgrade to latest compatible, latest incompatible, or user-provided version req
  • Selective locking to a specific version
  • Bulk upgrade to latest compatible
  • Bulk upgrade to latest incompatible
    • Selectively ignoring renamed dependencies as they are likely meant to be used with a specific major version (e.g. tokio_03)
    • Selectively ignoring non-default version requirement operators as the user might have intended to pin things

Note: "compatible" and "incompatible" are relative to the version requirements and not semver (though thats the default version requirement operator)

cargo update today

  • Works at workspace level
  • Edits Cargo.lock, not Cargo.toml
  • cargo update -p foo@ver, @ver is used to disambiguate foo within the dependency tree (must be full version)
    • --precise ver takes a full version for replacing ver in the lockfile and must remain compatible
    • --aggresive to recursively update foo

Considerations:

  • What do we optimize for (ie default)? For some workflows:
    • [[bin]] maintainers may want bulk updates of everything, most likely with a focus on compatible as incompatible might need hand updates
    • [lib] normal/build deps: maintainers may want compatible updates by hand. incompatible bulk updates are good for checking of it works but sometimes they will need to be done by hand
    • [lib] dev-only deps: like [[bin]]
  • Upcoming MSRV-aware resolver (personally leaning towards this always being enabled)
  • -Zdirect-minimal-versions
    • Users may want to keep their lockfile and requirements in-sync so what gets tested locally is at least what your dependents will use
    • This either requires a sticky -Zdirect-minimal-versions or a way to update requirements without updating the lockfile

Proposal 1: Deprecate cargo update in favor of cargo upgrade

  • cargo upgrade (formerly cargo update && cargo upgrade --to-lockfile)
  • cargo upgrade --incompatible / cargo upgrade -i
    • Change version requirements to latest incompatible, leaving compatible versions the same
    • Non-recursively updates lock file
    • Ignores pinned dependencies
  • cargo upgrade -p foo
    • Change dep names "foo" (not dependency package names)
    • Can be used with -i, --pinned
  • cargo upgrade -p foo@verreq
    • Change dep names "foo" to the specified version req
    • Open question: is --pinned needed?
  • Open question: what command handles the role of cargo update -p foo --precise ver?

Proposal 2: Separate Commands

  • cargo upgrade only does incompatible (formerly cargo upgrade --incompatible allow --compatible ignore)
  • cargo update --save does compatible

Proposal 3: Merge Upgrade into Update

  • cargo update stays the same except...
  • cargo update --save (formerly cargo upgrade --incompatible false --compatible true)
    • Concern: Inconsistency on whether --save is needed feels off to me
  • cargo update --incompatible (formerly cargo upgrade --incompatible true --compatible false)
  • cargo update -p tokio_03 de-sugars the dep name to package name + version (tokio@0.3.12)
  • cargo update -p foo --precise ver
    • Open question do we make a version requirement out of --precise and not allow controlling the precision or operators, do we hack up --precises behavior, or find a new flag?
    • Open question if ver is incompatible, do your need --save, -i, or either?
  • Open question can add support for cargo update --precise --save to fully specify all version requirements
  • cargo update && cargo update --save --locked would be an error, mirroring cargo adds behavior which has --locked apply to the manifest as well

Note:

  • All of these switch from being able to upgrade both compatible and incompatible in a single command to requiring two invocations. We are assuming that doing both is a low enough occurrence that the simplified set of flags for the more common cases justifies it
  • We have not addressed users limiting actions to normal+build vs dev
    • Cargo tree does this through the --edges flag which works when talking about graphs but not really in this context
  • Can we correctly limit upgrades that would cause incompatible links?
  • Do we bother allowing upgrading of pinned dependencies? Leaning towards "no"
  • When only upgrading incompatible version requirements, if that forces a direct dependency to do a compatible upgrade, should we also update the version requirements?

I think the next step is updating cargo-edit to one of the proposals for us to gain first-hand experience with how it works out

  • (1) and (2) would be easy to implement for just getting something better into people's hands
  • (3) would be the most different in workflow and I suspect the one that we would learn the most from people using it even if I personally hope we don't go this route
1 Like

I still don't think we should have --compatible true and --incompatible true; let's just spell those --compatible/-c and --incompatible/-i. If you pass either one, you just get that one; if you want both, pass both.

Of the various reasons given to not merge upgrade into update, the main one seems like the inconsistency of having to give --save to upgrade the manifest for compatible upgrades but not for incompatible upgrades.

I think that's worth not having two commands or a deprecated command, though, because I think it's a natural consequence:

  • Any upgrade that includes incompatible packages has to upgrade the manifest, so --save would be redundant there.
  • With a new command we could switch the default to save compatible versions in the manifest, as well, but I don't actually think that's the right default: "update the lockfile" still seems like a common action and shouldn't become more verbose.
  • We could make it consistent by adding a -c/--compatible option, and then having cargo update -c and cargo update -i both save changes to the manifest.

So:

  • cargo update stays the same
  • cargo update -c upgrades compatible and saves to the manifest
  • cargo update -i upgrades incompatible and saves to the manifest
  • cargo upgrade -c -i upgrades both compatible and incompatible and saves to the manifest.
1 Like

The options I gave dropped --compatible <value> / --incompatible <value>. The difference is in the details of what happens if no flags are passed in and whether both flags exist.

I disagree when it is as destructively described as --save.

However I think this is subject to how the flag is framed and framing it a different way (your --compatible) bypasses that. Sorry, I had forgotten to call this out when doing my write up.

I'm still a little cautious of this approach as there is still a gap in the concepts of --compatible and --incompatible . This isn't a "no", just a "eh".

Then I misunderstood your parentheticals; I thought you were giving "abbreviated form (long form)" rather than "new form (old form)".

Thanks for pointing out that unclear point; I've tried to clarify it

I kinda disagree here. For bins, if you're doing an update --incompatible at all, the goal is most likely to get all dependencies on their latest version, and there's no reason to not do so for dependencies that haven't released a breaking bump.

If you're being more precise about it, then you'd (either do so originally or back up and) probably do each incompatible dependency upgrade in an independent step and commit, not attempt to do all of them at the same time.

I still don't really see --incompatible true --compatible false ever really being a desirable workflow[1]; either you want to update all dependencies in one step, or you want to update single (trees of[2]) dependencies at a time to minimize the incremental patches needed to resolve the upgrade.

So my counteroffer would be I guess roughly

  • update writes to lockfile (as today)
  • update --save writes to manifest
  • update --breaking is an error, suggesting one of the below
  • update --breaking --save writes to manifest, upgrades allowed to cross compatibility ranges
  • update --breaking --precise implies --save for just the specified dependencies (any transitive updates are lockfile only and not saved unless --save is also specified)

A interestingly meaningful command might then be cargo update -bsp tracing-subscriber; this would upgrade your manifest dependencies on tracing-subscriber and tracing to the most up to date potentially breaking release.

Interesting (but potentially poor) idea: differentiate between version = "1.0.0" and version = "^1.0.0"; --breaking will update "default bound" version requirements across compatibility ranges but not move requirements using an operator across compatibility ranges. Rationale: using a non-default requirement mode is an advanced use case (which required manual editing of the manifest at some point rather than the use of cargo add and upgrade) and the user probably knows what they're doing and want better than the tool can guess it. As an escape hatch, a --force flag will change back to the default bound requirement again (if an update outside of the bound exists) and do the update.

That idea is why I used --breaking instead of --incompatible; I'm focusing on the fact that an incompatible upgrade of the default requirement mode is potentially breaking (and thus not done without an opt-in), rather than that it's incompatible with the version requirement.


  1. Even for libs that want to keep low requirements within compatibility ranges, I'd describe what they want not as "upgrade incompatible to the most recent" but "upgrade all to the lowest compatible in their most recent compatibility range;" it's not a difference in the upgrade they're doing but in the version selection preference. ↩︎

  2. Things are a bit more interesting when multiple dependencies related by a public/peer dependency are involved, as you do want/need to upgrade them together. ↩︎

With the current cargo-edit version is there any way to upgrade all dependencies for one crate within a workspace? Or even upgrade a version requirement in one crate but not another when multiple in the workspace have the same dependency? It seems like it's only capable of operating in --workspace mode currently. (I have a few repositories containing separate library and binary crates so I commonly want to treat their dependencies differently).

Currently, everything is workspace-wide. The motivations were

  • Avoid UX confusion over the user filtering their workspace vs selecting which packages get upgraded
  • Align with cargo-update

I think this is the first time since that change that someone has expressed interest in handling upgrades per-workspace-member. Feel free to come up with a counter proposal for how it can work. I'm assuming supporting this would push us away from merging cargo-upgrade and cargo-update.

I like --breaking over --incompatible. FWIW, I don't like update --save because IMO update without --save also saves (to the lockfile). I would maybe go over something like --requirements instead to make the difference more obvious.

I disagree. I never like bumping semver-compatible dependency requirements to force them to their latest version, because it's relatively common that the latest version of something will break/be yanked, which is easier to repair if it's just the lockfile.

Also in terms of precision, I usually don't actually need the newer dependency for a compatible update, so it also goes against the grain of maintaining minimal(ish) versions.

@djc Honestly, if we can agree on a structure of:

  • cargo update - same as before
  • cargo update --something (plus short option) - upgrade (just) compatible versions and write to manifest
  • cargo update --something-else (plus short option) - upgrade (just) incompatible versions and write to manifest
  • cargo update --something --something-else (plus short options) - upgrade all versions and write to manifest

then I really, really don't care much what color we paint those two bikesheds. People can learn whatever the option is as long as it's even slightly intuitive.

4 Likes

Yes, I think that design makes sense.

cargo update --save never really sat right with me. To help work through this, @josh and I had some further conversations on this.

Based on where this landed, I would ask that if people propose alternative solutions, to frame them in terms of what role the related commands are meant to fill and how the solution fits within that role.

Care Abouts

For reference, our shared priorities are

  • Don't break behavior on cargo update
  • Don't write out incompatible Cargo.lock and Cargo.toml
  • Focus is on end-users solving common problems and not on being a general programmatic CLI that is meant to cover every case
  • Be predictable and understandable
    • Can someone unfamiliar with Rust, reading a blog post, predict what different command invocations will do?
    • Preference for not having too similarly named commands
  • When higher priorities allow, avoid errors that make users go "if you know exactly what I was asking for then why didn't you just do it?"; those are a sign of issues with the UX.
  • Don't be hassle when dealing with intentionally held back dependencies

Our primary use cases we are targeting are:

  • Want to have simple workflow for "upgrade incompatible dependencies only".
  • Want to have simple workflow for bulk lock to latest compatible (already exists as cargo update)
  • (medium priority) Selective modify one dependency's version requirement to latest compatible, latest incompatible
  • (lower priority) Want to have simple-ish workflow for bulk upgrade to latest compatible
  • (lower priority) bulk upgrade all dependencies (could just be two command invocations)

Our secondary use cases are:

  • Selective modify version requirement to user-provided value
  • Upgrade explicitly pinned version requirements

Some open questions we had

  • How do we tell when a renamed dependency like tokio_03 is pinned or not?
    • We could just assume all renamed are pinned
    • We could add a dependency field but I'm a bit leery of adding that kind of bookkeeping to the manifest
    • We could force users to --exclude these dependencies but that might be a bit of a pain to always remember to do
    • We could only skip renamed if multiple dependencies exist that point to the same package

Context

Currently, cargo update is focused solely on Cargo.lock editing

  • Spans entire dependency tree
  • Multiple versions of a package may exist, referenced by name@version
  • Deals with exact versions and not version ranges
  • Only affects you and not your dependents

Version requirement editing is different in that

  • Workspace members only
  • May want differences between members
  • Supports alternative names for packages
  • Affects dependents

And as a reminder of the CLI:

cargo update -p foo  # select a non-ambiguous `foo` and update it
cargo update -p foo@ver  # selects a specific `foo` to update
cargo update -p foo --aggressive  # also update dependencies
cargo update -p foo --precise <ver>  # select a specific version
cargo update --locked  # fail if the lockfile will change

Note: cargo add --locked will also fail if the manifest will change

Some design tools we can consider include

  • Renaming a command, making the old name an alias
    • Even if there isn't a culture shift to use the new name, cargo <cmd> --help and cargo --list will point people to the new name
  • Versions without the build metadata field is a subset of version requirement syntax, we may be able to do some mixing of them
    • Precedence: using the same foo@ver syntax for versions and version requirements
  • Minimal-version resolution being the default mode would make Cargo.lock mostly align with Cargo.toml, making it easier to conflate the two commands (whether merging them or keeping separate but de-emphasizing update)

Interjection

Through this, I realized that the core of my concern with our previous attempts at a single command is that it feels like we are shoehorning new behaviors into cargo update rather than making the behavior cohesive.

  • If I see a cargo update --incompatible on a blog post, can I predict what will happen if you do cargo update? No, because --save is needed to match behavior
  • We were trying to make --package be both for package IDs and dependency names to make some of the cargo upgrade workflows work
  • We were trying to overload --precise to allow control over version requirements

I also realized that my Windows Updates vs Windows Upgrades analogy for cargo update and cargo upgrade breaks down a little because cargo upgrade can do "upgrades" that are on the level of cargo update (say we call it cargo upgrade --compatible). The difference is in the target audience (yourself vs your dependents)

Proposal: cargo update only changes version requirements as a side effect

The primary role of cargo update has been to update your active dependencies (ie Cargo.lock). We do not plan to change that role but give the user control to force it to update in situations that were previously unsupported, particularly updating the Cargo.toml if need be.

Behavior:

  • By default, only "safe" updates are allowed (today's behavior)
  • cargo update --incompatible / cargo update -i will force (ie update version reqs) update unpinned, incompatible versions (no other dependencies)
    • Yes, this could potentially be called --breaking or something else. The name depends on what expresses the concept clearly especially in light of any pinning behavior we have
  • cargo update -p foo --precise ver will force the update to happen, only erroring if we can't (don't own relevant version reqs, is pinned), even if its incompatible but unpinned. Version requirement is only changed on incompatible.
    • Maybe the error on pinned could be relaxed

Somewhere between deferred and rejected (speaking for myself): Support in cargo update for writing to the manifest for non-breaking changes, like bulk compatible upgrades of version requirements (ie a -save flag) which was one of our lower priority workflows. A --save flag is more about updating versions for your dependents, which while important for having valid lower-bounds on version requirements, doesn't fit with the existing model of cargo update. Maybe in the future we can find a way to express this in cargo update that fits with how it works or maybe another command can take on this role. We just aren't wanting to distract our efforts for handling most of the use cases to handle this one

While this tells a cohesive story, a part of me is somewhat concerned that this goes beyond the name update.

Potential related cargo update improvements

  • "Rename" --aggressive to --recursive (make --aggressive an alias for the new name)
  • Specialize the parsing of foo@x.y.z so that foo@x will work so long as its unambiguous (which it should be), much like cargo install foo@x.y.z supports auto-selecting the y and z fields (picks latest)
  • Consider applying some of the output formatting from cargo upgrade into cargo update
  • Add a positional package argument to cargo update, removing the need for --package
    • Should be safe because there are no trailing var args for a wrapped child process and no (and little chance of) flags that accept 0..=1 values

Alternatives

These are alternatives I had considered that help give an idea of what I mean by fitting into cargo.

cargo update always modifies Cargo.toml

  • This would be a breaking change
  • This would get in the way of people intentionally keeping separate versions from version requirements

We deprecate cargo update and a cargo upgrade always updates both files

  • This would get in the way of people intentionally keeping separate versions from version requirements

We migrate to minimal-version resolution by default

  • cargo update becomes less useful and we move it out of the spot light.
  • A cargo upgrade is added that is focused on editing version requirements

Separate commands for Cargo.lock (update) and Cargo.toml (upgrade)

  • Names don't clarify the role each fills
  • Much like Debian has apt dist-upgrade, maybe it could be cargo req-update?

Misc Notes

  • I don't see us making a distinction between default operator and ^ as we document them as being the same thing and sometimes people use ^ just because
  • We are erring on the side of needing cargo update && cargo update --incompatible vs cargo update --incompatible doing both as there are people who do want them separated and running two commands, while annoying, allows us to cover more use cases.
1 Like

FWIW, I do still care about this use case and use it regularly, and I would like to find a way to fit it into cargo. I'd be sad if we don't get this added as well. But I'm inclined to prioritize integrating cargo update -i, and if deferring this makes that possible sooner, I'm willing to defer it.