Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 update moves 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-code fails 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:

managed with devset

[![managed with devset](https://img.shields.io/badge/managed_with-devset-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHRpdGxlPmRldnNldDwvdGl0bGU+PHBhdGggZmlsbD0iI2YwZjZmYyIgZD0ibTE2IDMgMTMgNi41TDE2IDE2IDMgOS41WiIvPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2YwZjZmYyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIuNSIgZD0ibTMgMTYgMTMgNi41TDI5IDE2TTMgMjIuNSAxNiAyOWwxMy02LjUiLz48L3N2Zz4K)](https://github.com/atomix-labs/devset)

Next

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

OptionDoes
-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.
--uninstallRemoves the binary it would install.
DEVSET_VERSIONThe environment’s way to say -v.
DEVSET_INSTALL_DIRThe environment’s way to say -b.
DEVSET_NO_ATTEST=1Skips 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 useInstall
misemise use -g github:atomix-labs/devset
cargo-binstallcargo binstall devset-cli
cargocargo 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 withUpgrade with
the installerthe same line again, for the latest; with -v <version>, for another
misemise upgrade github:atomix-labs/devset
cargo-binstallcargo binstall devset-cli
cargocargo 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 withUninstall with
the installerthe same line with sh -s -- --uninstall, and the same -b if you gave one
misemise unuse -g github:atomix-labs/devset
cargo-binstallcargo uninstall devset-cli
cargocargo 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 status then shows it edited, with what differs. Keep it, and it stays drift; take the profile’s with devset apply --force, which restores owned files only; or, when another layer should provide it, choose with from.
  • 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 diff shows each value the profile would change.
  • A block is written at the end of a text file, a .gitignore or an AGENTS.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 just profile is, owns names such as check, test and nightly. A repository’s own recipe by one of those names clashes with it: rename it <verb>-<name>, as test-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/** or CHANGELOG.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 diff shows whether the difference is worth keeping, and apply --force takes 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:

FieldHolds
layersEach layer, in order: name, profile as source/name, features, configured, required_by, version, source, rev, digest, and applied, one of current, changed and never
filesEach 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
settingson-conflict, and the merge driver: builtin, or its command line
answersEvery variable’s answer
suggestionsMerge drivers a layer suggests that the target has not set
driftedWhether apply --force would write a file
unfinishedWhether 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, which status reports and apply --force restores.
  • 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

UnitCrate analogyUse it for
Sourceworkspace, registryprofiles released together under one ref
Profilecratea concern a repository adopts, updates or removes as a unit
FeatureCargo featurean optional, additive capability of that concern
Variableconfiguration valuea value per repository, or a choice between alternatives
Requirementdependencya concern this one cannot work without
Optional requirementoptional dependencya capability that needs another concern: ["dep:lychee"]
Bundlefacade crateonly requirements, with features that describe a kind of project
Scaffolda templatestarter content that becomes the project’s once written
Managed entrya dependency’s codecontent that stays in sync with the source

The Tests

Ask them in order:

  1. Would a repository want one without the other? Then two profiles. If they always go together and edit the same files, one.
  2. 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.
  3. Does the description join unrelated things with “and”? Then two profiles.
  4. Does it only make sense with its concern? Then a feature of that concern, not a profile of its own.
  5. 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.
  6. 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 mdbook profile: its scaffold writes the book when the project has none, its managed keys keep book.toml wired 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.0 and 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 rust bundle requires the concerns every Rust repository has, and its features name kinds of project: docs turns on the book, publish the crates.io profile, binaries the release archives.
  • House rules. Rules one team wants and others would not are a strict feature 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 exists attaches to what the project, or another profile, has: a block in AGENTS.md only where there is an AGENTS.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 = false on 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

  1. Find the newest release. devset update --dry-run names 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/||')
    
  2. Take it. devset update acme --tag "$latest" moves the pin in .devset/config.toml and merges every change with the repository’s edits.

  3. Take a conflict back. An update that conflicts exits 1 and leaves the conflict in .devset/conflicts/; devset apply --abort returns 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.

  4. Check. Run the repository’s checks, so the pull request says whether the release passes.

  5. 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 status or devset update;
  • a recipe or script that runs it, as atxp’s check-devset, and a pin of devset in mise.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

FieldMeaning
nameRequired. Names the profile: in its source, among a target’s layers, and in output.
versionFor people; a git source is pinned by its commit, not by this.
descriptionOne line, for people; devset list shows it.
devsetThe 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:

FieldMeaningChapter
policyHow local edits are treated: owned, merge or once.below
scopeHow much of the file the profile owns: file, keys or block.Parts of a File
commentThe comment syntax of a block’s markers.Parts of a File
starterFor a part, the file the target’s starts from when it is absent.Parts of a File
templateRender the file with the answers and the graph.Templates
whenWhen the entry applies: features, profiles, variables, paths.Gates and Scaffolds
scaffoldThe scaffold group the file is a starter of.Gates and Scaffolds
executableWrite the file with mode 755.below
validateHow a merged file is checked: toml, json, yaml or none.Updating and Merging

Policies

PolicyMeaning
ownedThe default. The profile is authoritative; local edits are drift.
mergeLocal edits are kept, and merged with the profile’s changes.
onceWritten if absent; the target’s from then on.

What devset apply does with each file, by what is on disk:

On diskapplyapply --force
absent, never writtenwrite itwrite it
present, never writtenadopt it: record the profile’s version, keep the fileoverwrite it
unchanged: as devset wrote itwrite it, if the profile’s version changedthe same
cosmetic: changed in whitespace onlywrite it, if the profile’s version changedthe same
editedkeep it; status reports itrestore it
missing: deletedrespect the deletion; status reports itrestore 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:

ScopeThe profile ownsFor
fileThe default. The whole file.Configurations with one owner
keysEvery key its payload definesTOML, JSON and JSONC; YAML, experimental
blockThe 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 once file 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 scaffolds Cargo.toml, others add their lint tables and release profile to it, and the file is written once, whole.

  • A part’s own starter, starter on its entry, names a file under files/ 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:

NameIs
devset.targetthe target directory’s name
devset.profilesevery active profile’s name
devset.featuresthis profile’s features that are on
devset.layerseach 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"] } }
ConditionHolds when
featuresevery listed feature of this profile is on
profilesevery listed profile is active in the target
varseach listed variable of this profile is answered with one of its values
existseach 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 written when none of its sentinels is there, and found when one is: the project has its own, and the group is left out. state.toml records 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/book writes the group’s missing files again, whatever was decided, and adopts the ones that are there.
  • Where it was written. state.toml records 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. default lists the features on unless the layer, or a requirer, sets default-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 fileownedmergeonce
as devset wrote itupdatedupdatednothing
editedkept, and reported as driftmergednothing
deletedthe deletion kept, and reportedthe deletion keptnothing

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-conflictThe rest of the update
apply-others (default)Written, and the lock moves; the conflicts wait
apply-noneWithheld 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 fileapply
as devset wrote itremoves it, and directories it leaves empty
editeduntracks it: the edit is the target’s
deleteduntracks it
written under onceuntracks 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.

PathHoldsIn git
config.tomlThe sources, the layers, [merge] settings, and file overrides: yourscommit
lock.tomlEach source’s commit; each layer’s digest, and its features and who turned them oncommit
answers.tomlThe answers to the profiles’ variablescommit
state.tomlWhat devset last wrote, per file and part: two digests each; each scaffold’s decision, and where it wrote its filescommit
base/The bytes devset last wrote, one blob per content digestcommit
.gitignoredevset’s own ignore rulescommit
.gitattributesKeeps base/ out of diffs and line-ending conversioncommit
conflicts/An unfinished update: its sidecars, and what to take it backignored
.lockHeld while devset writes, so two runs never interleaveignored

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.

KeyTakesMeaning
namestring, requiredNames the profile: in its source, among the layers, in output, and in from.
versionstringFor humans; a git source is pinned by commit.
descriptionstringOne line for humans.
devsetstringThe devset versions the profile works with.

[requires]

Profiles this one builds on, by name: each a layer before it. Each entry:

KeyTakesMeaning
featureslist of stringsIts features to turn on.
default-featuresbooleanWhether its default features are on; true unless set.
optionalbooleanWhether it applies only when a feature activates it. Default false.
gitstringThe repository of another source.
tagstringIts tag; only with git.
branchstringIts branch; only with git.
revstringIts commit; only with git.
pathstringThe 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:

KeyTakesMeaning
promptstringWhat to ask; the name if unset.
defaultstringThe answer offered.

[merge]

Merge defaults.

KeyTakesMeaning
on-conflictapply-others or apply-noneWhat a run does when a file conflicts.
driverstringA 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:

KeyTakesMeaning
unlessstring or list of stringsPaths 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:

KeyTakesMeaning
policyowned, merge or onceHow devset manages the file; owned unless set, and once in a scaffold.
validatetoml, json, yaml or noneHow a merged result is checked; inferred from the extension unless set.
templatebooleanWhether the file is a template, rendered with the answers and the devset object. Default false.
scopefile, keys or blockHow 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.
executablebooleanWhether devset writes the file executable, mode 755, as a script it ships must be. Default false.
scaffoldstringThe scaffold group the file belongs to, written with it.
starterstringFor a part, a file under files/ the target’s file starts from when it is absent.
whentable, belowWhen 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.

KeyTakesMeaning
featureslist of stringsFeatures of this profile that are on.
profileslist of stringsProfiles active in the target.
varstable of lists of stringsVariables of this profile, each answered with one of its values.
existslist of stringsPaths 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:

KeyTakesMeaning
gitstringRepository URL.
tagstringTag to use; only with git.
branchstringBranch to use; only with git.
revstringFull commit id to use; only with git.
pathstringThe profile’s directory: relative to the target, or within the repository with git.

[[layers]]

Profiles to apply, in order. Each entry:

KeyTakesMeaning
profilestring, requiredThe profile, source/name.
featureslist of stringsIts features to turn on, beside its default ones.
default-featuresbooleanWhether its default features are on; unless a profile that requires it turns them on.

[merge]

Merge settings; these override every layer’s.

KeyTakesMeaning
on-conflictapply-others or apply-noneWhat a run does when a file conflicts.
driverstringA 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:

KeyTakesMeaning
fromstringThe layer that provides the file, when several do.
policyowned, merge or onceHow devset manages the file.
validatetoml, json, yaml or noneHow 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.

KeyTakesMeaning
namestring, requiredThe name a target gives the source unless it chooses another.
descriptionstringOne 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:

FieldOverrides
policyThe file’s policy: owned, merge or once.
validateHow a merged result is checked: toml, json, yaml or none.
fromThe 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:

PlaceholderIs
%OThe base: what devset last wrote
%AThe target’s version, and where the driver leaves its result
%BThe profile’s new version
%PThe 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: status and its JSON, diff, explain, list and completions.
  • 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 a Fetching <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

OptionDoes
-q, --quietPrint only results, conflicts and errors: no log, no notes, no fetch lines.
--no-inputNever prompt; fail with the flags to pass instead.
--no-colorNever 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

CodeMeans
0Success.
1A merge conflicted; or, under status --exit-code, the target has drifted or an update is unfinished.
2An error, invalid arguments included.

Schemas

devset publishes a JSON Schema for each file people write, so an editor can complete and check them:

FileSchema
profile.tomlschema/profile.json
.devset/config.tomlschema/config.json
collection.tomlschema/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.

PlatformArchive’s targetNotes
Linux on x86-64x86_64-unknown-linux-muslstatic: runs on any distribution
Linux on Arm64aarch64-unknown-linux-muslstatic: runs on any distribution
macOS on Apple siliconaarch64-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.

devsetcopiercruftprojenchezmoicargo-generate
Starts a new projectyesyesyesyesnoyes
Takes the template’s later changesyesyesyesyesyesno
Keeps the project’s edits through themyesyesyesnopartlyno
Owns part of a file, the rest the project’syesnononoyesno
Several templates, each kept currentyesyesnopartlypartlyno
A template builds on anotheryesnonoyesnono
Optional parts, by feature or answeryesyespartlyyesyesyes
A drift check for CIyesnoyesyesyesno
Applies without running the template’s codeyespartlynonopartlypartly
Pins the template’s versionyesyesyesyespartlyyes

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 --continue installs the resolution, and --abort takes the run back. Updating and Merging
  • copier writes conflict markers into the file by default, or .rej files 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 .rej files. 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 merge opens 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.

WordMeaning
answerA target’s value for a variable, kept in .devset/answers.toml. Templates
baseThe bytes devset last wrote for a file: what an update merges against. State
blockA marked stretch of a text file that a profile owns. Parts of a File
bundleA profile with nothing but requirements. Composing Profiles
collectionA source published for others, which may describe itself in collection.toml. Publish a Collection
conflictWhere a profile’s change and a local edit touch the same lines; left beside the file, in .devset/conflicts/. Update and Resolve Conflicts
driftA file devset manages that no longer matches what the profile says. In CI
featureAn optional, additive capability of a profile. Composing Profiles
gateAn entry’s when: the conditions under which it applies. Gates and Scaffolds
keysThe keys of a TOML, JSON or YAML file that a profile owns. Parts of a File
layerOne 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
partThe keys, or the block, a profile owns in a file the target otherwise owns. Parts of a File
policyHow devset treats a file’s local edits: owned, merge or once. Profiles
profileA versioned bundle of files: profile.toml, and files/ beside it. Profiles
requirementA profile another builds on, by name. Composing Profiles
scaffoldA group of starter files, written once when the target has none of its own. Gates and Scaffolds
sourceWhere profiles come from: a git repository at a ref, or a directory. Composing Profiles
starterA file’s first content, written where the target has none, before a profile’s part joins it. Parts of a File
targetThe directory devset applies profiles to, a repository or not. State
templateA file devset renders with the target’s answers and its graph before writing it. Templates
variableA value a profile asks the target for. Templates