Comparison with Mill
Mill is the closest tool in spirit to bleep: same dissatisfaction with sbt, same instinct that the build should be a structured object you can introspect rather than a soup of effects. The two diverge on what "structured object" means.
Build is data vs. build is targets
In Mill, the build is a build.mill file. Scala code. Modules are
classes, settings are methods, and targets are macros that produce a
typed task graph evaluated lazily on demand.
In bleep, the build is YAML. The structure is closed (every field is in the schema). Custom logic doesn't live in the build file at all; it lives in scripts, which are regular Java, Kotlin, or Scala programs.
Same Scala module:
| build.mill (Mill) | bleep.yaml |
|---|---|
| |
Mill's build.mill is more expressive. You can write a method that
computes a setting from another setting, override a target, mix in a
trait that adds behavior. Bleep's YAML is more boring. That's the
trade.
For a build script that's stable enough to live in git for years, the boring side wins on diff legibility and tooling. For a build that's itself an evolving program, bleep is the wrong shape.
Faster everything (still)
Mill 1.x and bleep have the same shape: a native (Graal) launcher in front of a JVM daemon that does the actual compile work. The remaining differences:
| Mill 1.x | Bleep | |
|---|---|---|
| Launcher | Native (Graal) | Native (Graal) |
| Daemon | Persistent Mill server, per build root | bleep-bsp, shared across every workspace on the machine |
| Daemon spin-up | Mill server boot + build.mill compile (several seconds) | JVM warmup + Zinc analysis load (several seconds) |
| Build re-eval after editing | recompile build.mill | re-parse YAML |
| Inspection commands (list projects, show config, etc.) | go through Mill (server roundtrip) | run in the native CLI without touching the daemon |
| Incremental compile | Zinc | Zinc (same engine) |
The first compile in a workspace pays daemon spin-up either way.
After that, Mill reuses the same server per build root; bleep reuses
the same bleep-bsp across every bleep workspace on the machine.
Read-only commands (bleep projects, bleep build show,
bleep build invalidated) skip the daemon entirely on bleep, since
they run in the native CLI. See
Compile servers for the daemon
details.
Targets vs scripts
Mill's typed task graph is genuinely elegant. def myArtifact = T { ... } defines a target, Mill caches its result, and any input
change invalidates the cache automatically. Compose enough of these
and you've got a multi-step pipeline with cross-step caching,
expressed in idiomatic Scala.
The question bleep keeps coming back to is: does that have to live in the build? A task graph is a piece of incremental-compute machinery. The build tool consumes one (for compile, test, sourcegen, link). It doesn't have to be one, and most builds never define a custom target.
Bleep's answer is to push that machinery one layer out, into a
script. A script is a regular Java,
Kotlin, or Scala program; it reads the resolved build model and can
pull in any incremental-build library on the JVM. Mill's T works
as a library: depend on it from the script and you've
reproduced the typed task graph for your pipeline, decoupled from
the build tool itself. (And nothing stops you from triggering a
bleep compile from inside that pipeline if the graph needs it.)
In practice, most builds don't reach for any of that:
- Compile / test / run / publish / link: built-in commands with their own caches. You don't define them.
- Sourcegen: generators that auto-run before the projects that consume them, with timestamp-based invalidation. See Source generation.
- Custom pipelines: write a script. Use Mill's
T, use a plainMap[Hash, Path], use whatever incremental engine fits the task.
The task graph is a great pattern. We just don't think the build tool is the right place to keep it.
What Mill does that bleep doesn't
- Typed task graph in the build file: user-defined
Ttargets with automatic caching, expressed alongside the rest of the build. (In bleep that machinery moves into a script; see Targets vs scripts.) build.millas code: full Scala expressivity for non-trivial build logic.- Module hierarchies via Scala traits:
extends ScalaModule with PublishModule with ScalafmtModulecomposes capabilities. Bleep templates are flatter (key-value merge, not behavior composition). - BSP integration via
mill mill.bsp.BSP/install; bleep's BSP is always on with no install step.
What bleep does that Mill doesn't
- Native CLI for inspection commands. Mill goes through its server even for read-only queries; bleep handles them in the native binary without waking the daemon.
- Cross-workspace daemon sharing. One
bleep-bspserves every bleep build on your machine; Mill servers are per build root. - YAML build that any tool, LLM, or refactor can edit safely.
- Scripts as plain programs rather than
def myTask = T { ... }Scala targets. Equivalent power for the common case, no DSL to learn. - Built-in
bleep importfrom sbt and Maven: Mill has its own importer for some, but bleep's is broader.
When Mill is still the right answer
- You're already on Mill and the cold-start cost doesn't bother you.
- Your build is a task graph, the pipeline of intermediate artifacts is the central thing, and you want the build tool itself to own it rather than a script alongside it.
- You want full Scala expressivity in the build file itself, not pulled out into a scripts project.