- Rust 96%
- Shell 4%
| src | ||
| tests | ||
| .gitignore | ||
| build.sh | ||
| Cargo.lock | ||
| Cargo.toml | ||
| contain.sh | ||
| README.md | ||
| rust-toolchain.toml | ||
run-with-cache
run-with-cache is a single Rust binary for running commands with on-disk caching.
It replaces both legacy shell scripts:
run-with-cacherun-with-fail-cache
The same binary can do both jobs:
- normal output caching
- failure memoization with
--failure-only
Install
cargo install --git <git-url>
Tutorial
The idea
run-with-cache sits in front of another command.
On the first run, it executes the command normally. On later runs, if the relevant inputs still match, it replays the cached result instead of rerunning the command.
That is most useful when the wrapped command is:
- expensive
- noisy
- flaky
- repeated often with the same inputs
The basic shape
Wrapper options go before the first --.
The wrapped command starts after that --.
run-with-cache [wrapper options] -- command arg1 arg2
Everything after the first -- belongs to the wrapped command, including later -- values.
run-with-cache --some-wrapper-flag -- the_real_command --flag value -- nested --flag
That means this works exactly the way you want:
run-with-cache --cache-dir ~/.cache/my-tool -- \
docker run --rm image-name -- \
nested-command --final-flag
Your first cache
Run something that prints a value:
run-with-cache -- date +%s
Run it again immediately:
run-with-cache -- date +%s
The second run returns the same value because the first result was cached.
A more realistic example
Cache a recursive search:
run-with-cache -- rg TODO src
This is useful when:
- the codebase is large
- you repeat the same query often
- the underlying files have not changed in ways that affect the cache key
Core Concepts
1. Cache location
By default, the cache lives under XDG_CACHE_HOME/run-with-cache or ~/.cache/run-with-cache.
You can override it:
run-with-cache --cache-dir /tmp/run-cache -- rg TODO src
This is useful for:
- per-project caches
- CI jobs
- debugging cache contents
2. Cache lifetime
Cached results expire after --expire-seconds.
run-with-cache --expire-seconds 60 -- curl -fsS https://example.com/data.json
That says:
- reuse the cached result for up to 60 seconds
- rerun after that
3. Wrapper options vs wrapped command options
This is the most important syntax rule.
This is correct:
run-with-cache --expire-seconds 300 -- cargo test -- --nocapture
This means:
--expire-seconds 300configuresrun-with-cachecargo test -- --nocaptureis passed through untouched
4. Dry runs
Use --dry-run to ask whether a command would run.
run-with-cache --dry-run -- cargo check
Possible outcomes:
Cached. Would not run.Not cached. Would run.
5. Clearing cache entries
Use --clear to drop the cache for this command before running it again.
run-with-cache --clear -- cargo test
This is useful when:
- you want a clean rerun
- you suspect a stale cache entry
- you changed something that is not part of the cache key yet
6. Disabling cache behavior
Disable both reads and writes:
run-with-cache --disable -- cargo build
Disable only reads:
run-with-cache --disable-read -- cargo build
Disable only writes:
run-with-cache --disable-write -- cargo build
These are useful when testing behavior or comparing cached vs uncached performance.
What Forms the Cache Key
By default, the cache key is built from:
- the wrapped command and arguments
- the selected working directory
- selected environment variables, if any
- declared dependency files, if any
- optional network state, if enabled
- declared created outputs, if any
If any of those inputs change, the cache key changes too.
1. File dependencies with --depends
Use --depends when a command depends on files that are not obvious from the command line.
run-with-cache --depends Cargo.lock -- cargo build
You can repeat it:
run-with-cache \
--depends Cargo.lock \
--depends rust-toolchain.toml \
-- cargo test
Good uses:
- config files
- lockfiles
- generated inputs
- templates
2. Metadata hashing vs content hashing
Dependency files are hashed by metadata by default. That keeps the common case fast.
If you want stronger invalidation, use full content hashing:
run-with-cache --hash-mode content --depends config.json -- my-command
Use metadata when:
- speed matters most
- file size and mtime are enough
Use content when:
- correctness matters more than speed
- your workflow can preserve mtimes unexpectedly
3. Environment variables with --include-env
Sometimes a command changes behavior based on environment variables.
run-with-cache --include-env RUSTFLAGS -- cargo build
Or several:
run-with-cache --include-env RUSTFLAGS,CARGO_HOME -- cargo build
That makes the cache distinct for each relevant environment value.
Example:
SPECIAL_MODE=fast run-with-cache --include-env SPECIAL_MODE -- ./script.sh
SPECIAL_MODE=safe run-with-cache --include-env SPECIAL_MODE -- ./script.sh
Those are two different cache entries.
4. Network-sensitive commands with --network
If your workflow maintains cached network state files under XDG_CACHE_HOME/state, you can include them:
run-with-cache --network -- some-network-aware-command
This uses:
XDG_CACHE_HOME/state/nmap-scanXDG_CACHE_HOME/state/ip-state
Use this only if your environment actually manages those files.
Output-Aware Caching
Some commands matter not just for stdout/stderr, but also for files they create.
1. Declaring outputs with --creates
Use --creates when the wrapped command is expected to create or refresh files.
run-with-cache --creates target/report.json -- ./generate-report.sh
This does two things:
- verifies that the file exists after the command succeeds
- includes that file in the final cache identity
You can declare several outputs:
run-with-cache \
--creates dist/app.js \
--creates dist/app.css \
-- ./build-assets.sh
2. touch=1
Some tools produce a valid output without updating its mtime in the normal way.
Use touch=1 if the file should count as refreshed even if the tool itself does not touch it clearly.
run-with-cache --creates build/output.bin,touch=1 -- ./package.sh
3. precious=1
If a command fails after creating a file and that file should not be deleted, mark it precious.
run-with-cache --creates logs/partial.txt,precious=1 -- ./unstable-job.sh
Without precious=1, newly created outputs from a failed run are removed when they appear to have been created during that failed run.
4. Empty output as failure with --no-empty
Some commands are only useful if they print something.
run-with-cache --no-empty -- rg TODO src
If stdout is empty, the run is treated as a failure.
Failure Memoization
The old run-with-fail-cache behavior is now:
run-with-cache --failure-only -- command ...
This means:
- successful runs are not cached as failures
- failing exit codes are remembered
- if the same failing command is run again with the same inputs, the failure is returned immediately
Example:
run-with-cache --failure-only -- cargo test
This is useful when:
- a known-bad command is expensive
- you want to avoid repeating the same failing work
- you are polling something that often fails in the same way
Caching Failed Output Too
In normal mode, failed results are not stored unless you ask for that.
Use --preserve-failed if you want to cache a failing command's stdout, stderr, and exit code:
run-with-cache --preserve-failed -- ./failing-script.sh
This is useful when the failure output itself is stable and expensive to reproduce.
Working Directory Rules
The wrapped command runs from one of these locations:
- current directory if you use
--preserve-cwd --cwd <path>if you set one- otherwise the git repo root if one is found
- otherwise your home directory if available
Preserve the current directory
run-with-cache --preserve-cwd -- make test
Force a specific directory
run-with-cache --cwd ./backend -- cargo test
This matters because working directory selection is part of the cache identity.
Examples By Use Case
Cache a slow cargo check
run-with-cache \
--depends Cargo.lock \
--depends rust-toolchain.toml \
--include-env RUSTFLAGS \
-- cargo check
Cache a generated artifact
run-with-cache \
--creates target/schema.json \
-- ./scripts/generate-schema.sh
Cache an API fetch for a short period
run-with-cache \
--expire-seconds 30 \
-- curl -fsS https://example.com/api/items
Memoize a failing integration test
run-with-cache \
--failure-only \
--depends docker-compose.yml \
-- cargo test integration_test_name -- --nocapture
Wrap a command that itself uses --
run-with-cache \
--expire-seconds 300 \
-- cargo test my_test -- --exact --nocapture
Include environment-sensitive behavior
FEATURE_SET=lite run-with-cache --include-env FEATURE_SET -- ./bench.sh
FEATURE_SET=full run-with-cache --include-env FEATURE_SET -- ./bench.sh
Rebuild only when input files change
run-with-cache \
--hash-mode content \
--depends templates/email.html \
--depends config/production.toml \
--creates build/email.txt \
-- ./render-email.sh
Legacy Environment Variables
The Rust binary still supports the legacy CACHE_* environment variables where they still make sense.
Example:
CACHE_DIR=/tmp/run-cache \
CACHE_DEPENDS=Cargo.lock:rust-toolchain.toml \
CACHE_ENV_VARS=RUSTFLAGS,CARGO_HOME \
CACHE_PWD=1 \
run-with-cache -- cargo test
Supported variables:
CACHE_DIRCACHE_EXPIRE_SECONDSCACHE_NETWORKCACHE_PRESERVE_FAILEDCACHE_MD5CACHE_DRY_RUNCACHE_CLEARCACHE_DISABLECACHE_DISABLE_READCACHE_DISABLE_WRITECACHE_DEPENDSCACHE_CREATESCACHE_NO_EMPTYCACHE_ALLOW_EXPLICITCACHE_ENV_VARSCACHE_CWDCACHE_PWD
Prefer CLI flags for new usage. They are easier to read and compose.
Practical Advice
Start simple
Start with this:
run-with-cache -- your-command here
Then add only what you need:
--dependsfor hidden file inputs--include-envfor env-sensitive behavior--createsfor produced files--failure-onlyfor failure memoization
Good candidates
cargo checkcargo testwith fixed inputs- code generators
- expensive searches
- API fetches with short TTLs
Bad candidates
- commands that must always observe live external state
- commands with side effects you always want to happen
- commands whose true inputs are unknown and not declared
Notes
- This crate forbids
unsafe. - No
unsafeis used in the implementation. - The old
stracehook was intentionally dropped because it was Linux-specific, inconsistent in the shell version, and unnecessary for a portable cache core.