Introduction
devset applies versioned bundles of files to a directory, records exactly what it wrote, and updates them later without destroying local edits. This manual says how to use it: writing profiles, applying them, and keeping a repository up to date with them.
Why
A team that keeps more than one repository keeps more than one copy of its development configuration: formatter settings, lint rules, dependency policy, editor settings, CI workflows, toolchain pins. The copies are made by hand, and they drift.
The files nobody edits stay in sync on their own. The files that drift worst carry content that belongs where it is: a dependency policy holds the team’s licence rules and one repository’s own bans; a lint configuration holds the house rules and the exceptions one codebase needs. That content is not drift, and a tool that stamps it out is worse than none.
So devset’s job is not to make every file identical. It is to share what is shared, keep what is local, and show the difference.
How
A profile is a directory of files in a source: a git repository, or a
local directory. devset applies profiles to a target, the directory it
manages, and records the bytes it wrote in the target’s .devset/. That record
is what lets a later update tell a local edit from a change the profile made.
flowchart LR
subgraph source["A source: a git repository at a tag"]
rust["profile rust"]
lint["profile lint"]
end
subgraph target["A target: your repository"]
files["its files, some whole, some in part"]
state[".devset/: what devset wrote"]
end
rust -- "a layer" --> files
lint -- "a layer" --> files
files --- state
Profiles compose as crates do. devset is their cargo:
- A target names each source once, and applies profiles from them as layers,
by name:
atxp/rust. - A profile requires others by name, and offers features, which add files, requirements and features of what it requires. They unify across the target and every requirer, as Cargo’s do.
- An entry applies when its gate holds: a feature is on, a profile is active, a variable has one of some answers, or a file will exist.
- A scaffold writes starter files once, when the target has none of its own; from then on they are the target’s.
- A profile may own only a part of a file: some keys of a
Cargo.toml, or a marked block of a.gitignore. The rest stays the target’s.
And devset keeps them in sync:
- Each file has a policy: the profile owns it, the target’s edits are merged with the profile’s, or it is written once and left alone.
devset updatemoves each source to what its tag or branch names now, merging the target’s edits with the profile’s changes. A merge that fails waits for you, and never breaks the working file.devset status --exit-codefails in CI exactly when a file has drifted from what the profile says.
devset never runs code a profile ships. It installs no tools and runs no tasks:
a profile ships a mise.toml or a justfile for those, as ordinary files. And
it changes one target at a time: to change many repositories at once, run it
from a tool such as multi-gitter.
Next
Getting Started applies a first profile to a repository; A New Rust Project starts one from a collection’s bundle. The glossary holds every word the manual uses.
Getting Started
This tutorial applies a first profile to a repository, and shows how devset treats an edit to a file it manages.
Install
Install has every way to install devset; the shortest:
curl --proto '=https' --tlsv1.2 -fsSL https://atomix-labs.github.io/devset/install.sh | sh
A First Target
A target is a directory devset manages. devset init makes one, in the
directory you name, created if missing, or in the one you are in:
$ devset init hello
Created hello/.devset/config.toml
help: add a layer: `devset add <source>/<profile> --git <url>`, or `--path <dir>`
.devset/config.toml starts as a commented skeleton: where profiles come from,
and which to apply. init runs no git and needs no source; a target with no
layer is a target all the same. At a git repository’s top level, or in an empty
directory, init can wait: the first devset add starts the target itself.
A First Profile
A profile is a directory holding profile.toml, which lists the files it
manages, and files/, which holds them as the target should have them. Profiles
live in a source, a directory of them; make one beside a repository:
profiles/
base/
profile.toml
files/
.editorconfig
rustfmt.toml
# profiles/base/profile.toml
[profile]
name = "base"
[files.".editorconfig"]
[files."rustfmt.toml"]
policy = "merge"
devset init <dir> --profile writes a profile’s skeleton, its manifest a
commented tour of what it can say. Then, in the repository, add it as a layer,
naming the source house:
$ devset add house/base --path ../profiles
Created .editorconfig
Created rustfmt.toml
Finished 2 changes
devset named the source in .devset/config.toml, applied house/base, and
recorded what it wrote in .devset/. Commit .devset/ with the files: it is
what lets a teammate, or CI, see the same thing.
[sources]
house = { path = "../profiles" }
[[layers]]
profile = "house/base"
Where Things Stand
devset status compares every managed file with the profile, and says what
would change it:
$ devset status
house/base
All 2 files match the profile.
Edit both files, and ask again:
$ devset status
house/base
Drifted — `devset apply --force` restores:
edited .editorconfig owned restore
Local changes, kept:
edited rustfmt.toml merge
.editorconfig has the default policy, owned: the profile is authoritative,
and the edit is drift. devset apply still leaves it alone, since devset never
destroys an edit unless told to; devset apply --force restores it.
rustfmt.toml is merge: the edit is the repository’s, kept, and merged with
the profile’s own changes when the profile changes.
A file whose only change is whitespace at the end of its lines or of the file,
its line endings or a BOM is cosmetic, not edited: an editor that trims
whitespace or converts line endings never makes drift.
A Source in Git
A team keeps its profiles in a git repository, and a target pins a tag of it:
devset add acme/rust --git https://github.com/acme/profiles --tag v1.4.0 --features docs
devset add acme/book # the source is named now: a name is enough
.devset/lock.toml records the commit the tag named. Every later apply, on
any machine, uses that commit; devset update moves to what the tag, or a
branch, names now, and merges your edits with what changed. devset list acme
shows every profile the source holds, and its features.
Show It
A repository devset manages may say so in its README, with this badge, which
atxp’s project profile adds to the header with its feature devset-badge:
[](https://github.com/atomix-labs/devset)
Next
- Write a Profile goes further: parts of a file, variables and features.
- A New Rust Project and An Existing Repository apply a whole collection.
- In CI makes drift fail a build.
examples/holds each step as an example that runs.
Install
devset is one static binary, for Linux on x86-64 and Arm, and for macOS on Apple silicon. The installer takes the latest release:
curl --proto '=https' --tlsv1.2 -fsSL https://atomix-labs.github.io/devset/install.sh | sh
It downloads the release’s archive for the machine, checks it against the
release’s checksum, and, when the GitHub CLI is installed, its build
attestation; then it puts devset in ~/.local/bin. It edits no shell file,
and says so when that directory is not on your PATH.
Options
| Option | Does |
|---|---|
-v <version> | Installs that release, as 0.5.3, rather than the latest. |
-b <dir> | Installs into <dir>, rather than $XDG_BIN_HOME or ~/.local/bin. |
--uninstall | Removes the binary it would install. |
DEVSET_VERSION | The environment’s way to say -v. |
DEVSET_INSTALL_DIR | The environment’s way to say -b. |
DEVSET_NO_ATTEST=1 | Skips the attestation check. |
Pass options after sh -s --:
curl --proto '=https' --tlsv1.2 -fsSL https://atomix-labs.github.io/devset/install.sh | sh -s -- -v 0.5.3 -b /usr/local/bin
Other Ways
| You use | Install |
|---|---|
| mise | mise use -g github:atomix-labs/devset |
| cargo-binstall | cargo binstall devset-cli |
| cargo | cargo install --locked devset-cli |
The package is devset-cli, and the binary devset; cargo install builds it
with Rust 1.98 or later. In a repository, pin devset as its other tools are: a
mise.toml naming "github:atomix-labs/devset" = "0.5.3" gives every machine
and CI job the same one, and moving the pin upgrades them all.
Upgrade
devset upgrades the way it was installed. devset --version says which release
runs, and the releases list
every one, each with what changed.
| You installed with | Upgrade with |
|---|---|
| the installer | the same line again, for the latest; with -v <version>, for another |
| mise | mise upgrade github:atomix-labs/devset |
| cargo-binstall | cargo binstall devset-cli |
| cargo | cargo install --locked devset-cli |
An upgrade changes no repository: each keeps its sources at the tags its
.devset/config.toml names until devset update moves them.
Uninstall
| You installed with | Uninstall with |
|---|---|
| the installer | the same line with sh -s -- --uninstall, and the same -b if you gave one |
| mise | mise unuse -g github:atomix-labs/devset |
| cargo-binstall | cargo uninstall devset-cli |
| cargo | cargo uninstall devset-cli |
curl --proto '=https' --tlsv1.2 -fsSL https://atomix-labs.github.io/devset/install.sh | sh -s -- --uninstall
Uninstalling leaves devset’s cache of fetched sources, under
$XDG_CACHE_HOME/devset/ or ~/.cache/devset/, which is safe to delete, and
any completion script you wrote. A repository devset manages keeps working
without it: Stop Using devset says what to delete there.
Checking a Download
Each release’s archives are on its
release page, each with a
.sha256 beside it, and each attested by the workflow that built it. To check
one by hand:
sha256sum -c devset-0.5.3-x86_64-unknown-linux-musl.tar.xz.sha256
gh attestation verify devset-0.5.3-x86_64-unknown-linux-musl.tar.xz --repo atomix-labs/devset
Shell Completions
Each release’s archive holds them, in completions/ beside the binary:
devset.bash, _devset for zsh, devset.fish, devset.elv and _devset.ps1.
devset completions <shell> prints the same script, for bash, elvish, fish,
PowerShell or zsh:
devset completions bash > ~/.local/share/bash-completion/completions/devset
devset completions zsh > ~/.zfunc/_devset
devset completions fish > ~/.config/fish/completions/devset.fish
What It Needs
Platforms and Rust Versions lists where the releases run and what builds devset from source.
A source in a git repository needs git on your PATH; a local one needs
nothing else. devset installs no other tool, and runs none a profile names.
A New Rust Project
A collection’s bundle sets a whole repository up at once. This page takes
atxp’s rust bundle, a collection of
profiles provided by Atomix Labs: a Cargo workspace with its first crate, the
pinned toolchain, formatting and lints, dependency policy, tests, the changelog,
CI and the weekly bump. Any other collection’s bundle is taken the same way.
Apply the Bundle
In an empty directory, or a new repository’s top level:
mkdir hello && cd hello && git init
devset add atxp/rust --git https://github.com/atomix-labs/atxp --tag v0.20.0 --var repository=you/hello
add starts the target, names the source atxp, pins its tag, and applies the
bundle and every profile it requires, each a layer of its own. Each variable the
profiles declare takes its default, and devset names them all in one note;
--var name=value answers one otherwise, now or later. A variable with no
default, as repository is, is asked for in a terminal, and with --no-input,
the form for a script, it is an error naming the flag to pass.
See What It Wrote
devset status # every file, and whether it matches its profile
devset explain rust # the bundle's features, and which are off
devset explain Cargo.toml
explain of a file names each layer with a part of it, the part and the policy.
What the repository lacked, a profile wrote once, as a scaffold: the workspace
and its first crate, the changelog, the justfile’s start. Those are the
repository’s from now on; the rest stays in step with the profiles.
Set up and Check
The profiles pin their tools with mise and hang their checks from just.
setup.sh, which the bundle writes, installs mise at its pinned version,
checked against the release’s checksum, then every tool the lock pins, then runs
just setup; run again, it keeps what is in place:
./setup.sh
just check
just check runs every check the profiles define, as CI runs them, and names
each that fails. just --list shows every recipe: fix, test, bump and the
rest.
Turn on More
The bundle’s features add what a project may want, none on by default:
devset add atxp/rust --features docs,publish
devset explain rust lists them. A feature turned off later takes its files
with it, unless the repository has edited them. And any profile of the
collection is a layer of its own: devset add atxp/vhs records a demo for the
README.
Commit
Commit everything, .devset/ included: it records what devset wrote, so a
teammate, or CI, sees the same state. From here, In CI makes drift fail
a build, and Update and Resolve Conflicts takes the collection’s
next release.
An Existing Repository
A repository that has configuration of its own takes profiles as a new one does, and loses nothing: devset keeps every file it finds, and shows how each differs from its profile. This page is what adopting real repositories taught.
Look First
devset add atxp/rust --git https://github.com/atomix-labs/atxp --tag v0.20.0 --dry-run
--dry-run lists what the run would create, change or keep, and writes nothing.
Taking one layer at a time, and committing between, keeps each step small enough
to read.
What devset Does with What Is There
- A file a profile owns whole, already there, is adopted: devset records the
profile’s version as the file’s base and keeps the repository’s file.
devset statusthen shows itedited, with what differs. Keep it, and it stays drift; take the profile’s withdevset apply --force, which restoresownedfiles only; or, when another layer should provide it, choose withfrom. - Keys a profile owns in a TOML, JSON or YAML file are adopted one by one:
the repository’s values are kept, the keys it lacks are written, and every
other key, comment and layout stays its own.
devset diffshows each value the profile would change. - A block is written at the end of a text file, a
.gitignoreor anAGENTS.md, and stays wherever you move it. - A scaffold writes nothing where the repository has its own: a workspace already there is the workspace.
What to Expect
The same few things come up in most repositories:
- Recipe names that clash. A collection with a recipe spine, as atxp’s
justprofile is, owns names such ascheck,testandnightly. A repository’s own recipe by one of those names clashes with it: rename it<verb>-<name>, astest-unit, and the verb runs it with the rest. - Workflows the profiles replace. A repository’s own CI, bump or docs workflows overlap the ones the profiles bring. Retire them, or keep a job the profiles have no counterpart for.
- Exclusions the repository keeps. A formatter’s or a linter’s own
exclusions are the repository’s keys, kept on adoption; add what the profiles’
tools write, as
.just/**orCHANGELOG.md, where a check trips on it. - Older copies of shared files. A file the repository copied from the same
place long ago, a skill or a workflow, shows as
edited;devset diffshows whether the difference is worth keeping, andapply --forcetakes the profile’s. - Checks that are new to it. A spell checker, rustdoc over private items or a stricter lint finds real problems in code that was never held to them: fix them, or allow each by hand in the repository’s own configuration, which the profile leaves to it.
Done
devset status --exit-code passes once nothing has drifted: every owned file
is its profile’s, and the repository’s edits are in merge files, where they
are its own. Commit .devset/ with the files, and In CI keeps it that
way.
In CI
One command makes drift fail a build:
devset status --exit-code
It exits 1 exactly when devset apply --force would write a file, or an update
is unfinished: an owned file edited, deleted or never recorded; a file a
profile added and the target has not applied; a conflict waiting in
.devset/conflicts/. A local edit that is the target’s to make, to a merge or
a once file, never fails it, and neither does a change of whitespace alone.
Installing devset
Pin devset in the repository’s mise configuration, so every machine and every job runs the same version:
mise use github:atomix-labs/devset@0.5.3
Then a GitHub Actions job installs it with the rest of the repository’s tools:
jobs:
devset:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v4
- run: devset status --exit-code
GitHub Actions
Under GitHub Actions, devset also annotates each file that needs doing on the
pull request, as an error where the gate fails and a warning where apply would
write, and adds a table of them to the job’s summary page:
::error title=devset,file=a.toml::a.toml is edited; `devset apply --force` would restore it
::warning title=devset,file=c.toml::c.toml is new; `devset apply` would create it
Credentials
A git source’s repository is fetched with the job’s own git, so its credential
helpers, SSH keys and insteadOf rules apply as they do to any git fetch.
devset never lets git prompt where there is no terminal to answer, so a private
repository needs a credential helper or an SSH key set up before devset runs.
Status as JSON
devset status --json prints the survey for scripts:
| Field | Holds |
|---|---|
layers | Each layer, in order: name, profile as source/name, features, configured, required_by, version, source, rev, digest, and applied, one of current, changed and never |
files | Each managed file or part: path, scope, part, layer, policy, state, what apply and apply --force would do to it, apply and force, and gate, why its gate released it |
settings | on-conflict, and the merge driver: builtin, or its command line |
answers | Every variable’s answer |
suggestions | Merge drivers a layer suggests that the target has not set |
drifted | Whether apply --force would write a file |
unfinished | Whether an update is unfinished |
A file’s state is one of unchanged, cosmetic, edited, missing, new,
untracked, dropped and conflict. What apply would do is one of create,
adopt, update, restore, overwrite, merge, record, untrack,
remove and conflict, or null for nothing. status --exit-code fails when
drifted or unfinished is true.
Write a Profile
A profile is a directory: profile.toml, which says how devset manages each
file and when, and files/, which holds them as a target should have them. This
guide writes one, tries it on a scratch repository, and grows it a step at a
time. Getting Started is the shorter first pass.
Start
devset init profiles/lint --profile
init --profile writes profile.toml, a commented tour of what a manifest can
say, a README, and an empty files/. Put a file in files/ at the path a
target should have it, and name it in the manifest:
[profile]
name = "lint"
description = "The team's EditorConfig and rustfmt settings"
devset = ">=0.5"
[files.".editorconfig"]
[files."rustfmt.toml"]
policy = "merge"
Try It
A scratch repository takes the profile straight from its directory, so every
change is one apply away:
mkdir scratch && cd scratch && git init
devset add lint --path ../profiles/lint
devset status
A local source is read as it is now: edit the profile, run devset apply, and
the scratch repository takes the change, merging it with anything edited there.
Choose a Policy per File
owned, the default: the profile’s; an edit is drift, whichstatusreports andapply --forcerestores.merge: the target’s edits are kept, and merged with the profile’s changes.once: written where absent, then the target’s.
Profiles has what apply does with each.
Own Part of a File
Where a target keeps its own content in a file, own only what the profile needs: the keys its payload defines, or a marked block.
[files."Cargo.toml"]
scope = "keys" # the [workspace.lints] the payload holds, no more
[files.".gitignore"]
scope = "block" # a block between devset's markers
Parts of a File says how each merges and what a starter is.
Ask the Target
A variable is a value each target chooses, and a template uses it:
[vars.line_width]
prompt = "Line width"
default = "100"
[files."rustfmt.toml"]
template = true # files/rustfmt.toml holds max_width = {{ line_width }}
Templates has the language and what else a template can read.
Offer Features
A feature turns on files, requirements or other features; a gate says when an entry applies:
[features]
default = ["deny"]
deny = []
[files."deny.toml"]
when = { features = ["deny"] }
devset explain lint in the scratch repository shows the features and who
turned each on. Gates and Scaffolds and
Composing Profiles have the rest, and
examples/ has each
step as an example that runs.
Share It
Put the profile in a git repository beside others, and tag a release: Publish a Collection. Design Profiles says how to cut profiles so they compose.
Design Profiles
A profile is a crate for a repository, and the choices are a crate author’s: what is one crate and what two, what is a feature, what a configuration value. This chapter says how to make them, so a collection stays small, clear, and easy to take a piece of.
The Units
| Unit | Crate analogy | Use it for |
|---|---|---|
| Source | workspace, registry | profiles released together under one ref |
| Profile | crate | a concern a repository adopts, updates or removes as a unit |
| Feature | Cargo feature | an optional, additive capability of that concern |
| Variable | configuration value | a value per repository, or a choice between alternatives |
| Requirement | dependency | a concern this one cannot work without |
| Optional requirement | optional dependency | a capability that needs another concern: ["dep:lychee"] |
| Bundle | facade crate | only requirements, with features that describe a kind of project |
| Scaffold | a template | starter content that becomes the project’s once written |
| Managed entry | a dependency’s code | content that stays in sync with the source |
The Tests
Ask them in order:
- Would a repository want one without the other? Then two profiles. If they always go together and edit the same files, one.
- Would turning it on remove or replace something? Then a variable, not a feature: features only add, and two can always be on at once.
- Does the description join unrelated things with “and”? Then two profiles.
- Does it only make sense with its concern? Then a feature of that concern, not a profile of its own.
- Is it content the project will own and change? Then a scaffold. Content that must follow the source is managed: owned, merged, or a part.
- Names are API. Removing a profile, a feature or a variable, or narrowing what a feature adds, breaks the targets that name it: release it as a breaking change.
Working Examples
- A book. One
mdbookprofile: its scaffold writes the book when the project has none, its managed keys keepbook.tomlwired up either way, and KaTeX, Mermaid and an API reference are features, since each only makes sense with a book. Link checking is another concern, an optional requirement:links = ["dep:lychee"]. - A licence.
MIT,Apache-2.0and both are alternatives, so a variable,license, with each licence file gated on its answers; not three features, since turning one on would have to turn another off. - A Rust project. A
rustbundle requires the concerns every Rust repository has, and its features name kinds of project:docsturns on the book,publishthe crates.io profile,binariesthe release archives. - House rules. Rules one team wants and others would not are a
strictfeature of each concern, not a fork of the collection.
Adopting What Is There
A profile adopting an existing repository should keep what it finds, and wire it up. The tools:
- A part,
scope = "keys"or"block", manages what the profile needs in a file the project owns. - A scaffold writes starters only where the project has none, and remembers that it found some.
- A gate on
existsattaches to what the project, or another profile, has: a block inAGENTS.mdonly where there is anAGENTS.md. - A starter composes with parts, so the same profiles create a file from nothing and fill in an existing one.
Profiles for Agents
A concern that teaches an agent how to work with what it sets up ships that
teaching with itself, behind an agents feature: a skill beside the tool it is
about, gated so a project that does not use agents never gets it. A template can
name only the recipes the graph has, through devset.profiles.
Publish a Collection
A collection is a git repository of profiles that other repositories take by tag. Anyone can keep one, a person, a team or a company, for their own tastes and conventions; atxp, provided by Atomix Labs, is one.
Lay It Out
devset init profiles-repo --collection
init --collection writes collection.toml, a README and a first profile under
profiles/:
profiles-repo/
collection.toml [collection] name and description
README.md
profiles/
example/
profile.toml
README.md
files/
The collection’s name is what a target calls the source unless it chooses
another: devset add acme/lint --git <url> and devset add lint --git <url>
both name it acme when collection.toml says name = "acme". Profiles may
sit in folders of their own under profiles/, grouped by concern; a profile is
found by its name, wherever it is. devset list --path profiles-repo shows
what a target would see.
Release It
A tag is a release: every profile of the collection at one commit. Semantic versions tell a target what a move costs:
- a patch changes nothing a target must act on;
- a minor release, while the major is 0, may break a target, and says how in a note of migration;
- a major release, from 1.0, is the break.
git tag -s v0.1.0 && git push origin v0.1.0
A target pins the tag, and devset update names each newer release, so a tag
once pushed stays: move forward with a new one.
Say What It Holds
Each profile’s README says what it does and why; a collection’s README lists
them. atxp’s devset-collection profile writes both from the manifests, a
catalog in the collection’s README and a section of facts in each profile’s, and
fails its check when either is stale.
Let People Find It
Tag the repository with the GitHub topic devset-collection, so
a search finds it. Say in the
README what taste the profiles hold, and how to take one:
devset add acme/lint --git https://github.com/acme/profiles --tag v0.1.0
Share a Team’s Configuration
A team keeps one collection of profiles for all its repositories, and each repository takes it by tag. The collection can stand alone, or build on another collection’s profiles and add the team’s own.
Build on Another Collection
A profile requires another by name; with git, from another source. One tag
pins that whole source for every profile that requires it:
# profiles/house/profile.toml, in the team's collection
[profile]
name = "house"
description = "Our repositories: atxp's rust bundle with the book, and our deploy workflow"
devset = ">=0.5"
[requires]
rust = { git = "https://github.com/atomix-labs/atxp", tag = "v0.20.0", features = ["docs"] }
[files.".github/workflows/deploy.yml"]
A repository takes one layer, devset add team/house --git <url> --tag <tag>,
and gets every profile rust requires as a layer of its own, before house, so
the team’s files come last and its choices stand. Moving atxp forward is one
change, in the team’s collection, and a release of it.
Where the Team Differs
Formatting, lints and CI are taste. Where the team’s differs from a collection it builds on, it has three ways, from the lightest:
- Answer a variable: each repository’s answer,
--var line_width=120, is kept in its.devset/answers.toml. - Turn a feature off:
default-features = falseon the requirement, then the features the team wants. - Swap a profile: require the profiles the team agrees with rather than the whole bundle, and ship a profile of its own for the concern it sees differently.
Per Repository
Each repository still decides for itself: its answers, and in
.devset/config.toml the features on each layer, which layer provides a file
(from), and a file’s policy, once to take a file over entirely.
Settings has every override. A repository’s own edits in merge
files and outside a profile’s parts stay its own through every update.
Rolling Out a Release
Each repository moves when it takes the new tag: devset update team --tag <release>. A scheduled job can do it for each, and open a pull request with the
checks’ verdict: Automate Updates. To change many
repositories at once by hand, run devset from a tool such as
multi-gitter.
Update and Resolve Conflicts
How to take a profile’s changes, what to do when one meets an edit of the target’s own, and how to take a run back. Updating and Merging says what devset does underneath.
Taking a Change
.devset/lock.toml pins each git source to the commit its tag, branch or rev
named when it was applied, so every profile of a source is at one commit.
apply, on any machine, uses that commit; only an update moves it:
devset update # every source, to what its ref names now
devset update atxp # one source, by its name
devset update rust # the source of one layer, by its profile's name
A source pinned to a tag stays there until you move it, and update names the
releases newer than it, with the command that takes the newest:
$ devset update
Fetching https://github.com/atomix-labs/atxp
Finished nothing to change
note: atxp is pinned to v0.6.2; newer: v0.7.0
|
= help: take the newest: `devset update atxp --tag v0.7.0`
$ devset update atxp --tag v0.7.0
--tag, --branch and --rev move a source in .devset/config.toml, keeping
how the file writes it, and apply what the new ref holds; update --dry-run
names the newer releases without writing. Editing the ref by hand and running
devset apply does the same: a lock entry that no longer matches its source is
resolved again.
A local directory has no history to pin, so it is read as it is now, by apply
and update alike. status marks such a layer when its files changed since it
was applied.
Every command that writes takes --dry-run, which says what it would do and
writes nothing.
Resolving a Conflict
A conflict is written to .devset/conflicts/<path>, with conflict markers, and
the working file is left untouched, so a conflict never breaks the build.
The update exits 1 and names each conflict and why:
$ devset update
Fetching https://github.com/acme/profiles
Conflicted deny.toml conflicting changes
Updated rustfmt.toml
Finished 1 change, 1 conflict
help: resolve the files in .devset/conflicts/, then run `devset apply --continue`;
or take it back with `devset apply --abort`
Resolve it in the sidecar, which already holds every hunk that merged cleanly, then continue:
$EDITOR .devset/conflicts/deny.toml # fix the marked hunks
devset apply --continue # checks, installs, records the new base
--continue refuses while a sidecar still has markers or fails its check. It
installs every resolution at once, records the profile’s version as each file’s
new base, and removes the sidecars.
Until then the run is unfinished: every command that writes refuses to start
another, status lists the conflicts first, and status --exit-code fails. A
conflict can come from any of them, add, remove or apply as well as
update, and is finished the same way.
Taking a Run Back
devset apply --abort takes back the run that conflicted, as git merge --abort does: every file it wrote, the lock and the state return to what they
were before it, and the sidecars go. A file changed since the run stops the
abort, so nothing you did since is lost; --abort --force discards those
changes too. --abort --dry-run says what it would restore.
Automate Updates
A repository pinned to a collection’s tag stays there until it moves, so a release does nothing until someone takes it. A scheduled job takes it for you: it moves the pin, runs the checks, and opens a pull request with their verdict.
What the Job Does
-
Find the newest release.
devset update --dry-runnames every release newer than the pin, and the command that takes the newest. A script finds the tag itself:latest=$(git ls-remote --tags --refs --sort=-version:refname https://github.com/acme/profiles 'v*' \ | head -n 1 | sed 's|.*refs/tags/||') -
Take it.
devset update acme --tag "$latest"moves the pin in.devset/config.tomland merges every change with the repository’s edits. -
Take a conflict back. An update that conflicts exits 1 and leaves the conflict in
.devset/conflicts/;devset apply --abortreturns the repository to where it was, and the job reports the release for a person to take by hand, as Update and Resolve Conflicts shows. -
Check. Run the repository’s checks, so the pull request says whether the release passes.
-
Open a pull request with the changes, the release it moved to, and the checks’ verdict.
A cooldown, taking a release only once it is a few days old, leaves time for a bad one to be followed by its fix.
With Atxp
atxp’s github-bump profile is such a job, for every repository that applies
it: once a week, its bump-devset recipe moves each source pinned by tag on
github.com to its newest release three days old or older, and takes a conflict
back; the same run moves the repository’s other pins, runs just check, and
commits the result as one signed commit, to a branch, a pull request, or a merge
once green, as the repository chooses.
By Hand, Across Repositories
To move many repositories at once, run the same steps in each from a tool such as multi-gitter, which opens a pull request per repository.
Stop Using devset
Every file devset wrote is an ordinary file in the repository. Stopping takes nothing away: the files stay as they are, and only devset’s own records go.
Keep Every File
rm -r .devset
Then remove what runs devset:
- a CI step running
devset statusordevset update; - a recipe or script that runs it, as atxp’s
check-devset, and a pin of devset inmise.toml; - a scheduled update job, if the repository has one.
A block a profile owned stays in its file between devset’s markers, which are comments: delete the two marker lines, or leave them. Every tool the profiles pinned stays pinned where the repository’s own configuration names it.
Drop One Layer
devset remove <layer> is for the other case: dropping a profile’s files while
devset stays. It takes away each file the layer wrote that is still as devset
wrote it, keeps an edited one and stops tracking it, and takes the layer’s keys
or block out of a file it shared. devset remove <layer> --dry-run says which.
Coming Back
A repository that stopped can start again with devset add: every file there is
adopted, as An Existing Repository says, and nothing
is lost.
Profiles
A profile is a directory in a source: profile.toml at its root, and files/,
which mirrors the target. This chapter covers what profile.toml declares and
how devset treats each file; the schema lists every field, and
Design Profiles says how to cut a concern into profiles,
features and variables.
rust/
profile.toml
README.md # the profile's own documentation; never applied
files/
rustfmt.toml
deny.toml
.github/workflows/ci.yml
[profile]
name = "rust"
version = "1.4.0"
description = "Formatting, lints and CI for a Rust repository"
devset = ">=0.5"
[files."rustfmt.toml"]
[files.".github/workflows/ci.yml"]
[files."deny.toml"]
policy = "merge"
The Profile Table
| Field | Meaning |
|---|---|
name | Required. Names the profile: in its source, among a target’s layers, and in output. |
version | For people; a git source is pinned by its commit, not by this. |
description | One line, for people; devset list shows it. |
devset | The devset versions the profile works with, as >=0.2; any other is refused. |
Beside [profile], a manifest may declare [requires] and [features]
(Composing Profiles), [vars] (Templates),
[scaffolds] (Gates and Scaffolds), and [merge]
(Settings).
Files
[files."<path>"] declares that devset manages files/<path>, at <path> in
the target. A file under files/ that no entry declares is not applied, and an
entry whose file is missing is an error. A path may use variables, as "{{ book_dir }}/book.toml": its payload is under files/ as written, and the
target’s answer decides where it goes (Templates). An entry’s
fields:
| Field | Meaning | Chapter |
|---|---|---|
policy | How local edits are treated: owned, merge or once. | below |
scope | How much of the file the profile owns: file, keys or block. | Parts of a File |
comment | The comment syntax of a block’s markers. | Parts of a File |
starter | For a part, the file the target’s starts from when it is absent. | Parts of a File |
template | Render the file with the answers and the graph. | Templates |
when | When the entry applies: features, profiles, variables, paths. | Gates and Scaffolds |
scaffold | The scaffold group the file is a starter of. | Gates and Scaffolds |
executable | Write the file with mode 755. | below |
validate | How a merged file is checked: toml, json, yaml or none. | Updating and Merging |
Policies
| Policy | Meaning |
|---|---|
owned | The default. The profile is authoritative; local edits are drift. |
merge | Local edits are kept, and merged with the profile’s changes. |
once | Written if absent; the target’s from then on. |
What devset apply does with each file, by what is on disk:
| On disk | apply | apply --force |
|---|---|---|
| absent, never written | write it | write it |
| present, never written | adopt it: record the profile’s version, keep the file | overwrite it |
unchanged: as devset wrote it | write it, if the profile’s version changed | the same |
cosmetic: changed in whitespace only | write it, if the profile’s version changed | the same |
edited | keep it; status reports it | restore it |
missing: deleted | respect the deletion; status reports it | restore it |
--force touches owned files only. A once file is written only when it is
absent and was never written; after that it is the target’s. A file of a
scaffold is once. A merge file is kept like an owned
one, except that when the profile’s version changes, an update merges the two.
Adoption is what makes a first init into an existing repository safe:
devset records the profile’s version as the file’s base and keeps the file, so
status shows how the repository’s file differs from the profile, and nothing
is lost.
Whitespace at the ends is never an edit. devset compares text in a canonical
form, without a BOM, the whitespace that ends a line, CRLF line endings, or
blank lines at the end of the file, so an editor or formatter that changes only
those never makes drift, and apply does not rewrite the file for them. A
binary file is compared byte for byte.
Scripts
A file marked executable = true is written with mode 755 wherever devset
writes it, so a script a profile ships runs as ./setup.sh, and git records it
executable. The mode is not part of what devset records: changing it is not
drift.
Paths devset Refuses
A profile’s paths are relative, and stay inside the target. devset refuses a
profile with a path under .git or .devset, compared without case, since on a
case-insensitive filesystem .GIT/config is .git/config; or with two paths
that name one file on such a filesystem, as README.md and readme.md do. In
the target, a managed path that has become a symlink or a directory is an error
devset reports: it never writes through a symlink.
Parts of a File
A profile may own part of a file the target otherwise owns: the lint tables of a
Cargo.toml, a few keys of .vscode/settings.json, one stack’s lines in a
.gitignore. scope on the file’s entry says how much:
| Scope | The profile owns | For |
|---|---|---|
file | The default. The whole file. | Configurations with one owner |
keys | Every key its payload defines | TOML, JSON and JSONC; YAML, experimental |
block | The lines between two comments that mark it | .gitignore, CODEOWNERS, READMEs |
status names a part by its file and its profile, as Cargo.toml [keys: rust].
Keys
The payload of a keys part is a partial document: the keys the profile owns,
and nothing else.
# profile.toml
[files."Cargo.toml"]
scope = "keys"
# files/Cargo.toml
[workspace.lints.clippy]
pedantic = { level = "deny", priority = -1 }
unwrap_used = "deny"
The profile owns each leaf its payload defines. In TOML, a leaf is any value
but a [table]: an inline table, as pedantic is here, and an array of tables
are leaves whole. In JSON and YAML, a leaf is any value but an object. Here the
leaves are workspace.lints.clippy.pedantic and
workspace.lints.clippy.unwrap_used. Every other key is the target’s, including
keys it adds to the same tables.
- Values are compared after parsing, so reformatting is never drift, and only the keys that change are rewritten: comments and layout elsewhere in the file survive.
- A key the profile adds or changes is written as its payload writes it, an array of tables with its comments; a file the part creates is the payload as written.
- A TOML key that joins a table the file holds takes the table’s alignment: where the file aligns its entries, as taplo does, the group the key joins is aligned again, comments and all; where it spaces each key by one, so is the new one.
- A file that already holds some of the keys is adopted key by key: its values are kept, and the keys it lacks are written.
- An update merges key by key, and a conflict marks only the keys both sides changed.
YAML is experimental: only the first document of a stream is managed.
Blocks
A block is the lines between two comments, in the file’s own comment syntax, named after the profile:
/deployments/local/*
# >>> devset: rust >>>
/target
# <<< devset: rust <<<
A new block goes at the end of the file, and stays wherever you move it. devset
knows the comment syntax of common files by name, as .gitignore, justfile
and Dockerfile, or by extension; where it does not, comment on the entry
names it: #, //, --, ;, %, /* or <!--. The last two close their
markers, as <!-- >>> devset: badges >>> -->.
To place a block before devset first writes it, write its two markers with nothing between them: devset fills them where they are.
Several Parts in One File
Several layers may own parts of one file, in one scope, so long as no two own the same key. With every part written, the file must still parse, with no duplicate keys; if it does not, the change waits as a conflict, and the file is left as it was.
Starters
A part of a file the target does not have yet starts it. With nothing else, the file holds the part alone; a starter gives it the rest, once:
-
A whole
oncefile of another layer, or a scaffold’s file, on the same path starts the file, and every layer’s part is spliced into it in the same run. One profile scaffoldsCargo.toml, others add their lint tables and release profile to it, and the file is written once, whole. -
A part’s own starter,
starteron its entry, names a file underfiles/the target’s file starts from when it is absent:[files."docs/book.toml"] scope = "keys" starter = "book.starter.toml" # files/book.starter.toml: the rest of a new book.toml
When the file is there, the parts go into it as it is, and no starter is used. A
starter is once: the target’s as soon as it is written, so what the parts
change in it is never an edit, and a starter written again, by --rescaffold,
takes every part with it. A file has one starter: two, whole or a part’s own,
collide, and from names the layer whose starter it is, every layer’s part
staying.
Templates
A file is copied as it is unless its entry marks it a template. A template is rendered with the target’s answers to the profile’s variables, and with the graph of profiles the target applies, so a profile need not hard-code what differs between targets: an author, a repository’s name, a machine’s build flags, which other profiles are there.
# profile.toml
[vars.author]
prompt = "Author name"
[vars.target_cpu]
prompt = "ISA floor for x86-64 builds"
default = "x86-64-v2"
[files."Cargo.toml"]
policy = "once"
template = true
# files/Cargo.toml
[package]
authors = ["{{ author }}"]
Templates are MiniJinja: {{ name }} is a
variable, and the rest of Jinja’s syntax works too. A variable a template uses
that no layer declares is an error, never an empty string. A variable’s name is
an identifier: letters, digits and _, not starting with a digit, and not
devset, which names the graph.
Paths
A path in [files], and the paths and globs of a gate or a scaffold, may use
variables too, template or not:
[vars.book_dir]
prompt = "Directory of the book"
default = "docs"
[files."{{ book_dir }}/book.toml"] # files/{{ book_dir }}/book.toml in the profile
The payload is under files/ at the path as written. Answers are settled before
paths are rendered, and a rendered path must be a valid path in the target: one
that climbs out of it is an error. A changed answer moves a managed file: the
old path is released, as a dropped file is, and the new one written. A file a
scaffold already wrote stays where it is.
The Graph
Besides the answers, every template sees devset, built from the profiles the
target applies:
| Name | Is |
|---|---|
devset.target | the target directory’s name |
devset.profiles | every active profile’s name |
devset.features | this profile’s features that are on |
devset.layers | each layer: profile, source, features, and configured, or brought in by a requirer |
Before you finish, run `just fix`{% if "rust-doc" in devset.profiles %}, then `just check-rust-doc`{% endif %}.
Adding or removing a layer, or turning a feature on or off, changes what such a
template renders, as a changed answer does: an untouched file is written again,
and an edited merge file merged.
Answers
A variable not yet answered takes its default, and devset names every default it
took in one note; a variable with no default is asked for in a terminal.
Elsewhere, or with --no-input, a variable with no default is an error that
names the --var flag to pass:
devset add --path ../profiles/rust --var author=Ada
Every answer, defaults included, is kept in .devset/answers.toml, which you
commit, so a profile that later changes a default never changes a target’s file
without asking. A variable a profile newly declares is asked on the next update.
Variables describe the target, so they share one namespace: author is one
question, answered once, and used by every layer that declares it. Layers that
give it different defaults are settled by the target’s answer.
Changing an Answer
add, apply and update take --var name=value, which replaces the answer;
a name no layer declares is an error that lists the ones they do. To devset, a
changed answer is a change to the profile’s version of every file that uses it:
an untouched file is written again, and an edited merge file is merged.
An answer to a variable no layer declares any more, after devset remove, say,
leaves answers.toml, and devset notes which.
Gates and Scaffolds
A profile does real work: it scaffolds what a project lacks, wires up what it
has, and leaves alone what is not its business. Two things make that possible. A
gate, an entry’s when, says when the entry applies. A scaffold is a
group of starter files, written once when the target has none of its own.
Gates
An entry applies when every condition of its when holds:
[files."{{ book_dir }}/theme/katex.css"]
when = { features = ["katex"] }
[files."AGENTS.md"]
scope = "block"
when = { exists = ["AGENTS.md"] } # only where the project has one
[files."LICENSE-APACHE"]
when = { vars = { license = ["Apache-2.0", "MIT OR Apache-2.0"] } }
| Condition | Holds when |
|---|---|
features | every listed feature of this profile is on |
profiles | every listed profile is active in the target |
vars | each listed variable of this profile is answered with one of its values |
exists | each path or glob will exist when the run is done |
exists looks at the end of the run, not the start: a path holds when it is
on disk and the run does not remove it, or when the run writes it, whole or as a
part; a directory holds when the run writes a file under it. So gates chain in
one run: one profile scaffolds AGENTS.md, and another’s block attaches to it
in the same apply. A pattern with any of *?[{ is a glob, * within a
directory and ** across them, matched against the files no .gitignore
excludes, in a git repository or not; others are paths, and may use variables.
exists only ever turns entries on, so devset settles it the way that cannot
depend on order: every gated entry starts off, and each round turns on the ones
whose paths will exist, until no more do. An entry is never its own evidence:
its own path counts only as the disk and the other entries have it. The order of
the layers never matters.
There is no “if absent” condition. Content a project should get only when it has none of its own is a scaffold, whose decision is recorded, so it never flips.
A gate is decided on disk. An exists on a file that is not committed decides
differently in CI, and devset status --exit-code shows that as drift.
When a Gate Turns Off
An entry whose gate stops holding is released, as though its profile had stopped
shipping it: unchanged, it is removed, or a part taken out of its file; edited,
it is kept and untracked; a once file is untracked and left alone. status
and --dry-run say which condition decided:
$ devset status
atxp/rust (changed since it was applied)
Pending — `devset apply` will:
dropped book.toml remove (feature docs is off)
dropped guide.md untrack (feature docs is off)
devset explain <path> shows every layer that lists a file, its gates and how
each came out, and the decision of its scaffold:
$ devset explain LICENSE-MIT
LICENSE-MIT
house/project file owned off: license is "Apache-2.0"
when license is one of ["MIT", "MIT OR Apache-2.0"]: "Apache-2.0"
Scaffolds
A scaffold is a named group of starter files, written together when none of its sentinels is there:
[scaffolds.book]
unless = "{{ book_dir }}/book.toml" # a path or a glob, or a list: any there means "found"
[files."{{ book_dir }}/book.toml"]
scaffold = "book"
template = true
[files."{{ book_dir }}/src/SUMMARY.md"]
scaffold = "book"
- Decided once. The first time the profile is applied, the group is
writtenwhen none of its sentinels is there, andfoundwhen one is: the project has its own, and the group is left out.state.tomlrecords the decision, and it holds: a sentinel that appears later changes nothing. - The target’s once written. A scaffold’s files are
once, so the project owns them: edit them, or delete them, and devset respects it. - Asked for again.
devset apply --rescaffold book/bookwrites the group’s missing files again, whatever was decided, and adopts the ones that are there. - Where it was written.
state.tomlrecords where each file went, so a changed answer that would move{{ book_dir }}moves no file a scaffold wrote.
A scaffold file may be a template, and gated: a file gated by a feature turned on later is written then. A group is decided the first time one of its files applies, so a group whose files are all gated off waits; one with no sentinel is written then.
Scaffolds and starters are how a profile takes a project
from nothing: a workspace profile scaffolds Cargo.toml, and lint, release and
toolchain profiles splice their keys into it in the same run. Where the project
already has a Cargo.toml, the scaffold finds it, and the same keys go into the
project’s own file.
Composing Profiles
A target applies profiles from sources, and a profile builds on others and offers features, as a crate builds on crates and offers features. This is how a team builds on a shared collection instead of copying it: its own profiles require the collection’s, turn on what they need, and add what is the team’s.
Sources
.devset/config.toml names each source once, and applies profiles from them as
layers, by source/profile:
[sources]
atxp = { git = "https://github.com/atomix-labs/atxp", tag = "v0.20.0" }
house = { path = "../house-profiles" }
[[layers]]
profile = "atxp/rust"
features = ["docs", "agents"]
[[layers]]
profile = "house/deploy"
default-features = false
A source uses Cargo’s git-dependency fields. path alone is a directory,
relative to the target; with git, it is a directory inside the repository that
holds the profiles. tag, branch and rev go only with git, and at most
one of them; with none, the repository’s default branch. A git URL may be a
local path, as ../profiles.git, relative to the target too.
A source’s profiles are every profile.toml in it, found by their name
wherever they are, so a collection may group its profiles as it likes, and move
them, without breaking a target. Names are unique in a source, which devset
checks when it reads one; a profile.toml inside another profile’s files/ is
that profile’s payload. A source may describe itself in collection.toml:
[collection]
name = "atxp" # the name a target gives it unless it chooses another
description = "Profiles for devset, one concern each"
devset add names a source as it adds it: devset add atxp/rust --git <url>
names it atxp; without a name, after its collection.toml, or else its
directory. A profile named alone, as devset add book, is in the target’s only
source. devset list shows each source’s profiles, their descriptions and their
features.
Layers
Each active profile is a layer: the target’s own, and every profile they
require. A layer is named by its profile’s name, which is what from, devset remove and devset explain take; so two active profiles may not share one,
whichever sources they come from. Layers apply in order, each requirement before
the profile that requires it.
Two layers that provide the same file collide, and devset refuses to guess which one wins:
$ devset add house/rust
error: fmt.toml is provided by base and rust
|
= help: choose one in .devset/config.toml:
[files."fmt.toml"]
from = "base" # or "rust"
from is the target’s choice; Settings has the rest of what the
target decides. Layers that own parts of one file share it without a
choice, so long as no two own the same key, and one layer’s whole once file
may start a file the others own parts of.
Requirements
[requires] names the profiles one builds on, by name:
[requires]
just = {} # in this profile's source
lychee = { optional = true } # only when a feature activates it
github-ci = { features = ["pages"], default-features = false }
deny = { git = "https://github.com/acme/profiles", tag = "v2.0.0" }
A requirement is in the requirer’s own source, at the same commit, unless it
names another with git: one tag pins a whole collection. Each is a layer of
its own, before the profile that requires it, so the requirer’s files come last
and its choices stand. A profile required from two places is one layer. A
profile with nothing but requirements is a bundle.
status folds each required layer, applied as it is, into a requires line
under the layer that brings it in; devset status -v lists them all. A required
layer is not the target’s to remove: devset remove takes the configured one
that brings it in.
Features
[features] declares what a target, or a profile that requires this one, may
turn on:
[features]
default = ["katex", "links"]
katex = [] # files gated on it: when = { features = ["katex"] }
api = ["katex"] # another feature of this profile
pages = ["github-ci/pages"] # a feature of a requirement, which activates it too
links = ["dep:lychee"] # an optional requirement, activated
agents = ["mdbook?/agents"] # a requirement's feature, only if the target has it anyway
Features follow Cargo’s rules:
- Additive. A feature adds files, parts, requirements and features. It never removes or replaces: a choice between alternatives is a variable.
- Unified. A profile is one layer, whoever asks for it: the features the target and every requirer turn on are unioned, and devset expands the graph until nothing more turns on.
- Default.
defaultlists the features on unless the layer, or a requirer, setsdefault-features = false. When a layer switches them off and a requirer turns them on, they are on, and devset warns and names the requirer. - Named. An unknown feature is an error, with a near match.
devset explain shows each layer’s features, and who turned each on; devset explain <layer> adds the ones it leaves off. The lock records them.
$ devset explain
atxp/ci [pages] (required by mdbook)
pages <- mdbook/pages
atxp/mdbook [api, katex, pages] (required by rust)
api <- rust
katex <- default, mdbook/api
pages <- rust/docs
atxp/rust [docs]
docs <- target
devset add changes a layer’s features as cargo add changes a dependency’s:
on a profile the target already applies, --features turns more on, and
--no-default-features and --default-features switch its defaults. A profile
active only because another requires it becomes a layer of its own when you add
features to it.
devset remove <layer> --features turns off features the layer lists, and keeps
the layer; a feature a requirer or the defaults turn on stays on, and devset
says which.
devset add atxp/mdbook --features mermaid
devset remove mdbook --features mermaid
devset refuses a graph that cannot hold: a cycle of requirements, a profile named twice from two sources, or listed as two layers, one source at two refs, and two required profiles that provide the same file.
Updating and Merging
What happens when a profile changes: what an update does to each file, how devset merges a change with the target’s own edits, where a conflict goes, and what becomes of a file no layer provides any more. Update and Resolve Conflicts is the how-to.
What an Update Does
For each file whose profile version changed, by its policy:
| The target’s file | owned | merge | once |
|---|---|---|---|
| as devset wrote it | updated | updated | nothing |
| edited | kept, and reported as drift | merged | nothing |
| deleted | the deletion kept, and reported | the deletion kept | nothing |
Merging
flowchart LR
base["the base: what devset last wrote"]
ours["the target's file, with its edits"]
theirs["the profile's new version"]
merge{"three-way merge, then the format's check"}
merged["the merged file, written"]
conflict[".devset/conflicts/: the file untouched"]
base --> merge
ours --> merge
theirs --> merge
merge -- "clean" --> merged
merge -- "both changed the same lines" --> conflict
A merge is three-way: the base, which is what devset last wrote and keeps in
.devset/base/; the target’s version; and the profile’s new one. By default a
built-in line merge makes it; a merge driver such
as Mergiraf can instead.
A merged file is checked before it is written. A line merge can report a clean merge and still make a broken configuration. Given a local edit near the top of a file and the profile’s edit near its bottom, every line merger produces this, cleanly:
name: app
timeout: 30 # the target's edit
replicas: 1
port: 80
timeout: 60 # the profile's edit: the same key, twice
So a merged TOML, JSON or YAML file must parse, with no key twice, or the merge
is a conflict, however cleanly it merged. The format is read from the extension
(.toml, .json and .jsonc, .yaml and .yml); validate on the file’s
entry names it, or turns the check off with none. JSON is read as JSON with
comments, since so many .json configurations have them.
Conflicts
A change and an edit that touch the same lines conflict. devset writes the
conflict to .devset/conflicts/<path>, with conflict markers, and leaves the
working file untouched, so a conflict never breaks the build or a formatter;
the run is unfinished until it is resolved and continued, or taken back, as
Update and Resolve Conflicts shows.
What else an update writes while a file conflicts is the target’s choice,
[merge] on-conflict in Settings:
on-conflict | The rest of the update |
|---|---|
apply-others (default) | Written, and the lock moves; the conflicts wait |
apply-none | Withheld until every conflict is resolved, then written at once |
Some files cannot merge. A binary file conflicts, and its sidecar holds the
profile’s version, to keep or to replace with yours. A file whose base is lost,
as when .devset/base/ was never committed, merges without one, and always
waits for you.
Files No Layer Provides
A file no layer provides any more, because a profile dropped it, a layer was
removed, or its gate turned off, is
dropped, and status says so, with the condition that decided when a gate
did. The next apply takes it away only if it is as devset wrote it:
| The dropped file | apply |
|---|---|
| as devset wrote it | removes it, and directories it leaves empty |
| edited | untracks it: the edit is the target’s |
| deleted | untracks it |
written under once | untracks it: it became the target’s |
A dropped part leaves its file the same way, its keys or its block taken out, and a file it leaves empty goes. A key another layer’s part now covers stays, so a key that moves between profiles is never lost.
State
Everything devset keeps about a target is in its .devset/ directory. Commit
it: it is what lets a teammate, or CI, see exactly what you see, and what makes
devset status work on a fresh clone.
| Path | Holds | In git |
|---|---|---|
config.toml | The sources, the layers, [merge] settings, and file overrides: yours | commit |
lock.toml | Each source’s commit; each layer’s digest, and its features and who turned them on | commit |
answers.toml | The answers to the profiles’ variables | commit |
state.toml | What devset last wrote, per file and part: two digests each; each scaffold’s decision, and where it wrote its files | commit |
base/ | The bytes devset last wrote, one blob per content digest | commit |
.gitignore | devset’s own ignore rules | commit |
.gitattributes | Keeps base/ out of diffs and line-ending conversion | commit |
conflicts/ | An unfinished update: its sidecars, and what to take it back | ignored |
.lock | Held while devset writes, so two runs never interleave | ignored |
The lock and the state are version 2. devset 0.2 reads a version 1 state as it
is, and resolves a version 1 lock anew; a version 1 config.toml, whose layers
name locations, is refused with the way to write it as sources and names.
BREAKING-CHANGES.md, in devset’s repository, has the steps.
The Base
base/ is what makes a real merge possible: the exact bytes devset last wrote
for each file, kept rather than reconstructed. Each blob is named by its own
digest, never by its path, so a profile’s .gitignore or .gitattributes in
base/ can never affect the rest, and identical files are stored once. A blob
nothing records any more is deleted when state is written.
A conflict in progress is your work in progress, as a rebase is, so conflicts/
stays out of version control.
The Target’s Own Files
devset never edits your .gitignore or .gitattributes for its own sake: its
rules are in .devset/, where git honours them, and where they are inert in a
target that is not a repository.
Finding the Target
devset runs from anywhere inside a target: the target is the nearest directory,
upwards, that holds .devset/, found as Cargo finds Cargo.toml. Nothing needs
git, and each git worktree of a repository is a target of its own.
The Cache
Git sources are fetched into a cache per machine, one bare repository per URL
under $XDG_CACHE_HOME/devset/, ~/.cache/devset/ by default, on Linux and
macOS alike, and never into a target. One fetch serves every target on the
machine, and a commit already there is never fetched again.
devset
Apply versioned file bundles to a directory, and update them without losing local edits
Usage: devset [OPTIONS] <COMMAND>
Commands:
init Make a directory a target, or a profile or a collection to author
add Add a profile as a layer, or features to one, and apply it
remove Remove a layer, or features from one: unchanged files go, edited ones stay
apply Apply the pinned profiles without destroying local edits
update Move sources to newer commits, or to another tag, merging local edits
status Show where every managed file stands against the profile
diff Show, line by line, how files differ from the profile
explain Show why a layer's features are on, or why a file is managed as it is
list List the profiles a source holds, with their features
completions Print a shell completion script
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset add atxp/rust --git https://github.com/atomix-labs/atxp --tag v0.20.0
devset add atxp/mdbook --features katex
devset status
devset update --dry-run
Manual: https://atomix-labs.github.io/devset/
devset init
Make a directory a target, or a profile or a collection to author
Usage: devset init [OPTIONS] [PATH]
Arguments:
[PATH] The directory, created if missing; this one if not given
Options:
--profile Make it a profile to author, not a target
--collection Make it a collection of profiles to publish, not a target
--dry-run Show what would change; write nothing
-h, --help Print help
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset init this directory, a target to add layers to
devset init hello a new directory, hello/, as a target
devset init my-lint --profile a profile: profile.toml, files/ and a README
devset init acme --collection a source of profiles: collection.toml and profiles/
devset add
Add a profile as a layer, or features to one, and apply it.
Where there is no target yet, `add` starts one: at a git repository's top level, or in an empty
directory; anywhere else, run `devset init` first.
Usage: devset add [OPTIONS] <LAYER|--git <GIT>|--path <PATH>>
Arguments:
[LAYER]
The profile, `source/profile`; with --git or --path, `profile` alone names it in the
source they name
Options:
-h, --help
Print help (see a summary with '-h')
Source:
--git <GIT>
Git repository URL
--tag <TAG>
Tag to use
--branch <BRANCH>
Branch to use
--rev <REV>
Full commit id to use
--path <PATH>
A local directory; with --git, the directory in the repository its profiles are in
Features:
-F, --features <FEATURES>
Features to turn on, beside the default ones; comma-separated or repeated
--no-default-features
Leave the profile's default features off
--default-features
Turn a layer's default features back on
Variables:
--var <NAME=VALUE>
Answer a profile variable; repeatable
--dry-run
Show what would change; write nothing
Global Options:
-q, --quiet
Print only results and errors
--no-input
Never prompt; fail with the flags to pass instead
--no-color
Never colour output
Examples:
devset add atxp/mdbook from a source the target names
devset add atxp/mdbook --features katex with features beside the defaults
devset add atxp/mdbook --features mermaid a feature, to a layer already applied
devset add house/deploy --git git@github.com:acme/profiles --branch main
devset add --path ../profiles/base a source holding one profile
devset remove
Remove a layer, or features from one: unchanged files go, edited ones stay
Usage: devset remove [OPTIONS] <LAYER>
Arguments:
<LAYER> The layer, by its profile's name
Options:
--dry-run Show what would change; write nothing
-h, --help Print help
Features:
-F, --features <FEATURES> Only these features, which the layer lists; the layer stays.
Comma-separated or repeated
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset remove mdbook by its profile's name
devset remove mdbook --features mermaid a feature, keeping the layer
devset remove mdbook --dry-run what would change
devset status
Show where every managed file stands against the profile
Usage: devset status [OPTIONS]
Options:
--exit-code Exit 1 when `apply --force` would write a file, or a run that conflicted is
unfinished
--json Print JSON
-v, --verbose Also list files that match, and the settings in force
-h, --help Print help
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset status what needs doing
devset status -v and everything in sync
devset status --exit-code in CI: fail on drift
devset diff
Show, line by line, how files differ from the profile
Usage: devset diff [OPTIONS] [PATHS]...
Arguments:
[PATHS]... Only these files
Options:
-h, --help Print help
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset diff every file that differs
devset diff deny.toml one file
devset apply
Apply the pinned profiles without destroying local edits.
A run that conflicted, of any command, is finished with `--continue`, once the files in
.devset/conflicts/ are resolved, or taken back with `--abort`.
Usage: devset apply [OPTIONS]
Options:
--force
Also restore `owned` files that were edited, deleted or never recorded; with --abort, also
discard changes made since the conflicted run
--rescaffold <PROFILE/GROUP>
Write the missing files of a scaffold again, `profile/group`; repeatable
--continue
Install the conflicts resolved in .devset/conflicts/
--abort
Take back the run that conflicted: every file it wrote, the lock and the state
--dry-run
Show what would change; write nothing
-h, --help
Print help (see a summary with '-h')
Variables:
--var <NAME=VALUE>
Answer a profile variable; repeatable
Global Options:
-q, --quiet
Print only results and errors
--no-input
Never prompt; fail with the flags to pass instead
--no-color
Never colour output
Examples:
devset apply --dry-run what would change
devset apply --force also restore drifted owned files
devset apply --rescaffold mdbook/book write a scaffold's missing files again
devset apply --continue after resolving .devset/conflicts/
devset apply --abort take back what the conflicted run wrote
devset update
Move sources to newer commits, or to another tag, merging local edits.
A source pinned to a tag stays there: `update` names the releases newer than it, and `--tag`,
`--branch` or `--rev` moves it.
Usage: devset update [OPTIONS] [NAME]
Arguments:
[NAME]
Only the source with this name, or the source of the layer with this name
Options:
-h, --help
Print help (see a summary with '-h')
Move the source:
--tag <TAG>
To this tag
--branch <BRANCH>
To this branch
--rev <REV>
To this commit, by its full id
--dry-run
Show what would change; write nothing
Variables:
--var <NAME=VALUE>
Answer a profile variable; repeatable
Global Options:
-q, --quiet
Print only results and errors
--no-input
Never prompt; fail with the flags to pass instead
--no-color
Never colour output
Examples:
devset update every source
devset update atxp one source, or the source of one layer
devset update atxp --tag <tag> move a source to another release
devset update --dry-run what would change
devset explain
Show why a layer's features are on, or why a file is managed as it is.
For a layer, its features and who turned each on, and those it leaves off; for a file, each layer
that lists it, how it manages it, and its gates.
Usage: devset explain [OPTIONS] [NAME]
Arguments:
[NAME]
A layer, by its profile's name, or a file, from the current directory: `./name` for a file
a layer's name matches
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-q, --quiet
Print only results and errors
--no-input
Never prompt; fail with the flags to pass instead
--no-color
Never colour output
Examples:
devset explain every layer, with its features and who turned each on
devset explain mdbook one layer, with the features it leaves off
devset explain docs/book.toml one file: each layer that lists it, and its gates
devset list
List the profiles a source holds, with their features
Usage: devset list [OPTIONS] [SOURCE]
Arguments:
[SOURCE] A source the target names
Options:
-h, --help Print help
Source:
--git <GIT> Git repository URL
--tag <TAG> Tag to use
--branch <BRANCH> Branch to use
--rev <REV> Full commit id to use
--path <PATH> A local directory; with --git, the directory in the repository its profiles
are in
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset list every source the target names
devset list atxp one of them
devset list --git https://github.com/atomix-labs/atxp --tag v0.20.0
devset completions
Print a shell completion script
Usage: devset completions [OPTIONS] <SHELL>
Arguments:
<SHELL> The shell [possible values: bash, elvish, fish, powershell, zsh]
Options:
-h, --help Print help
Global Options:
-q, --quiet Print only results and errors
--no-input Never prompt; fail with the flags to pass instead
--no-color Never colour output
Examples:
devset completions bash > ~/.local/share/bash-completion/completions/devset
devset completions zsh > ~/.zfunc/_devset
devset completions fish > ~/.config/fish/completions/devset.fish
profile.toml
A profile’s manifest: who it is, what it builds on, its features and variables, and every file it manages. Profiles explains it, and Write a Profile writes one.
Editors complete and check it from its schema, as Schemas says.
[profile]
Identity.
| Key | Takes | Meaning |
|---|---|---|
name | string, required | Names the profile: in its source, among the layers, in output, and in from. |
version | string | For humans; a git source is pinned by commit. |
description | string | One line for humans. |
devset | string | The devset versions the profile works with. |
[requires]
Profiles this one builds on, by name: each a layer before it. Each entry:
| Key | Takes | Meaning |
|---|---|---|
features | list of strings | Its features to turn on. |
default-features | boolean | Whether its default features are on; true unless set. |
optional | boolean | Whether it applies only when a feature activates it. Default false. |
git | string | The repository of another source. |
tag | string | Its tag; only with git. |
branch | string | Its branch; only with git. |
rev | string | Its commit; only with git. |
path | string | The directory in the repository its profiles are in; only with git. |
[features]
What a target or a requirer may turn on. The [features] table: default, and
every feature with what it turns on.
[vars.<name>]
Variables templates and paths use; the target answers them. Each entry:
| Key | Takes | Meaning |
|---|---|---|
prompt | string | What to ask; the name if unset. |
default | string | The answer offered. |
[merge]
Merge defaults.
| Key | Takes | Meaning |
|---|---|---|
on-conflict | apply-others or apply-none | What a run does when a file conflicts. |
driver | string | A merge program, with %O %A %B %P substituted. From a profile it is only a suggestion: devset never runs a program a profile names. |
on-conflict takes:
apply-others: write every other file and advance the lock; conflicts wait in.devset/conflicts/.apply-none: write nothing but the conflicts until every one is resolved.
[scaffolds.<name>]
Groups of starter files, each written once, when none of its sentinels is there. Each entry:
| Key | Takes | Meaning |
|---|---|---|
unless | string or list of strings | Paths or globs of which any, there before the run, means the target has its own: the group is not written. A string or a list; none writes the group when the profile is first applied. |
[files."<path>"]
Managed files, by path in the target, as found under files/; a path may use
variables. Each entry:
| Key | Takes | Meaning |
|---|---|---|
policy | owned, merge or once | How devset manages the file; owned unless set, and once in a scaffold. |
validate | toml, json, yaml or none | How a merged result is checked; inferred from the extension unless set. |
template | boolean | Whether the file is a template, rendered with the answers and the devset object. Default false. |
scope | file, keys or block | How much of the file devset owns: all of it, the keys the payload defines, or a block. Default file. |
comment | #, //, --, ;, %, /* or <!-- | The comment syntax of a block’s markers, where devset does not know the file type’s. |
executable | boolean | Whether devset writes the file executable, mode 755, as a script it ships must be. Default false. |
scaffold | string | The scaffold group the file belongs to, written with it. |
starter | string | For a part, a file under files/ the target’s file starts from when it is absent. |
when | table, below | When the entry applies; always, unless set. |
policy takes:
owned: the profile is authoritative; local edits are drift.merge: local edits are kept, and merged with the profile’s.once: written once if absent; the target’s from then on.
validate takes:
toml: TOML.json: JSON with comments and trailing commas (JSONC), a superset of JSON.yaml: YAML.none: not checked.
scope takes:
file: the whole file.keys: every leaf the payload, a partial document, defines; the file’s other keys are the target’s.block: one comment-marked block, named after the profile; the file’s other lines are the target’s.
comment takes:
#: shells, TOML, YAML, Python,.gitignore.//: C, Rust, JavaScript, JSONC.--: SQL, Lua, Haskell.;: INI, Lisp, assembly.%: TeX, Erlang, MATLAB./*: CSS.<!--: Markdown, HTML, XML.
when
When an entry applies: every condition holds.
| Key | Takes | Meaning |
|---|---|---|
features | list of strings | Features of this profile that are on. |
profiles | list of strings | Profiles active in the target. |
vars | table of lists of strings | Variables of this profile, each answered with one of its values. |
exists | list of strings | Paths or globs that will exist when the run finishes, whole files or parts; may use variables. |
config.toml
A target’s .devset/config.toml: its sources, its layers, and its word on every
setting they carry. Composing Profiles and
Settings explain it.
Editors complete and check it from its schema, as Schemas says.
[sources]
Where profiles come from, each named once. Each entry:
| Key | Takes | Meaning |
|---|---|---|
git | string | Repository URL. |
tag | string | Tag to use; only with git. |
branch | string | Branch to use; only with git. |
rev | string | Full commit id to use; only with git. |
path | string | The profile’s directory: relative to the target, or within the repository with git. |
[[layers]]
Profiles to apply, in order. Each entry:
| Key | Takes | Meaning |
|---|---|---|
profile | string, required | The profile, source/name. |
features | list of strings | Its features to turn on, beside its default ones. |
default-features | boolean | Whether its default features are on; unless a profile that requires it turns them on. |
[merge]
Merge settings; these override every layer’s.
| Key | Takes | Meaning |
|---|---|---|
on-conflict | apply-others or apply-none | What a run does when a file conflicts. |
driver | string | A merge program, with %O %A %B %P substituted. From a profile it is only a suggestion: devset never runs a program a profile names. |
on-conflict takes:
apply-others: write every other file and advance the lock; conflicts wait in.devset/conflicts/.apply-none: write nothing but the conflicts until every one is resolved.
[files."<path>"]
Overrides of the layers’ [files] entries. Each entry:
| Key | Takes | Meaning |
|---|---|---|
from | string | The layer that provides the file, when several do. |
policy | owned, merge or once | How devset manages the file. |
validate | toml, json, yaml or none | How a merged result is checked. |
policy takes:
owned: the profile is authoritative; local edits are drift.merge: local edits are kept, and merged with the profile’s.once: written once if absent; the target’s from then on.
validate takes:
toml: TOML.json: JSON with comments and trailing commas (JSONC), a superset of JSON.yaml: YAML.none: not checked.
collection.toml
What a source says of itself, at its root. Publish a Collection explains it.
Editors complete and check it from its schema, as Schemas says.
[collection]
The [collection] table.
| Key | Takes | Meaning |
|---|---|---|
name | string, required | The name a target gives the source unless it chooses another. |
description | string | One line for humans. |
Settings
A profile sets defaults; the target decides. .devset/config.toml holds the
target’s decisions, beside its layers: how an update handles a conflict, which
merge driver runs, and overrides of the layers’ file entries.
[merge]
on-conflict = "apply-none" # default "apply-others"
driver = "mergiraf merge --git %O %A %B -p %P" # default: the built-in line merge
[files."deny.toml"]
policy = "once"
[files."rustfmt.toml"]
from = "base" # the one layer that provides it, when several do
Precedence
A profile’s [merge] table sets defaults for the target. Where layers set a
setting differently, devset does not choose between them: it refuses until the
target decides, naming the setting.
$ devset add --path ../q
error: p and q set merge.on-conflict differently
|
= help: decide it in .devset/config.toml by setting merge.on-conflict
The target’s [merge] overrides every layer’s, and its [files."<path>"]
tables override the layers’ entries for that path:
| Field | Overrides |
|---|---|
policy | The file’s policy: owned, merge or once. |
validate | How a merged result is checked: toml, json, yaml or none. |
from | The layer that provides the file, or starts it beside every part, when several would: a layer’s name. |
An override of a path no layer provides is an error, so a stale one never
lingers; devset remove takes out the overrides of the layer it removes.
Conflicts
on-conflict says what an update does when a file conflicts: apply-others,
the default, writes every other file and moves the lock, and apply-none writes
nothing but the conflicts until every one is resolved.
Updating and Merging has the whole story.
Merge Drivers
driver names a program that merges instead of the built-in line merge, as
git’s merge drivers do, with git’s placeholders:
| Placeholder | Is |
|---|---|
%O | The base: what devset last wrote |
%A | The target’s version, and where the driver leaves its result |
%B | The profile’s new version |
%P | The file’s path in the target |
%% | A literal % |
Exit zero is a clean merge; any other exit, a conflict whose result is %A.
Either way, devset then checks the result as it checks its own merges.
The line is split into arguments as a shell would split it, then run without a
shell: no quoting pitfalls, and no injection. Each version is written to a
scratch directory under the file’s own name, so a driver can tell the language
from the extension, and %A is always a scratch copy: no driver, however it
behaves, touches the target’s file.
A profile may suggest a driver in its [merge] table. devset tells you, and
never runs it: devset never runs a program a profile names. To use it, set
it in .devset/config.toml.
Output and Exit Codes
What devset prints, where, and what its exit code means; the same for every command.
Where Output Goes
- stdout has what a command answers:
statusand its JSON,diff,explain,listandcompletions. - stderr has everything else: the log of what a command did or would do,
errors, notes, a
help:line with the next step, prompts, and aFetching <url>line before devset reaches a remote.
So devset status | grep edited sees only the status, devset apply 2>/dev/null prints nothing, and a reader that stops early, as devset status | head does, is not an error.
The log reads as cargo’s does: a verb aligned on the right, past tense for what
happened, and a Finished summary. A dry run says Would instead, and ends
nothing written.
Created mise.toml
Merged clippy.toml
Conflicted deny.toml merged, but invalid: duplicate key at line 56
Finished 2 changes, 1 conflict
Errors
An error says what is wrong, then what fixes it. A file that does not parse is shown with the offending line marked; a mistyped layer, variable or path gets a did-you-mean, or else a list of what exists.
error: no layer is named teem
|
= help: did you mean `team`?
Global Options
| Option | Does |
|---|---|
-q, --quiet | Print only results, conflicts and errors: no log, no notes, no fetch lines. |
--no-input | Never prompt; fail with the flags to pass instead. |
--no-color | Never colour output. Setting NO_COLOR does the same. |
devset prompts only when both stdin and stderr are terminals, and git may ask for credentials only when devset may prompt.
Exit Codes
| Code | Means |
|---|---|
0 | Success. |
1 | A merge conflicted; or, under status --exit-code, the target has drifted or an update is unfinished. |
2 | An error, invalid arguments included. |
Schemas
devset publishes a JSON Schema for each file people write, so an editor can complete and check them:
| File | Schema |
|---|---|
profile.toml | schema/profile.json |
.devset/config.toml | schema/config.json |
collection.toml | schema/collection.json |
Each is served beside this manual, at
https://atomix-labs.github.io/devset/schema/<name>.json, and every one of
these files devset writes, with devset init or when devset add starts a
target, names its schema on its first line, as below.
In an Editor
Taplo, and the editors built on it such as VS Code’s Even Better TOML, take a schema from a directive on a file’s first line:
#:schema https://atomix-labs.github.io/devset/schema/profile.json
[profile]
name = "rust"
Or, for every profile in a repository, from taplo.toml:
[[rule]]
include = ["**/profile.toml"]
schema = { path = "https://atomix-labs.github.io/devset/schema/profile.json" }
Platforms and Rust Versions
Where devset’s releases run, where it builds from source, and which Rust it builds with.
Releases
Each release attaches an archive for each platform below, built on that platform
by the release workflow, with its .sha256 and its build attestation.
| Platform | Archive’s target | Notes |
|---|---|---|
| Linux on x86-64 | x86_64-unknown-linux-musl | static: runs on any distribution |
| Linux on Arm64 | aarch64-unknown-linux-musl | static: runs on any distribution |
| macOS on Apple silicon | aarch64-apple-darwin |
These are the platforms the installer, mise and cargo-binstall take a release for. CI runs every check on Linux; the macOS archive is built, and attested, on macOS.
From Source
Elsewhere, cargo install --locked devset-cli builds devset where Rust and git
run, such as macOS on Intel. Windows is not tested yet: a report of what works
there is welcome, as an issue.
Rust Versions
Every crate declares its minimum Rust as rust-version, now 1.98, and just check-rust-msrv builds every crate on that version, with every target and
feature, on every change. The minimum rises only when a newer Rust brings
something devset uses, in a minor release whose changelog says so; a patch
release never raises it. Developing devset takes the nightly its
rust-toolchain.toml pins, which ./setup.sh installs.
What devset Runs
A source in a git repository needs git on your PATH; a local one needs
nothing else. devset runs no other program, and none a profile names.
Comparison
Many tools start a project from a template; fewer keep it in step with the template afterwards. devset is built for the second part: a repository takes several small profiles, each owning whole files or only parts of them, and stays current with each release, keeping its own edits, with drift failing CI.
Each cell below links the page that shows it, in the tool’s own documentation, or, where the documentation says nothing, its source. They were read in September 2026, against copier 9.18, cruft 2.16, projen 0.103, chezmoi 2.72 and cargo-generate 0.25. A cell that is wrong, or out of date, is a bug: a pull request fixing it, with the page that shows it, is welcome.
| devset | copier | cruft | projen | chezmoi | cargo-generate | |
|---|---|---|---|---|---|---|
| Starts a new project | yes | yes | yes | yes | no | yes |
| Takes the template’s later changes | yes | yes | yes | yes | yes | no |
| Keeps the project’s edits through them | yes | yes | yes | no | partly | no |
| Owns part of a file, the rest the project’s | yes | no | no | no | yes | no |
| Several templates, each kept current | yes | yes | no | partly | partly | no |
| A template builds on another | yes | no | no | yes | no | no |
| Optional parts, by feature or answer | yes | yes | partly | yes | yes | yes |
| A drift check for CI | yes | no | yes | yes | yes | no |
| Applies without running the template’s code | yes | partly | no | no | partly | partly |
| Pins the template’s version | yes | yes | yes | yes | partly | yes |
Where a change meets an edit, the tools differ most:
- devset writes the conflict beside the file, in
.devset/conflicts/, and leaves the working file as it was, so a formatter or a build never meets conflict markers;devset apply --continueinstalls the resolution, and--aborttakes the run back. Updating and Merging - copier writes conflict markers into the file by default, or
.rejfiles beside it with--conflict rej. copier - cruft applies the template’s diff with
git apply -3, which writes markers into the file; outside a git repository it falls back to.rejfiles. cruft - projen has nothing to merge: it writes its files again from code, and a hand edit fails its anti-tamper check. projen
- chezmoi asks before overwriting a file changed since it wrote it, and
chezmoi mergeopens a three-way merge tool. chezmoi
What Each Is For
In their own words:
- copier: “a code lifecycle management tool.” (comparisons)
- cruft: “maintain all the necessary boilerplate for packaging and building projects separate from the code you intentionally write.” (key features)
- projen: “Define and maintain complex project configuration through code.” (README)
- chezmoi: “Manage your dotfiles across multiple diverse machines, securely.” (chezmoi.io)
- cargo-generate: “get up and running quickly with a new Rust project by leveraging a pre-existing git repository as a template.” (book)
When You Do Not Need devset
One repository, sharing nothing with another, has nothing for devset to keep in step: a template tool, or no tool, serves it. A project started once from a template and never updated from it is cargo-generate’s or copier’s. A home directory’s dotfiles are chezmoi’s. Configuration written as code, whole files generated and never edited, is projen’s. devset earns its place when several repositories share configuration that each also edits.
FAQ
The questions people ask of tools like devset, and devset’s answers.
Can I Use devset in a Repository It Did Not Create?
Yes. devset add into an existing repository adopts what it finds: a file a
profile owns whole is kept, its differences shown as drift; keys a profile owns
are merged into the repository’s file one by one; a block goes at the end of a
text file. Nothing is overwritten unless you say so, and --dry-run shows the
run first. An Existing Repository goes through it.
Two Profiles Both Need the Same File. What Happens?
Each can own a part of it: different keys of a TOML, JSON or YAML file, or a
block each of a text file, and devset writes both. Where two profiles provide
the same whole file, or the same key, devset refuses to guess: it stops and
names them, and the repository chooses one with from.
Composing Profiles and Settings say how.
I Edited a File devset Manages. Will an Update Clobber It?
No. What happens depends on the file’s policy. Under merge, the edit is the
repository’s: an update merges the profile’s change with it, three ways, against
what devset last wrote. Under owned, the edit is drift: devset status
reports it, an update leaves the file alone, and only devset apply --force
restores the profile’s version. A once file is the repository’s from the first
write. A repository can change a file’s policy for itself, as once to take a
file over. Updating and Merging has the table.
Can I See What an Update Would Do First?
Yes. devset update --dry-run names the newer releases, and devset update <source> --tag <release> --dry-run says what taking one would change, writing
nothing; devset diff shows, file by file, how the repository differs from its
profiles now. Every command that writes takes --dry-run.
An Update Went Wrong. How Do I Undo It?
While a run is unfinished, because a file conflicted, devset apply --abort
takes it back: every file it wrote, the lock and the state return to what they
were. A finished update is an ordinary change in the working tree: review it
with git diff, and discard it as any change is, or move back with devset update <source> --tag <the old tag>.
Update and Resolve Conflicts
Why Is a Conflict Not Written into the File?
Because a file with conflict markers in it breaks whatever reads it: a
formatter, a linter, a build, a CI job. devset writes the conflict to
.devset/conflicts/<path>, with markers, and leaves the working file as it was,
so the repository keeps working while you resolve it. devset apply --continue
installs the resolution, and checks a TOML, JSON or YAML file still parses.
Update and Resolve Conflicts
Does Applying a Profile Run Its Code?
No. A profile is data: files, and when to write them. devset runs no hook, task
or script a profile ships, and installs no tool, so taking a profile needs no
more trust than reading its files. What other tools do in hooks, a profile does
with files: it ships a script, as atxp’s setup.sh is, for the repository to
run when it chooses. A merge driver a profile suggests is only a suggestion,
which the repository must name itself.
I Deleted a Managed File, or a Profile Stopped Shipping One. What Now?
A file you deleted stays deleted: devset status reports it, and devset apply --force brings it back. A file a profile no longer ships is taken away if it is
as devset wrote it, and kept, no longer tracked, if the repository edited it.
Updating and Merging
Why Is This File Managed, and by Which Profile?
devset explain <file> names every layer that lists it, the part each owns, its
policy and its gates, and whether a scaffold wrote it. devset explain <layer>
shows a layer’s features and who turned each on. devset status -v lists every
file, those that match too.
Is Reformatting a Managed File Drift?
Not for what devset owns in part: keys are compared after parsing, so a
reformatted TOML, JSON or YAML file is unchanged. A whole file is compared as
text, except whitespace at the ends of lines, line endings, a BOM and blank
lines at the end, which are never edits, so CRLF against LF is no change; any
other reformatting of an owned file is drift. A profile that ships files as
its formatter leaves them never meets it.
How Do I Keep Many Repositories in Step?
Keep the shared configuration in one collection, and have each repository take
it by tag. A scheduled job in each moves to the newest release and opens a pull
request, as atxp’s weekly bump does; Automate Updates has
the steps, and Share a Team’s Configuration the
layout. In CI, --no-input takes every default and fails on a question with
none; devset status --exit-code fails on drift; and an update that conflicts
exits 1, for the job to take back with devset apply --abort and report.
Output and Exit Codes lists every code.
Can a Repository Skip One File a Profile Ships?
Yes, in .devset/config.toml: policy = "once" on that file makes it the
repository’s after its first write, and from picks another layer’s version
where several provide it. A profile’s feature that brings the file may also be
turned off. Settings
Does devset Need the Network?
Only to fetch a git source it has not fetched before, or to update one. It keeps
each source in its cache, and .devset/lock.toml pins the commit, so apply
and status work offline once a source is fetched. A local directory source
needs nothing.
How Do I Upgrade or Uninstall devset Itself?
The way you installed it: the installer’s line again upgrades, and with sh -s -- --uninstall removes; mise, cargo-binstall and cargo each have their own.
Install has each way’s command, and what uninstalling
leaves. An upgrade moves no repository to a new profile release: devset update
does.
How Do I Stop Using Devset?
Delete .devset/, and whatever runs devset: every file stays as it is.
Stop Using devset
My Question Is Not Here
Ask it in Discussions, under Q&A, where the answer helps the next person too.
Glossary
The words the manual uses, each with the page that says more.
| Word | Meaning |
|---|---|
| answer | A target’s value for a variable, kept in .devset/answers.toml. Templates |
| base | The bytes devset last wrote for a file: what an update merges against. State |
| block | A marked stretch of a text file that a profile owns. Parts of a File |
| bundle | A profile with nothing but requirements. Composing Profiles |
| collection | A source published for others, which may describe itself in collection.toml. Publish a Collection |
| conflict | Where a profile’s change and a local edit touch the same lines; left beside the file, in .devset/conflicts/. Update and Resolve Conflicts |
| drift | A file devset manages that no longer matches what the profile says. In CI |
| feature | An optional, additive capability of a profile. Composing Profiles |
| gate | An entry’s when: the conditions under which it applies. Gates and Scaffolds |
| keys | The keys of a TOML, JSON or YAML file that a profile owns. Parts of a File |
| layer | One active profile of a target: configured by it, or required by a profile. Composing Profiles |
| lock | .devset/lock.toml: the commit each source resolved to, and each layer’s features. State |
| part | The keys, or the block, a profile owns in a file the target otherwise owns. Parts of a File |
| policy | How devset treats a file’s local edits: owned, merge or once. Profiles |
| profile | A versioned bundle of files: profile.toml, and files/ beside it. Profiles |
| requirement | A profile another builds on, by name. Composing Profiles |
| scaffold | A group of starter files, written once when the target has none of its own. Gates and Scaffolds |
| source | Where profiles come from: a git repository at a ref, or a directory. Composing Profiles |
| starter | A file’s first content, written where the target has none, before a profile’s part joins it. Parts of a File |
| target | The directory devset applies profiles to, a repository or not. State |
| template | A file devset renders with the target’s answers and its graph before writing it. Templates |
| variable | A value a profile asks the target for. Templates |