DEV Community

Cover image for I wanted Copier in Rust, so I wrote rootstock
Timur Lolaev
Timur Lolaev

Posted on

I wanted Copier in Rust, so I wrote rootstock

I write Rust for a living, and over the last couple of years the tools around me quietly turned into Rust too. On the frontend, Biome does what ESLint and Prettier used to. In Python land it's Ruff and uv. They're fast, they ship as one binary, and there's nothing to set up besides the binary itself.

Project templates were the exception. The best tool I know for them is Copier, and it's genuinely good. But it's Python, so every machine and every CI job that touches a template needs Python and pipx first. I wanted the same thing as one binary. That's rootstock.

Creating a project is the easy part

Plenty of tools can create a project from a template: cargo-generate, kickstart, cookiecutter. They render the files and that's it. From then on the project and the template live separate lives.

That's fine for a one-off. It hurts when the template keeps changing. Think shared CI workflows, lint configs, a service skeleton your team uses everywhere. Someone fixes the CI in the template, and now there are twenty projects with the old version, each with its own local edits. Copying the fix by hand gets old fast.

Copier's answer is to remember how the project was made and replay the template's changes on top of it. rootstock does the same.

What an update looks like

rootstock new renders the template and writes a small record, .rootstock.toml, next to your files: the template source, the tag and exact commit, your answers, and the list of files it wrote.

$ rootstock new gh:acme/service-template my-app --data project_name=my-app --defaults
created my-app from gh:acme/service-template v1.0.0
Enter fullscreen mode Exit fullscreen mode

Later the template ships v1.1.0. It bumps actions/checkout in CI and adds --all-targets to the test and lint targets in the Makefile. Meanwhile you've added a fmt target and made clippy stricter.

$ rootstock update
updated to v1.1.0
markers Makefile
1 conflict
Enter fullscreen mode Exit fullscreen mode

The CI bump and the new test line went in on their own. Your fmt target is still there. The one line you both changed is marked:

lint:
<<<<<<< project
    cargo clippy -- -D warnings
=======
    cargo clippy --all-targets
>>>>>>> template v1.1.0
Enter fullscreen mode Exit fullscreen mode

You fix it, run rootstock check, commit. The update is a plain diff in git, so it's easy to review and easy to throw away.

How the merge works

Under the hood rootstock renders the template twice in temp directories: the old version with the answers from the record (the base), and the new version with the current answers (the target). Then it goes path by path and compares base, target and what's in your project.

If only the template changed a file, you get the template's version. If only you changed it, nothing happens. If both did, the text goes through git merge-file, which is what Copier uses too. I picked the git CLI over a merge library on purpose: the result matches Copier, the markers look like the ones everyone already knows, and your own git config doesn't leak into it.

Some cases can't be merged at all, and rootstock doesn't pretend they can. A binary file you edited keeps your copy and gets the template's version next to it as name.rootstock-new. A file you deleted but the template changed stays deleted and gets reported. Files marked as starter (a README, say) are written once and never touched again.

Want to change an answer later? rootstock update --data ci=false renders the base with the old answers and the target with the new ones, even when the version stays the same.

Conflicts are an exit code

Every case that needs a human ends with exit code 2 and one line per problem on stdout: markers Makefile, binary logo.png and so on. Everything else goes to stderr.

That makes it easy to automate. My harness template (agent tooling, hooks and CI for my projects) has a daily workflow in each project that runs rootstock update, opens a PR and labels it when the exit code is 2. Clean minor and patch updates merge themselves once CI is green. The conflicting ones wait for me.

rootstock check runs in CI without network access and fails on leftover markers or side files, so a half-resolved conflict can't slip into main.

Coming from Copier

rootstock import-copier converts a Copier template, a project generated by Copier, or both if they live in the same directory. It writes nothing unless the whole conversion succeeds. Old template tags don't need converting – update reads versions that only have copier.yml and converts them in memory.

For harness I checked that the converted template renders byte for byte like Copier 9.19 for every combination of its answers. That was my bar for calling the import done.

What it doesn't do (yet)

rootstock supports the part of Copier my templates use. _migrations, Jinja extensions and some question types aren't there, and the import stops with the full list if your template needs them. It's v0.1.0, so expect rough edges.

Try it

cargo install rootstock --locked
Enter fullscreen mode Exit fullscreen mode

There are also prebuilt binaries for Linux (static, musl), macOS and Windows on GitHub Releases. The only thing it needs at runtime is git.

If you maintain templates and something here doesn't fit how you work, open an issue. I'd like to hear about it.

GitHub logo bodasooqa / rootstock

Create projects from versioned git templates and update them by three-way merge, without losing the project's edits. A single Rust binary, like Copier.

rootstock

Project templates that keep growing after you use them.

Create a project from a versioned git template, then update it to every new template version
with a real three-way merge: your edits stay, the template's improvements arrive, conflicts are never silent.

CI crates.io License: MIT OR Apache-2.0

Website · Install · Quick start · How updates work · Commands · Writing a template · Coming from Copier


In grafting, the rootstock is the root system, and the scion is the branch grafted onto it. The scion grows its own way, and the root keeps feeding it. A rootstock project works the same way. The template is the root and the project is the scion: the project goes its own way, and improvements to the template keep reaching it.

Why rootstock

Most scaffolding tools stop once the project is created. That is fine for a one-off, but not for a template that keeps changing: shared CI, lint…




Top comments (0)