Caching
Declare what a task reads and writes, and outdo skips it when nothing changed:
build: {
desc: "Build the project",
inputs: ["src/**/*.ts", "tsconfig.json"],
outputs: ["dist/**"],
run: async ({ $ }) => {
await $`bun build src/index.ts --outdir dist`;
},
},What's in the fingerprint
A task is skipped only when all of these are unchanged:
- Input file contents — real content hashes with an mtime+size fast path:
touchwon't bust the cache, actual edits will - The task definition itself — including the text of the
runbody (or the command); edit the code, invalidate the cache - Validated env output — change
DEPLOY_URLand the task re-runs (only for tasks with anenvschema) - CLI args and
--passthrough - Dependency fingerprints — upstream changes cascade downstream automatically
- Dependency data outputs — a dep whose
run()returned a different value invalidates its consumers, even when the dep itself isn't cacheable
Plus: every declared outputs glob must still match at least one file — or be restorable.
Each component is hashed separately and stored in the manifest, which is what lets outdo explain tell you exactly which one changed.
What a hit gives you
On a hit the task doesn't run (✓ build (cached) in ~0ms), and three things come back from the last successful run:
- its data output, replayed from the manifest, so dependents keep working across cache skips
- its declared outputs, when you deleted them (below)
- its log, printed again — for runs that used child processes (parallel plans,
--isolate,outdo ci), outdo keeps the last 2,000 output lines of each cacheable task
Deleted outputs come back
After a successful run, outdo copies the files matched by outputs into .outdo/cache/ (the latest run only). When the fingerprint still matches but the outputs are gone, it copies them back instead of re-running:
$ rm -rf dist && outdo build
✓ build (cached, outputs restored)- Only a complete copy is restored; otherwise the task re-runs.
- Outputs larger than 256 MB in total are not stored — those tasks re-run when their outputs go missing.
--dryandexplainnever write files; they report a restorable task as "would skip".--forcealways re-runs (and stores fresh copies).
This is a local cache: nothing is shared between machines. That's still Turborepo/Nx territory — see Why outdo?.
Cache state lives in .outdo/cache/ next to your do.ts.
WARNING
Add .outdo/ to .gitignore — outdo init does this for you. The cache is per-machine state, not shared artifacts.
Why will this re-run?
Stop guessing — ask:
outdo explain buildbuild — Build the project
backend: sequential (in-process)
plan (dependencies first):
✓ codegen would skip (cached)
↻ build would run — inputs changed (modified: src/app.ts)
details for build:
deps: codegen
inputs: src/**/*.ts (14 files), tsconfig.json (1 file)
outputs: dist/** (present)
env: no schema
cache: miss — inputs changed (modified: src/app.ts)explain walks the plan read-only (nothing runs, nothing is written) and diagnoses each task's cache verdict by fingerprint component: no previous run, inputs changed (with the exact files), env changed, args changed, task definition changed, dependency outputs changed, declared outputs missing, or --force. Add --json for the machine-readable version.
Controlling it
outdo --force build # ignore stored fingerprints (still records new ones)
outdo --dry build # shows which planned tasks would be cache-skipped
outdo explain build # explains *why* each task will run or skipTasks without inputs never cache — they run every time. Services (persistent: true) and aggregation tasks (no run) never cache either. Command tasks and package.json scripts cache like any other task once you give them inputs (for a script, through a do.ts entry or member defaults).
Known blind spot: closures
The fingerprint hashes the run function's source text. A value captured from outside the function body (a top-level constant, an import) can change without the text changing. Keep task-relevant values inside the body, model them as env/args, or use --force when in doubt.