machdocs
Home Ecosystem Changelog

Dependencies

A mach project declares its dependencies in mach.toml and vendors them into a flat dep/ tree. A git dependency selects releases by version range, or one exact commit, tag, or branch by ref. mach dep resolves and realizes that tree, and mach build then verifies it offline, with no network.

Declaring a dependency

A dependency is named by its project id, and that one name appears in three places: the manifest key [dep.<id>], the directory dep/<id>/, and the head segment of every module path the dependency exposes (use <id>.x;). The compiler checks that all three agree, so dep/<id>/mach.toml must declare id = "<id>". A dependency whose id is the declaring project's own is refused where it is declared.

# in mach.toml
[dep.std]
git     = "https://github.com/briar-systems/mach-std"
version = "^5.3.0"                # any release from 5.3.0 up to, not including, 6.0.0

[dep.gfx]
git = "https://example.com/gfx"
ref = "tag/v1.4.0"                 # one exact tag

[dep.util]
path = "../util"                   # a local project tree, never fetched
KeyTypeMeaning
gitstringGit URL. The dependency is a git submodule at dep/<id>/, pinned by the gitlink the root repository commits. Requires version or ref.
versionstringA version range over the dependency's releases. Valid only with git.
refstringExact selector for git: branch/<name>, tag/<name>, or commit/<full-object-id>.
pathstringLocal project tree, resolved relative to this manifest's directory. Never fetched. Forbids version and ref.

A stanza names exactly one source, git or path. A git stanza also names exactly one selector, version or ref, and naming both is an error ([dep.std] names both 'ref' and 'version'; keep one). version selects among releases. ref selects one exact tag or commit, or follows a branch. Any other ref spelling is rejected: there are no bare names, short hashes, or empty refs.

A path dependency is copied into dep/<id>/, without the source's own dep/, its Git metadata, or its build output. When the source lives in a git work tree, only what git keeps is copied: tracked files and the untracked files its ignore rules keep. Copied files are not staged automatically.

Version ranges

[dep.<id>].version and the compiler range [project].mach share one range grammar:

range   = clause *( "," clause )        ; the intersection of every clause
clause  = op partial
op      = "^" / "~" / ">=" / ">" / "<=" / "<" / "="
partial = major [ "." minor [ "." patch [ "-" pre ] ] ]

A version satisfies a range when it satisfies every clause. Each clause names its operator, so a bare 1.2 is refused (a clause needs an operator, such as ^1.2 or >=1.2). Whitespace is allowed around , and between an operator and its version, and nowhere else. A missing component is 0.

ClauseMeans
^1.2.3>=1.2.3, <2.0.0
^1.2>=1.2.0, <2.0.0
^1>=1.0.0, <2.0.0
~1.2.3>=1.2.3, <1.3.0
~1.2>=1.2.0, <1.3.0
~1>=1.0.0, <2.0.0
>=1.2, >1.2, <=1.2, <2the bound with missing components as 0, so >1.2 is >1.2.0
=1.2.3exactly 1.2.3. = needs all three components.

Below 1.0 a minor release is breaking, so caret fixes everything up to the first nonzero component:

ClauseMeans
^0.4.2>=0.4.2, <0.5.0
^0.4>=0.4.0, <0.5.0
^0.0.3=0.0.3
^0.0>=0.0.0, <0.1.0
^0>=0.0.0, <1.0.0

A pre-release version (1.3.0-rc.1) satisfies a range only when one of its clauses names a pre-release of that same release. So ^1.2 never selects 1.3.0-rc.1, and >=1.3.0-rc.1, <2 does. Build metadata (+...) is refused in a range and ignored in a release's version. There is no * and no ||.

Releases and resolution

A release of a git dependency is a tag vX.Y.Z (or vX.Y.Z-pre) together with the mach.toml at that tag. A tag whose manifest's [project].version differs from the tag name is not a candidate.

Resolution runs in exactly three places: mach dep add, mach dep update, and mach dep outdated. Builds never resolve. They verify, offline. For every identity in the closure that some manifest selects by version, resolution picks one release such that:

  1. every requirer's range contains it,
  2. its own [project].mach contains the running compiler, and
  3. the closure its own manifest implies also resolves.

Among the choices that satisfy all three, it takes the highest release of each identity, and writes the result as gitlinks like any other pin. mach dep update <path> <name> keeps every other identity at its current release while that release still fits, so an update moves as little as it can. --all resolves from scratch. When nothing fits, the error lists every requirement that took part, including a release that needs a newer compiler, and names the identity the root can settle:

error: no set of releases satisfies every requirement:
    root requires b ^1.2
    a 1.0.0 requires b ^2.0
  the root decides by declaring the identity itself, for example:
    [dep.b]
    version = "<a range the root can use>"

Resolution never silently settles for a lower release than the ranges allow.

Root declarations: narrowing and overriding

A root version for an identity narrows: it is intersected with every requirer's range, and resolution and verification hold the pin to all of them. A root ref or path overrides: the requirers' ranges and selectors for that identity no longer apply, and add and update print each one they override. A range is never widened silently, and the escape hatch is one visible line in the root manifest.

Overrides are unchecked

Because the closure is flat, a requirer's use b.* binds to whatever the root selected, even a major that requirer was never built or tested against. A passing build only shows that the code it reached compiled. mach dep verify prints a note for every edge an override replaced, without failing. Treat each note as a claim to confirm.

A release selects only releases

A dependency reached through a release (a version range or an exact tag/) may itself select dependencies only by version or tag/, so a release is reproducible from its tag all the way down. A dependency reached through branch/ or commit/ is in development, and its manifest may use any selector. A release that breaks the rule is refused wherever it is reached, naming the chain and the offending line, unless the root declares the offending dependency itself. mach dep verify <path> --release holds the project itself to the same rule, so a library's release workflow catches the mistake before it tags.

The mach dep command

mach dep <action> <path> manages the tree under dep/. Every action takes the project path first and accepts --quiet / -q.

ActionEffect
add <path> <name>Declare a dependency and realize the tree. Takes --git <url> with an optional --version <range> or --ref <ref>, or --path <dir>. Without a selector it writes the caret range of the highest compatible release.
update <path> (<name> | --all)The only command that moves a pin. Refreshes path copies, advances branch refs to their remote tips, moves exact refs to the commit they name, and resolves version ranges. Takes --lowest and --offline. Never edits the manifest.
outdated <path>Report each version-selected dependency's pinned, highest compatible, and latest release. Takes --offline.
pull <path>Realize the declared closure without advancing revisions or refreshing existing path copies. Idempotent.
verify <path>Check the realized closure against the manifests without changing anything, and print ok. --release also applies the release rule to the project itself.
remove <path> <name>Drop the entry from mach.toml. --purge also deletes dep/<name>/.
list <path>Print each declared entry with its source, ref, staged pin, and whether it is realized or missing.
# add std at the caret range of its newest compatible release
mach dep add . std --git https://github.com/briar-systems/mach-std

# or state the range, or pin one exact tag
mach dep add . std --git https://github.com/briar-systems/mach-std --version "^9.0"
mach dep add . std --git https://github.com/briar-systems/mach-std --ref tag/v9.0.0

# see what newer releases exist, then move to them
mach dep outdated .
mach dep update . std

pull looks at what a git dependency's dep/<id> holds together with its record (the staged gitlink, its .gitmodules entry, and any module directory Git retained) and takes the one step that brings it to what a build verifies. A staged gitlink with nothing checked out is initialized in place, a checkout at the wrong commit is checked out at its gitlink, a clean unregistered checkout is registered, and with neither the submodule is added at its selector. A symlink, a file, a directory that is not a checkout of its own, and a dirty checkout are refused and left alone. A version-selected dependency that has no pin yet is refused, with the mach dep update command to run.

update <name> looks the name up in the whole closure, so it can re-pin a transitive dependency the root does not declare. For an identity reached by more than one path, the root's selector wins if the root declares it. Otherwise the requirers must agree, or the command stops, prints both chains, and names the root declaration that would decide.

Pins are gitlinks

The record of which commit a dependency is at is the gitlink committed in the root repository, generated into .gitmodules by mach dep. Nothing else records a pin. There is no lock file, and a mach.lock in the project root is an unrelated file no command reads. The verifier reads the git index, so a freshly realized dependency is verifiable before it is committed.

A project does not need its own Git repository. A project root is identified by its own mach.toml. In a repository root, git dependencies use the staged gitlinks as their pins. In a plain directory, or a project nested inside an unrelated repository, they are plain clones whose own checkout commits are verified. Path dependencies are verified from their copies.

The flat dep tree

The root's dep/ holds every identity in its transitive closure, one directory each, one level deep. The root manifest declares only what the root uses directly, plus any override. A dependency's own dependencies reach the root's dep/ through closure computation and need no declaration in the consumer. With a root that declares a, and a that declares b, the layout is dep/a/ and dep/b/, and a's use b.* resolves against the root's dep/b/. Git materializes a's own gitlink as an empty dep/a/dep/b/ that is neither realized, verified, nor descended into. dep itself must be a physical directory, not a symlink.

The build resolves a dependency purely by vendor layout: it reads that directory's own mach.toml for its [project].id and [project].src, and routes any module path whose head segment matches a dep's id into that tree.

One identity, one commit

One identity resolves to exactly one commit per build, with no exception for majors. Two different selections of one identity is a clash, and the diagnostic prints both chains and the root declaration that would resolve it:

error: dependency conflict: project id 'b' is reached with two different selections:
    root -> a -> b requires git <url> @ tag/v1.0.0
    root -> c -> b requires git <url> @ tag/v2.0.0
  the root decides by declaring the identity itself, for example:
    [dep.b]
    git = "<url>"
    ref = "tag/v2.0.0"

The root's declaration may point at upstream or at a fork carrying the same id, with no change to any consumer. Two unrelated packages claiming one id is a collision and is rejected. URL disagreement is a mirror, not a conflict: the root's declared URL wins, else the first declaring path's, and verification compares commits, never URLs.

Removed forms

pull, verify, and every build refuse two shapes of a realized closure:

What a build verifies

Builds never fetch and never write under dep/. Every build, and mach dep verify, checks offline that:

  1. every git dependency is a clean checkout at its applicable pin, and every path dependency is a contained tree without repository metadata,
  2. its project id equals its directory name,
  3. the closure computed from the realized manifests equals the set of directories under dep/, with nothing missing and nothing extra,
  4. there are no cycles,
  5. every realized manifest's [project].mach accepts the running compiler, and
  6. for every identity selected by version, the pinned commit carries a release tag that is inside every requirer's range, the root's included, and every release in the closure selects only releases.

A pin outside a range names the requirer chain, the range, the pinned release, and the mach dep update command that re-pins it. For an identity the root does not declare, every requirer's exact tag/ or commit/ selector must match the realized commit. A root ref or path is the override, so the root's gitlink is its pin. A branch/ selector is an input to update, never something a build checks.

Note

mach build never requires the network: a project whose dep tree is present builds on a bare machine. Only add, update, outdated, and pull reach a remote, through git discovered on PATH. A failed git command names the command, its exit status, and git's own message.

Importing a dependency

Once vendored, a dependency's modules are imported by their full path: the head segment is the dep's [project].id, and the rest mirrors its source tree.

use std.print;
use std.types.result.res;

A one-segment use path equal to a dependency's id binds that project's public module. For a dependency, that is the entry shared by its library artifacts (static or shared) marked default = true.

# in the dependency manifest:
[artifact.lib]
kind    = "static"
entry   = "mylib.mach"
out     = "lib/libmylib{artifact.suffix}"
targets = ["*"]
link    = []
need    = []
default = true
# in a consumer:
use mylib;   # binds mylib.mach from the default library artifact

A dependency that declares no default library artifact, or whose default library artifacts name different entries, has no public module. A bare import of it is an error naming the missing declaration, and callers import by full module path instead.

Cascading link requirements

A dependency declares its own external link requirements as [link.X] entries. An entry marked export = true also applies to any project that links this dependency's modules, so a platform link requirement lives once in the manifest that needs it and out of every consumer. A standalone build and a consumed build read the same entries, so nothing behaves differently as a dependency.

# in the dependency's manifest:
[link.kernel32]
source = "system"
name   = "kernel32.dll"
os     = "windows"          # filtered out on any non-windows cell
isa    = "*"
abi    = "*"
export = true                # and cascades to every consumer

An entry applies to a build cell when all three of its os / isa / abi axes match. Target names are local to each manifest and are never matched on. Across dependencies the order is topological, then declaration order, and the union is deduplicated.

See also

  • Manifest - the full mach.toml reference, including [project].mach, [dep.*], and [link.*]
  • CLI - every mach command, including mach dep actions
  • Modules - how source files map to importable module paths
  • Project layout - where dep/ and the manifest live in a project