pmacs/src/packages/resolver.rs

1559 lines
59 KiB
Rust

// packages/resolver.rs --- Dependency resolver (T M7.5).
//! Take a top-level set of `(address, pin)` pairs and produce a flat
//! plan: one chosen `(commit, version, manifest)` per package, ordered
//! so dependencies precede dependents.
//!
//! This module is the spine of the M7.5 → M7.6 → M7.7 chain: M7.6 turns
//! a `ResolvePlan` into a `pmacs.lock` file, M7.7 walks a plan to wire
//! installs into Lua's `require`. The resolver itself is Rust-only;
//! Lua exposure waits for M7.7+ when the lockfile feedback loop is
//! settled.
//!
//! # Algorithm
//!
//! Iterate-to-fixed-point with **monotonic-version-decrease**. Each
//! iteration either lowers some chosen package's version (because a
//! new constraint excluded the prior choice) or terminates.
//!
//! ## Why this terminates
//!
//! Constraints on a package name are **additive**: once a constraint
//! is recorded, it stays in the constraint set even if the manifest
//! that introduced it is later replaced. Adding constraints can only
//! narrow the satisfying-tag set; the resolver always picks the
//! highest tag in the satisfying set. Since the per-package version
//! set is finite, each chosen-version change strictly decreases some
//! version, and the sum of chosen versions across all packages is a
//! monotonically-decreasing well-founded measure. Termination is
//! guaranteed.
//!
//! ## Tradeoff: stale constraints can over-constrain
//!
//! Additive accumulation means a constraint introduced by a manifest
//! that has since been replaced still applies. In a constraint cycle
//! where two packages alternately push each other down, the algorithm
//! collects both sets of constraints and may report an unsatisfiable
//! conflict on a graph that a backtracking SAT solver would resolve.
//! That's a deliberate tradeoff: the resolver stays simple,
//! comprehensible, and predictable; pathological cycles surface as
//! clear errors rather than silent oscillation. v1.0 doesn't ship a
//! SAT solver; v2.0 may revisit if real-world graphs surface false
//! positives.
//!
//! # Two-pass version filtering
//!
//! For version-pinned packages (top-level or transitive), the
//! resolver picks the highest tag that satisfies **both** the user /
//! transitive constraint **and** the manifest's `pmacs_required`
//! against the running pmacs version. The two filters surface as
//! distinct errors so users can tell "loosen your constraint" from
//! "upgrade pmacs" apart:
//!
//! - [`ResolveError::NoVersionMatchesConstraints`] — no tag matches
//! the version constraint set.
//! - [`ResolveError::NoVersionMatchesPmacsRequirement`] — tags match
//! the version constraints, but none of them are pmacs-compatible.
//!
//! Manifest fetches are lazy: tags are walked highest-first, and the
//! first tag whose manifest passes both filters wins. The common
//! case (latest tag is compatible) costs one manifest fetch.
//!
//! # Determinism
//!
//! All iteration order goes through `BTreeMap` / sorted vectors. Tag
//! selection is `Version::cmp` descending. Two resolves of the same
//! input produce byte-identical plans modulo upstream Git state.
// Resolver errors carry rich diagnostic context (constraint paths,
// available tag lists, conflict declarations) by design — the user-
// facing `Display` is the entire reason this enum is wide. Boxing the
// error to placate `result_large_err` would only move the cost
// without changing the user-visible surface, so the lint is allowed
// for this module.
#![allow(clippy::result_large_err)]
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt::Write as _;
use std::path::PathBuf;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::address::{Address, AddressError};
use super::fetcher::{FetchError, Fetcher, RefSpec};
use super::installer::{InstallPin, package_basename, running_pmacs_version};
use super::lockfile::{Lockfile, LockfileError, UpdatePolicy};
use super::manifest::{ManifestError, PackageManifest, PackageName};
// ---------------------------------------------------------------------------
// Public input types
// ---------------------------------------------------------------------------
/// One top-level entry to feed the resolver.
#[derive(Debug, Clone)]
pub struct ResolveRequest {
/// Where to fetch the package from.
pub address: Address,
/// How the user pinned the install: by version constraint, branch
/// name, or specific commit.
pub pin: InstallPin,
}
// ---------------------------------------------------------------------------
// Public output types
// ---------------------------------------------------------------------------
/// Where a recorded constraint came from. Used to format conflict
/// errors so the user can see the chain of declarations that
/// produced the constraint set.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum Source {
/// A top-level user request (passed directly to
/// [`Resolver::resolve`]).
TopLevel,
/// A `dependencies` entry inside another package's manifest.
DependencyOf {
/// The package whose manifest declared this dependency.
name: PackageName,
/// The version of that package at which the dependency was
/// observed.
version: Version,
},
}
impl Source {
fn display(&self) -> String {
match self {
Self::TopLevel => "(top-level)".to_string(),
Self::DependencyOf { name, version } => format!("{} @ {}", name.as_str(), version),
}
}
}
/// One entry in a successful resolve.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedPackage {
/// Canonical package name from the resolved manifest.
pub name: PackageName,
/// Address used to fetch this package. For transitive deps this
/// is whatever string the parent manifest recorded.
pub address: Address,
/// The commit-ish this package resolves to: a 40-char SHA, or a
/// tag/branch name (the resolver deliberately works against
/// commit-ishes and defers concrete-SHA resolution to the installer /
/// lockfile — see `TagCandidate::commit_for_tag`). **Not guaranteed to
/// be a SHA**; do not treat it as immutable. Renamed from `commit`
/// (audit F-011), whose name falsely promised a hash.
pub revision: String,
/// Version parsed from the manifest at `revision`.
pub version: Version,
/// Manifest at `revision`, captured during resolution.
pub manifest: PackageManifest,
/// `Some(pin)` if this entry came directly from a top-level
/// [`ResolveRequest`]; `None` if it was pulled in transitively
/// via another package's `dependencies`. M7.6's lockfile uses
/// this to know which entries should re-resolve as branches on
/// `update`: top-level branch pins move with upstream, but
/// transitive deps are derived from manifests and don't carry
/// branch semantics.
pub top_level_pin: Option<InstallPin>,
}
/// Output of a successful resolve.
///
/// `packages` is in **topological order**: every entry's manifest
/// `dependencies` reference packages that appear earlier in the
/// vector. M7.6 / M7.7 walk it in order to install / load.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvePlan {
/// Packages in topological order.
pub packages: Vec<ResolvedPackage>,
}
// ---------------------------------------------------------------------------
// Resolver
// ---------------------------------------------------------------------------
/// The resolver itself. Borrows a [`Fetcher`] for the duration of a
/// resolve.
#[derive(Debug)]
pub struct Resolver {
fetcher: Fetcher,
}
impl Resolver {
/// Construct a resolver over the given fetcher's cache.
#[must_use]
pub fn new(fetcher: Fetcher) -> Self {
Self { fetcher }
}
/// Borrow the underlying fetcher (useful for callers that want to
/// reuse the same cache for downstream installs).
#[must_use]
pub fn fetcher(&self) -> &Fetcher {
&self.fetcher
}
/// Resolve a top-level request set into a flat install plan.
///
/// See module-level docs for the algorithm and termination
/// argument.
pub fn resolve(&self, requests: &[ResolveRequest]) -> Result<ResolvePlan, ResolveError> {
let pmacs_version = running_pmacs_version();
let mut state = ResolverState::new(&self.fetcher, &pmacs_version);
for req in requests {
state.add_top_level(req)?;
}
state.iterate_to_fixpoint()?;
state.check_conflicts()?;
state.into_plan()
}
/// Resolve under an explicit lockfile / [`UpdatePolicy`].
///
/// - [`UpdatePolicy::Frozen`] short-circuits to
/// [`Lockfile::to_resolve_plan`] — no upstream re-resolution. The
/// `requests` set must be a subset of the lockfile by canonical
/// URL; entries in `requests` not present in the lockfile produce
/// [`ResolveError::FrozenRequestMissing`] (the user must update
/// the lockfile or remove the new entry).
/// - [`UpdatePolicy::UpdateAll`] ignores the lockfile and delegates
/// to [`Resolver::resolve`].
/// - [`UpdatePolicy::UpdateOne`] runs a fresh resolve with the
/// lockfile's recorded versions as **preferences** for every
/// package except the named target. A preferred version is
/// chosen if it satisfies the current constraint set; otherwise
/// the resolver falls back to highest-version selection
/// (cascade behavior).
///
/// `lockfile` is required for `Frozen` and `UpdateOne`. Passing
/// `None` with either policy returns
/// [`ResolveError::LockfileRequired`].
pub fn resolve_with_policy(
&self,
requests: &[ResolveRequest],
lockfile: Option<&Lockfile>,
policy: &UpdatePolicy,
) -> Result<ResolvePlan, ResolveError> {
match (policy, lockfile) {
(UpdatePolicy::UpdateAll, _) => self.resolve(requests),
(UpdatePolicy::Frozen, Some(lock)) => {
// Validate that every top-level request appears in the lockfile.
for req in requests {
let url = req.address.to_git_url();
if lock.entry_by_url(&url).is_none() {
return Err(ResolveError::FrozenRequestMissing {
address: req.address.clone(),
});
}
}
lock.to_resolve_plan(&self.fetcher)
.map_err(ResolveError::Lockfile)
}
(UpdatePolicy::UpdateOne(name), Some(lock)) => {
if lock.entry(name).is_none() {
return Err(ResolveError::Lockfile(LockfileError::UpdateOneMissing {
name: name.clone(),
}));
}
let mut prefer: BTreeMap<String, Version> = BTreeMap::new();
for entry in &lock.packages {
if &entry.name == name {
continue;
}
prefer.insert(entry.url.clone(), entry.version.clone());
}
let pmacs_version = running_pmacs_version();
let mut state = ResolverState::new(&self.fetcher, &pmacs_version);
state.prefer_versions = prefer;
for req in requests {
state.add_top_level(req)?;
}
state.iterate_to_fixpoint()?;
state.check_conflicts()?;
state.into_plan()
}
(UpdatePolicy::Frozen | UpdatePolicy::UpdateOne(_), None) => {
Err(ResolveError::LockfileRequired)
}
}
}
}
// ---------------------------------------------------------------------------
// ResolverState (internal)
// ---------------------------------------------------------------------------
/// All mutable state for a single `resolve()` invocation.
struct ResolverState<'a> {
fetcher: &'a Fetcher,
pmacs_version: &'a Version,
/// Each package by canonical name plus its current resolution.
chosen: BTreeMap<PackageName, ChosenEntry>,
/// Constraints accumulated across the resolve. **Additive**:
/// constraints stay in this map even when the manifest that
/// introduced them is replaced. See module docs for why.
constraints: BTreeMap<PackageName, Vec<(Source, VersionReq)>>,
/// Conflict declarations stashed during resolution; checked once
/// after fixed-point convergence. Like `constraints`, this is
/// additive — declarations from replaced manifests still count.
conflicts: Vec<ConflictDecl>,
/// Repo cache: address-string → on-disk fetched repo path.
/// Keyed by the post-`Address::to_git_url()` clone URL so
/// equivalent address spellings (`github:foo/bar` vs.
/// `https://github.com/foo/bar.git`) share fetched data.
repo_paths: BTreeMap<String, PathBuf>,
/// Manifest cache: `(git_url, commit)` → manifest. Avoids
/// re-running `git show` for the same blob; load-bearing on
/// diamond-dependency graphs where multiple constraint paths
/// reach the same commit.
manifests: BTreeMap<(String, String), PackageManifest>,
/// Pending dep edges discovered but not yet processed.
pending: VecDeque<PendingEdge>,
/// Lockfile-supplied version hints. When `pick_best_version` finds
/// a candidate matching the hinted version (and the candidate
/// satisfies the constraint set), it is preferred over the
/// highest-version candidate. Empty for fresh resolves; populated
/// from the lockfile for [`UpdatePolicy::UpdateOne`]. Keyed by
/// canonical clone URL because the resolver learns canonical names
/// only after picking a version.
prefer_versions: BTreeMap<String, Version>,
}
/// One pending dependency edge waiting to be merged into the chosen
/// set. Carries everything needed to resolve a name without
/// re-touching its parent manifest.
#[derive(Debug, Clone)]
struct PendingEdge {
/// Address-string from the parent's `dependencies` entry.
/// Re-parsed lazily (the manifest stored it as a `String`).
address: String,
/// Constraint contributed by the parent manifest.
constraint: VersionReq,
/// Which manifest declared this constraint (for error attribution).
source: Source,
}
#[derive(Debug, Clone)]
struct ChosenEntry {
address: Address,
commit: String,
version: Version,
manifest: PackageManifest,
top_level_pin: Option<InstallPin>,
}
#[derive(Debug, Clone)]
struct ConflictDecl {
declaring_name: PackageName,
declaring_version: Version,
target_address: String,
target_constraint: VersionReq,
}
impl<'a> ResolverState<'a> {
fn new(fetcher: &'a Fetcher, pmacs_version: &'a Version) -> Self {
Self {
fetcher,
pmacs_version,
chosen: BTreeMap::new(),
constraints: BTreeMap::new(),
conflicts: Vec::new(),
repo_paths: BTreeMap::new(),
manifests: BTreeMap::new(),
pending: VecDeque::new(),
prefer_versions: BTreeMap::new(),
}
}
// -- Top-level seeding ----------------------------------------------
fn add_top_level(&mut self, req: &ResolveRequest) -> Result<(), ResolveError> {
let url = req.address.to_git_url();
self.ensure_repo(&url)?;
let repo = self.repo_paths[&url].clone();
let (commit, manifest) = match &req.pin {
InstallPin::Version(constraint) => {
let candidates = self.list_tags(&repo, &url, &Source::TopLevel, &req.address)?;
let chosen = self.pick_best_version(
&repo,
&url,
&req.address,
&candidates,
&[(Source::TopLevel, constraint.clone())],
)?;
let manifest = self.read_manifest(&url, &repo, &chosen.commit)?.clone();
(chosen.commit, manifest)
}
InstallPin::Branch(b) => {
let commit = self
.fetcher
.resolve(&repo, &RefSpec::Branch(b.clone()))
.map_err(|source| ResolveError::Fetch {
address: req.address.clone(),
source,
})?;
let manifest = self.read_manifest(&url, &repo, &commit)?.clone();
self.check_pinned_pmacs_required(&req.address, &manifest, &commit, "branch", b)?;
(commit, manifest)
}
InstallPin::Commit(c) => {
let commit = self
.fetcher
.resolve(&repo, &RefSpec::Commit(c.clone()))
.map_err(|source| ResolveError::Fetch {
address: req.address.clone(),
source,
})?;
let manifest = self.read_manifest(&url, &repo, &commit)?.clone();
self.check_pinned_pmacs_required(&req.address, &manifest, &commit, "commit", c)?;
(commit, manifest)
}
// Local pins are ephemeral working-tree symlinks
// installed via install_local (T M8.1c). They never go
// through the resolver because they have no clone URL
// and no resolve story --- the source path is the
// canonical bytes. add_top_level is unreachable from
// any caller that admits Local pins.
InstallPin::Local { .. } => return Err(ResolveError::LocalPinNotResolvable),
};
let name = manifest.name.clone();
let version = manifest.version.clone();
if self.chosen.contains_key(&name) {
return Err(ResolveError::DuplicateTopLevel { name });
}
if let InstallPin::Version(constraint) = &req.pin {
self.constraints
.entry(name.clone())
.or_default()
.push((Source::TopLevel, constraint.clone()));
}
self.enqueue_dependencies(&name, &version, &manifest);
self.stash_conflicts(&name, &version, &manifest);
self.chosen.insert(
name,
ChosenEntry {
address: req.address.clone(),
commit,
version,
manifest,
top_level_pin: Some(req.pin.clone()),
},
);
Ok(())
}
// -- Iteration ------------------------------------------------------
fn iterate_to_fixpoint(&mut self) -> Result<(), ResolveError> {
// Termination invariant: each iteration either drains a
// pending edge (finite supply, monotonically grows only when
// a chosen version *decreases*) or strictly lowers some
// chosen package's version. Both measures are well-founded.
loop {
while let Some(edge) = self.pending.pop_front() {
self.process_pending(&edge)?;
}
let names: Vec<PackageName> = self.chosen.keys().cloned().collect();
let mut changed = false;
for name in names {
if self.recheck_chosen(&name)? {
changed = true;
}
}
if !changed && self.pending.is_empty() {
break;
}
}
Ok(())
}
fn process_pending(&mut self, edge: &PendingEdge) -> Result<(), ResolveError> {
let address =
Address::parse(&edge.address).map_err(|source| ResolveError::AddressInDependency {
address: edge.address.clone(),
source,
declared_by: edge.source.clone(),
})?;
let url = address.to_git_url();
self.ensure_repo(&url)?;
// Reuse any existing chosen entry whose URL matches: the dep
// edge may simply be a re-statement of a constraint we
// already know, or a new constraint on an already-chosen
// package. Either way we go through `recheck_chosen` once we
// know the canonical name.
//
// Determining the name requires reading a manifest. Pick the
// best-version tag under the *new* constraint alone first,
// read its manifest to learn the name, then merge with
// existing constraints + recheck.
let repo = self.repo_paths[&url].clone();
let candidates = self.list_tags(&repo, &url, &edge.source, &address)?;
let probe = self.pick_best_version(
&repo,
&url,
&address,
&candidates,
&[(edge.source.clone(), edge.constraint.clone())],
)?;
let probe_manifest = self.read_manifest(&url, &repo, &probe.commit)?.clone();
let name = probe_manifest.name.clone();
// Record the constraint under the canonical name.
self.constraints
.entry(name.clone())
.or_default()
.push((edge.source.clone(), edge.constraint.clone()));
if self.chosen.contains_key(&name) {
// Already chosen: re-resolve under the now-larger
// constraint set. `recheck_chosen` handles the
// monotonic-decrease step.
self.recheck_chosen(&name)?;
} else {
// First time seeing this name: install the probe choice.
// It already satisfies the new constraint and pmacs_required.
let version = probe_manifest.version.clone();
self.enqueue_dependencies(&name, &version, &probe_manifest);
self.stash_conflicts(&name, &version, &probe_manifest);
self.chosen.insert(
name,
ChosenEntry {
address,
commit: probe.commit,
version,
manifest: probe_manifest,
top_level_pin: None,
},
);
}
Ok(())
}
/// Re-resolve `name` under its current constraint set. If the
/// best-version drops below the currently-chosen version, replace
/// the chosen entry, enqueue the new manifest's deps, and stash
/// its conflicts. Returns true if anything changed.
fn recheck_chosen(&mut self, name: &PackageName) -> Result<bool, ResolveError> {
let entry = match self.chosen.get(name) {
Some(e) => e.clone(),
None => return Ok(false),
};
// Top-level branch / commit pins are not subject to
// constraint-driven re-resolution: the user fixed the
// revision explicitly. New transitive constraints either
// already match or they conflict — the conflict surfaces in
// `check_conflicts` (for manifest conflicts) or in the
// version-mismatch check below.
if let Some(InstallPin::Branch(_) | InstallPin::Commit(_)) = &entry.top_level_pin {
// Validate that all current constraints still match the
// pinned version. If not, surface a typed error so the
// user knows the explicit pin disagrees with a transitive
// constraint.
let constraints = self.constraints.get(name).map_or(&[][..], Vec::as_slice);
for (source, req) in constraints {
if matches!(source, Source::TopLevel) {
continue;
}
if !req.matches(&entry.version) {
return Err(ResolveError::PinnedRevisionViolatesConstraint {
name: name.clone(),
version: entry.version.clone(),
via: source.clone(),
constraint: req.clone(),
});
}
}
return Ok(false);
}
let url = entry.address.to_git_url();
let repo = self.repo_paths[&url].clone();
let candidates = self.list_tags(&repo, &url, &Source::TopLevel, &entry.address)?;
// Use *all* current constraints on this name (additive).
let constraints = self.constraints.get(name).cloned().unwrap_or_default();
let constraints = if constraints.is_empty() {
// Top-level Version pin only — fall back to "any" so we
// pick the highest tag that's pmacs-compatible.
vec![(Source::TopLevel, VersionReq::STAR)]
} else {
constraints
};
let chosen =
self.pick_best_version(&repo, &url, &entry.address, &candidates, &constraints)?;
if chosen.version >= entry.version {
// No-op: re-resolution picked the same or higher version.
// (Higher would only happen via "no constraint change"
// re-checks; we guard with >= so the loop is stable.)
return Ok(false);
}
// Strictly lower: update chosen, enqueue the new manifest's
// deps, stash its conflicts.
let new_manifest = self.read_manifest(&url, &repo, &chosen.commit)?.clone();
let new_version = new_manifest.version.clone();
self.enqueue_dependencies(name, &new_version, &new_manifest);
self.stash_conflicts(name, &new_version, &new_manifest);
self.chosen.insert(
name.clone(),
ChosenEntry {
address: entry.address.clone(),
commit: chosen.commit,
version: new_version,
manifest: new_manifest,
top_level_pin: entry.top_level_pin.clone(),
},
);
Ok(true)
}
// -- Conflict checking ---------------------------------------------
fn check_conflicts(&self) -> Result<(), ResolveError> {
// Build name → version index from chosen.
let chosen_versions: BTreeMap<&PackageName, &Version> =
self.chosen.iter().map(|(n, e)| (n, &e.version)).collect();
// Map address → name (canonical). Useful for resolving each
// conflict declaration's target_address to a chosen name.
let mut url_to_name: BTreeMap<String, &PackageName> = BTreeMap::new();
for (name, entry) in &self.chosen {
url_to_name.insert(entry.address.to_git_url(), name);
}
for decl in &self.conflicts {
// Prefer the declaring entry's *current* version: a
// conflict declaration from a no-longer-chosen version is
// stale (additive accumulation tradeoff). Skip if the
// declaring (name, version) pair isn't currently chosen.
match self.chosen.get(&decl.declaring_name) {
Some(entry) if entry.version == decl.declaring_version => {}
_ => continue,
}
let target_url = match Address::parse(&decl.target_address) {
Ok(a) => a.to_git_url(),
Err(_) => continue, // Malformed conflict address — silently skip.
};
let Some(target_name) = url_to_name.get(&target_url) else {
continue;
};
let Some(target_version) = chosen_versions.get(target_name) else {
continue;
};
if decl.target_constraint.matches(target_version) {
return Err(ResolveError::ManifestConflict {
declaring_name: decl.declaring_name.clone(),
declaring_version: decl.declaring_version.clone(),
target_name: (*target_name).clone(),
target_version: (*target_version).clone(),
target_constraint: decl.target_constraint.clone(),
});
}
}
Ok(())
}
// -- Plan emission --------------------------------------------------
fn into_plan(self) -> Result<ResolvePlan, ResolveError> {
// Build a name → entry map plus name → dep-name set for topo
// sort. Determinism: BTreeMap iteration + dep-name BTreeSet.
let entries = self.chosen;
// Map address-url → name once so each manifest's deps can
// resolve to chosen names.
let mut url_to_name: BTreeMap<String, PackageName> = BTreeMap::new();
for (name, entry) in &entries {
url_to_name.insert(entry.address.to_git_url(), name.clone());
}
let mut deps_of: BTreeMap<PackageName, BTreeSet<PackageName>> = BTreeMap::new();
for (name, entry) in &entries {
let mut set: BTreeSet<PackageName> = BTreeSet::new();
for dep in &entry.manifest.dependencies {
let dep_url = match Address::parse(&dep.address) {
Ok(a) => a.to_git_url(),
Err(_) => continue,
};
if let Some(dep_name) = url_to_name.get(&dep_url) {
set.insert(dep_name.clone());
}
}
deps_of.insert(name.clone(), set);
}
// Kahn's topological sort, dependencies before dependents.
// Edge direction: "X depends on Y" is X → Y, and Y must be
// emitted before X. Track each node's *outgoing* pending edges —
// `indegree[X]` = the number of X's deps not yet emitted, seeded
// to `|deps_of[X]|` — and when a node Y is emitted, decrement
// every X that depends on Y; emit X once its count reaches 0.
// `BTreeMap` / `BTreeSet` keep tie-breaking deterministic
// (alphabetical by package name).
let mut indegree: BTreeMap<PackageName, usize> = deps_of
.iter()
.map(|(n, deps)| (n.clone(), deps.len()))
.collect();
// Reverse adjacency: dependents[Y] = set of X where X depends on Y.
let mut dependents: BTreeMap<PackageName, BTreeSet<PackageName>> = BTreeMap::new();
for (x, deps) in &deps_of {
for y in deps {
dependents.entry(y.clone()).or_default().insert(x.clone());
}
}
let mut ready: BTreeSet<PackageName> = indegree
.iter()
.filter(|&(_, d)| *d == 0)
.map(|(n, _)| n.clone())
.collect();
let mut order: Vec<PackageName> = Vec::with_capacity(entries.len());
while let Some(next) = ready.iter().next().cloned() {
ready.remove(&next);
order.push(next.clone());
if let Some(succs) = dependents.get(&next) {
for x in succs {
if let Some(d) = indegree.get_mut(x) {
*d = d.saturating_sub(1);
if *d == 0 {
ready.insert(x.clone());
}
}
}
}
}
if order.len() != entries.len() {
// Should be impossible given monotonic-version-decrease
// termination, but guarded for safety: a cycle in the
// dependency graph after fixed-point. Surface as a typed
// error rather than panic.
let cycle: Vec<PackageName> = entries
.keys()
.filter(|n| !order.contains(n))
.cloned()
.collect();
return Err(ResolveError::DependencyCycle {
participants: cycle,
});
}
let mut entries = entries;
let packages: Vec<ResolvedPackage> = order
.into_iter()
.map(|name| {
let entry = entries.remove(&name).expect("name in order is in entries");
ResolvedPackage {
name,
address: entry.address,
revision: entry.commit,
version: entry.version,
manifest: entry.manifest,
top_level_pin: entry.top_level_pin,
}
})
.collect();
// Reject distinct packages that share an install basename (F-005).
// The plan is the one place holding every resolved name at once;
// installing by basename would otherwise let two of them (e.g.
// `owner/magit` and `other/magit`) collide on disk, most-recent
// winning. (This does not touch the loader's *intended* cross-scope
// override, which shares a basename across separate installs.)
if let Some((basename, names)) =
find_basename_collision(packages.iter().map(|p| p.name.clone()))
{
return Err(ResolveError::BasenameCollision { basename, names });
}
Ok(ResolvePlan { packages })
}
// -- Helpers --------------------------------------------------------
fn ensure_repo(&mut self, url: &str) -> Result<(), ResolveError> {
if self.repo_paths.contains_key(url) {
return Ok(());
}
let path = self
.fetcher
.fetch(url)
.map_err(|source| ResolveError::Fetch {
address: Address::Url(url.to_string()),
source,
})?;
self.repo_paths.insert(url.to_string(), path);
Ok(())
}
fn list_tags(
&self,
repo: &std::path::Path,
url: &str,
source: &Source,
address: &Address,
) -> Result<Vec<TagCandidate>, ResolveError> {
let raw = self
.fetcher
.list_tags(repo)
.map_err(|source_err| ResolveError::Fetch {
address: address.clone(),
source: source_err,
})?;
// Filter to parseable semver tags and sort descending.
// Tags that don't parse as semver are dropped silently —
// pmacs's semver discipline says the user's version
// constraint matches against semver-shaped tags only. The
// installer applies the same convention.
let _ = url;
let _ = source;
let mut tagged: Vec<TagCandidate> = raw
.into_iter()
.filter_map(|t| {
let v = parse_tag_as_version(&t)?;
Some(TagCandidate { tag: t, version: v })
})
.collect();
tagged.sort_by(|a, b| b.version.cmp(&a.version));
Ok(tagged)
}
fn pick_best_version(
&mut self,
repo: &std::path::Path,
url: &str,
address: &Address,
candidates: &[TagCandidate],
constraints: &[(Source, VersionReq)],
) -> Result<ChosenTag, ResolveError> {
if candidates.is_empty() {
return Err(ResolveError::NoTagsAvailable {
address: address.clone(),
sources: constraints
.iter()
.map(|(s, r)| (s.clone(), r.clone()))
.collect(),
});
}
// Phase 1: filter by constraint intersection. This eliminates
// tags that can't satisfy any version constraint regardless
// of pmacs_required, so we don't fetch their manifests.
let after_user: Vec<&TagCandidate> = candidates
.iter()
.filter(|c| constraints.iter().all(|(_, req)| req.matches(&c.version)))
.collect();
if after_user.is_empty() {
return Err(ResolveError::NoVersionMatchesConstraints {
address: address.clone(),
constraints: constraints.to_vec(),
available: candidates.iter().map(|c| c.tag.clone()).collect(),
});
}
// Phase 2a: lockfile hint. If a hint version is in the
// user-filtered candidate set and its manifest is
// pmacs-compatible, prefer it over the highest-version
// selection. This is the `UpdatePolicy::UpdateOne` cascade
// brake — non-target packages stay at lockfile versions
// unless current constraints force them to move.
if let Some(hint) = self.prefer_versions.get(url).cloned()
&& let Some(cand) = after_user.iter().find(|c| c.version == hint)
{
let manifest = self
.read_manifest(url, repo, &cand.commit_for_tag())?
.clone();
if manifest.pmacs_required.matches(self.pmacs_version) {
return Ok(ChosenTag {
tag: cand.tag.clone(),
commit: cand.commit_for_tag(),
version: cand.version.clone(),
});
}
// Hinted version is no longer pmacs-compatible (e.g.
// user upgraded pmacs and the locked version requires
// an older one). Fall through to highest-version
// selection.
}
// Phase 2b: walk highest-first, fetch manifest lazily, return
// first that satisfies pmacs_required. Lazy fetch matters: in
// the common case the latest tag is compatible and we fetch
// exactly one manifest.
let mut incompatible: Vec<(String, VersionReq)> = Vec::new();
for cand in after_user {
let manifest = self
.read_manifest(url, repo, &cand.commit_for_tag())?
.clone();
if manifest.pmacs_required.matches(self.pmacs_version) {
return Ok(ChosenTag {
tag: cand.tag.clone(),
commit: cand.commit_for_tag(),
version: cand.version.clone(),
});
}
incompatible.push((cand.tag.clone(), manifest.pmacs_required.clone()));
}
Err(ResolveError::NoVersionMatchesPmacsRequirement {
address: address.clone(),
running: self.pmacs_version.clone(),
constraints: constraints.to_vec(),
incompatible,
})
}
fn read_manifest(
&mut self,
url: &str,
repo: &std::path::Path,
commit: &str,
) -> Result<&PackageManifest, ResolveError> {
let key = (url.to_string(), commit.to_string());
if !self.manifests.contains_key(&key) {
let bytes = self
.fetcher
.show_blob(repo, commit, "pmacs.toml")
.map_err(|source| ResolveError::Fetch {
address: Address::Url(url.to_string()),
source,
})?;
let text = std::str::from_utf8(&bytes).map_err(|_| ResolveError::ManifestNotUtf8 {
url: url.to_string(),
commit: commit.to_string(),
})?;
let manifest =
PackageManifest::from_toml(text).map_err(|source| ResolveError::Manifest {
url: url.to_string(),
commit: commit.to_string(),
source,
})?;
self.manifests.insert(key.clone(), manifest);
}
Ok(&self.manifests[&key])
}
fn check_pinned_pmacs_required(
&self,
address: &Address,
manifest: &PackageManifest,
commit: &str,
pin_kind: &'static str,
pin_value: &str,
) -> Result<(), ResolveError> {
if manifest.pmacs_required.matches(self.pmacs_version) {
return Ok(());
}
Err(ResolveError::PinnedRevisionIncompatibleWithPmacs {
address: address.clone(),
commit: commit.to_string(),
pin_kind,
pin_value: pin_value.to_string(),
required: manifest.pmacs_required.clone(),
running: self.pmacs_version.clone(),
})
}
fn enqueue_dependencies(
&mut self,
name: &PackageName,
version: &Version,
manifest: &PackageManifest,
) {
for dep in &manifest.dependencies {
self.pending.push_back(PendingEdge {
address: dep.address.clone(),
constraint: dep.version.clone(),
source: Source::DependencyOf {
name: name.clone(),
version: version.clone(),
},
});
}
}
fn stash_conflicts(
&mut self,
name: &PackageName,
version: &Version,
manifest: &PackageManifest,
) {
for conflict in &manifest.conflicts {
self.conflicts.push(ConflictDecl {
declaring_name: name.clone(),
declaring_version: version.clone(),
target_address: conflict.address.clone(),
target_constraint: conflict.version.clone(),
});
}
}
}
// ---------------------------------------------------------------------------
// Internal value types
// ---------------------------------------------------------------------------
/// A semver-shaped tag that survived parsing, carrying its parsed
/// version. Tags are pre-sorted descending by version.
#[derive(Debug, Clone)]
struct TagCandidate {
tag: String,
version: Version,
}
impl TagCandidate {
/// Tags are commit-ish in git: `git show <tag>:path` works
/// uniformly. Resolution to a 40-char SHA is deferred until the
/// installer materializes the snapshot — the resolver works
/// against tags throughout, and the lockfile records the commit
/// the installer eventually materializes.
fn commit_for_tag(&self) -> String {
self.tag.clone()
}
}
#[derive(Debug, Clone)]
struct ChosenTag {
#[allow(dead_code)]
tag: String,
commit: String,
version: Version,
}
/// Parse a Git tag as a semver version. Tolerates a `v` prefix
/// (`v1.2.3` → `1.2.3`); rejects anything that isn't otherwise
/// valid semver.
fn parse_tag_as_version(tag: &str) -> Option<Version> {
let stripped = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(stripped).ok()
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors produced by [`Resolver::resolve`].
///
/// `Display` for each variant produces a self-contained, user-facing
/// message — read in a CI log without context, the user can see what
/// to do. Lua wrappers (M7.7+) surface these strings directly without
/// reformatting.
#[derive(Debug, Error)]
pub enum ResolveError {
/// Two top-level requests resolved to the same canonical name.
/// Pick one.
#[error(
"two top-level package requests resolved to the same name `{name}`. \
Each top-level address must produce a distinct package name; \
remove one of the duplicate requests.",
name = name.as_str(),
)]
DuplicateTopLevel {
/// The conflicting name.
name: PackageName,
},
/// A `ResolveRequest` carried [`InstallPin::Local`].
/// `install_local` (T M8.1c) installs working-tree symlinks and
/// never goes through the resolver; this error is a
/// programming-error safeguard for callers that build a request
/// from a roster entry without filtering Local pins.
#[error(
"InstallPin::Local cannot be resolved; use pmacs.packages.install_local \
for working-tree installs"
)]
LocalPinNotResolvable,
/// `dependencies[*].address` from a manifest failed to parse.
#[error(
"manifest of {declared_by} declared dependency on `{address}`, but the \
address failed to parse: {source}",
declared_by = source_display(declared_by),
)]
AddressInDependency {
/// The bad address string.
address: String,
/// Underlying parse error.
#[source]
source: AddressError,
/// Which manifest declared the dependency.
declared_by: Source,
},
/// Fetcher (clone, fetch, rev-parse, archive, list-tags) failed.
#[error("fetch error for {address_display}: {source}", address_display = address.to_git_url())]
Fetch {
/// Address that was being fetched.
address: Address,
/// Underlying fetch error.
#[source]
source: FetchError,
},
/// Reading or parsing `pmacs.toml` at a specific commit failed.
#[error("manifest at {url}@{commit}: {source}")]
Manifest {
/// Repository URL.
url: String,
/// Commit hash.
commit: String,
/// Parse / IO error.
#[source]
source: ManifestError,
},
/// `pmacs.toml` blob at the specified commit was not valid UTF-8.
/// Surfaced separately from [`Self::Manifest`] because the repair
/// is "fix the upstream commit," not "fix the manifest schema."
#[error(
"manifest at {url}@{commit}: pmacs.toml is not valid UTF-8 at this revision. \
Pin to a different revision or contact the upstream maintainer."
)]
ManifestNotUtf8 {
/// Repository URL.
url: String,
/// Commit hash.
commit: String,
},
/// The repository has no semver-shaped tags. Surfaced when the
/// resolver was asked to resolve a version constraint against an
/// empty tag set.
#[error(
"no semver-shaped tags published at {address_display}. {sources_msg} \
Either publish a tag, or pin to a branch (`branch = \"main\"`) \
or specific commit (`commit = \"<sha>\"`).",
address_display = address.to_git_url(),
sources_msg = format_constraint_paths(sources),
)]
NoTagsAvailable {
/// The address with no usable tags.
address: Address,
/// All constraint paths that asked for a version of this package.
sources: Vec<(Source, VersionReq)>,
},
/// Tags exist but none satisfies the union of version
/// constraints. Distinct from [`Self::NoVersionMatchesPmacsRequirement`]
/// so the user can tell "loosen your constraint" from "upgrade
/// pmacs" apart.
#[error(
"no version of {address_display} satisfies all constraints:\n{paths}\n\
available tags: {tags}",
address_display = address.to_git_url(),
paths = format_constraint_paths(constraints),
tags = format_tag_list(available),
)]
NoVersionMatchesConstraints {
/// The over-constrained address.
address: Address,
/// All constraint paths on this package.
constraints: Vec<(Source, VersionReq)>,
/// Tags available at the address (after semver-parse filter).
available: Vec<String>,
},
/// Tags satisfy the user / transitive constraints, but none of
/// them are compatible with the running pmacs version.
#[error(
"no version of {address_display} is compatible with pmacs {running}. \
{constraint_msg}\
versions matching version constraints but incompatible with pmacs:\n{incompat}",
address_display = address.to_git_url(),
constraint_msg = format_constraint_msg_when_present(constraints),
incompat = format_incompatible_list(incompatible),
)]
NoVersionMatchesPmacsRequirement {
/// The address with no pmacs-compatible version.
address: Address,
/// The running pmacs version.
running: Version,
/// Constraint set that the candidates satisfied.
constraints: Vec<(Source, VersionReq)>,
/// Each candidate that matched version constraints but had a
/// `pmacs_required` that excluded this pmacs.
incompatible: Vec<(String, VersionReq)>,
},
/// User pinned a top-level package to a specific branch or commit
/// whose manifest's `pmacs_required` excludes the running pmacs.
#[error(
"{address_display} pinned to {pin_kind} `{pin_value}` (commit {commit_short}) \
requires pmacs {required}, but this pmacs is {running}. \
Upgrade pmacs, or pin to a different {pin_kind}.",
address_display = address.to_git_url(),
commit_short = short_sha(commit),
)]
PinnedRevisionIncompatibleWithPmacs {
/// The pinned address.
address: Address,
/// The full commit the pin resolved to.
commit: String,
/// `"branch"` or `"commit"`.
pin_kind: &'static str,
/// The user's pin value.
pin_value: String,
/// The package's `pmacs_required`.
required: VersionReq,
/// The running pmacs version.
running: Version,
},
/// User pinned a package to a branch or commit, and a transitive
/// dependency in the resolved graph adds a constraint that the
/// pinned version doesn't satisfy.
#[error(
"{name} pinned to version {version}, but {via} requires `{constraint}`. \
Either change the pin, or update the dependent to accept this version.",
name = name.as_str(),
via = via.display(),
)]
PinnedRevisionViolatesConstraint {
/// The pinned package.
name: PackageName,
/// The pinned version.
version: Version,
/// Where the conflicting constraint came from.
via: Source,
/// The constraint the pinned version doesn't satisfy.
constraint: VersionReq,
},
/// A manifest's `conflicts` declaration matched a chosen package.
#[error(
"package conflict: {declaring_name} @ {declaring_version} declares conflict \
with {target_name}, and {target_name} resolved to {target_version} \
(matches conflict clause `{target_constraint}`). \
Drop one of these packages, or pin them to non-conflicting versions.",
declaring_name = declaring_name.as_str(),
target_name = target_name.as_str(),
)]
ManifestConflict {
/// Package whose manifest declared the conflict.
declaring_name: PackageName,
/// Version of that package at which the conflict was declared.
declaring_version: Version,
/// Package targeted by the conflict declaration.
target_name: PackageName,
/// Resolved version of the target package.
target_version: Version,
/// Constraint clause from the conflict declaration.
target_constraint: VersionReq,
},
/// Topological sort detected a cycle. Should be unreachable given
/// the additive monotonic-decrease termination, but guarded
/// rather than panicking.
#[error(
"dependency cycle among packages: {participants:?}. \
The resolver cannot order these packages for installation."
)]
DependencyCycle {
/// Participants in the cycle.
participants: Vec<PackageName>,
},
/// `resolve_with_policy` was called with [`UpdatePolicy::Frozen`]
/// but `requests` includes an address with no matching lockfile
/// entry.
#[error(
"package {address_display} is not in the lockfile. \
A frozen install cannot add new packages. Either run \
`pmacs.packages.update()` to regenerate the lockfile with this \
package included, or remove the package from your install list.",
address_display = address.to_git_url(),
)]
FrozenRequestMissing {
/// The address that has no lockfile entry.
address: Address,
},
/// `resolve_with_policy` was called with a policy that requires a
/// lockfile, but `lockfile` was `None`.
#[error(
"this update policy requires a lockfile, but none was provided. \
Either pass an existing lockfile, or use `UpdatePolicy::UpdateAll` \
for a fresh resolve."
)]
LockfileRequired,
/// A lockfile-aware path produced an underlying [`LockfileError`].
#[error("lockfile: {0}")]
Lockfile(#[from] LockfileError),
/// Two or more distinct packages in one resolve plan share an install
/// basename (audit F-005). pmacs installs each package under
/// `<install_root>/<basename>/` and routes `require` by basename, so
/// distinct packages with the same last `/`-segment (e.g. `owner/magit`
/// and `other/magit`) would overwrite each other on disk with the
/// most-recent install silently winning. Reject rather than collapse
/// them — a namespace-preserving layout is the real fix (deferred).
#[error(
"packages [{names}] share the install basename `{basename}`: pmacs \
installs and requires by basename, so these distinct packages would \
collide on disk. Rename one, or install them in separate roots.",
names = format_package_name_list(.names)
)]
BasenameCollision {
/// The colliding last-path-segment.
basename: String,
/// The distinct package names that map to it (sorted).
names: Vec<PackageName>,
},
}
/// Comma-join package names for a basename-collision message. Shared with
/// [`super::lockfile`], which guards the frozen/lockfile plan path.
pub(crate) fn format_package_name_list(names: &[PackageName]) -> String {
names
.iter()
.map(|n| n.as_str().to_string())
.collect::<Vec<_>>()
.join(", ")
}
/// Find an install basename shared by more than one distinct package name
/// (audit F-005). Returns the colliding basename and its names (sorted by
/// the `BTreeMap`/first-seen order), or `None` when every basename is
/// unique. Guards both plan-construction paths: `into_plan` (fresh /
/// `UpdateOne` resolves) and [`super::lockfile::Lockfile::to_resolve_plan`]
/// (the frozen / lockfile-derived path).
pub(crate) fn find_basename_collision(
names: impl Iterator<Item = PackageName>,
) -> Option<(String, Vec<PackageName>)> {
let mut by_basename: BTreeMap<String, Vec<PackageName>> = BTreeMap::new();
for name in names {
by_basename
.entry(package_basename(name.as_str()).to_string())
.or_default()
.push(name);
}
by_basename.into_iter().find(|(_, names)| names.len() > 1)
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
/// Format a constraint-source list as a bulleted block, one per line.
fn format_constraint_paths(constraints: &[(Source, VersionReq)]) -> String {
if constraints.is_empty() {
return String::new();
}
let mut out = String::new();
for (source, req) in constraints {
let _ = writeln!(out, " - required by {} as `{}`", source.display(), req);
}
// Trim trailing newline for cleaner Display interpolation.
if out.ends_with('\n') {
out.pop();
}
out
}
fn format_constraint_msg_when_present(constraints: &[(Source, VersionReq)]) -> String {
if constraints.is_empty() {
String::new()
} else {
format!(
"Constraints requiring this package:\n{}\n",
format_constraint_paths(constraints),
)
}
}
/// Format a tag list with truncation when long. Long tag lists make
/// error messages unreadable; truncating with the count preserves the
/// useful information.
const TAG_LIST_DISPLAY_CAP: usize = 10;
fn format_tag_list(tags: &[String]) -> String {
let total = tags.len();
if total == 0 {
return "(none)".to_string();
}
if total <= TAG_LIST_DISPLAY_CAP {
let body: Vec<String> = tags.iter().map(|t| format!("\"{t}\"")).collect();
return format!("[{}]", body.join(", "));
}
// Show the first few and the last few, plus the total count.
let show_each_end = TAG_LIST_DISPLAY_CAP / 2;
let head: Vec<String> = tags
.iter()
.take(show_each_end)
.map(|t| format!("\"{t}\""))
.collect();
let tail: Vec<String> = tags
.iter()
.skip(total - show_each_end)
.map(|t| format!("\"{t}\""))
.collect();
format!(
"[{}, ..., {}] ({total} total)",
head.join(", "),
tail.join(", "),
)
}
fn format_incompatible_list(incompatible: &[(String, VersionReq)]) -> String {
if incompatible.is_empty() {
return "(none)".to_string();
}
let mut out = String::new();
for (tag, required) in incompatible {
let _ = writeln!(out, " - {tag} requires pmacs `{required}`");
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn source_display(source: &Source) -> String {
source.display()
}
fn short_sha(commit: &str) -> &str {
commit.get(..7).unwrap_or(commit)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_tag_strips_v_prefix() {
let v = parse_tag_as_version("v1.2.3").expect("parse");
assert_eq!(v, Version::new(1, 2, 3));
}
#[test]
fn basename_collision_flags_distinct_same_basename_packages() {
let pn = |s: &str| PackageName::new(s).expect("valid name");
// `owner/magit` and `other/magit` both install as `magit` → collide.
let collision = find_basename_collision(
[pn("owner/magit"), pn("other/magit"), pn("ripgrep")].into_iter(),
);
let (basename, names) = collision.expect("collision detected");
assert_eq!(basename, "magit");
assert_eq!(names.len(), 2);
assert!(names.contains(&pn("owner/magit")) && names.contains(&pn("other/magit")));
// The message names both offenders.
let err = ResolveError::BasenameCollision { basename, names };
let msg = err.to_string();
assert!(
msg.contains("owner/magit") && msg.contains("other/magit"),
"{msg}"
);
}
#[test]
fn basename_collision_ignores_unique_and_same_name_repeats() {
let pn = |s: &str| PackageName::new(s).expect("valid name");
// Distinct basenames: no collision.
assert!(
find_basename_collision([pn("owner/magit"), pn("owner/forge")].into_iter()).is_none()
);
// A namespaced and a bare package with different basenames are fine.
assert!(find_basename_collision([pn("owner/magit"), pn("ripgrep")].into_iter()).is_none());
// The plan's names are already distinct (keyed by PackageName), so
// an empty or singleton set never collides.
assert!(find_basename_collision(std::iter::empty()).is_none());
assert!(find_basename_collision([pn("owner/magit")].into_iter()).is_none());
}
#[test]
fn parse_tag_accepts_bare_version() {
let v = parse_tag_as_version("1.2.3").expect("parse");
assert_eq!(v, Version::new(1, 2, 3));
}
#[test]
fn parse_tag_rejects_non_semver() {
// semver is strict: `1.2` (no patch) is not valid.
assert!(parse_tag_as_version("v1.2").is_none());
assert!(parse_tag_as_version("nightly").is_none());
assert!(parse_tag_as_version("v-foo").is_none());
}
#[test]
fn format_tag_list_under_cap_lists_all() {
let tags = vec![
"v1.0.0".to_string(),
"v1.1.0".to_string(),
"v2.0.0".to_string(),
];
let out = format_tag_list(&tags);
assert!(out.contains("v1.0.0"));
assert!(out.contains("v2.0.0"));
assert!(!out.contains("total"));
}
#[test]
fn format_tag_list_over_cap_truncates_with_count() {
let tags: Vec<String> = (0..47).map(|i| format!("v0.{i}.0")).collect();
let out = format_tag_list(&tags);
assert!(out.contains("47 total"));
assert!(out.contains("..."));
// First and last fragments present.
assert!(out.contains("v0.0.0"));
assert!(out.contains("v0.46.0"));
}
#[test]
fn format_tag_list_empty_says_none() {
assert_eq!(format_tag_list(&[]), "(none)");
}
#[test]
fn source_display_top_level() {
assert_eq!(Source::TopLevel.display(), "(top-level)");
}
#[test]
fn source_display_dependency_of() {
let s = Source::DependencyOf {
name: PackageName::new("foo").expect("name"),
version: Version::new(1, 2, 3),
};
assert_eq!(s.display(), "foo @ 1.2.3");
}
#[test]
fn short_sha_truncates_to_seven() {
assert_eq!(short_sha("0123456789abcdef"), "0123456");
assert_eq!(short_sha("abc"), "abc");
}
}