# stale - Complete API Documentation > A CLI tool to run or skip commands based on file content hashes **Version:** 0.2.0 **License:** Apache-2.0 **Repository:** https://github.com/th1nkful/stale **Dependencies:** - glob (0.3) - clap (4.5) [features: derive] - anyhow (1.0) - serde_json (1.0) - toml (0.8) - xxhash-rust (0.8) [features: xxh3] Generated: 2026-03-18 01:39:15 UTC Created by: [cargo-llms-txt](https://github.com/masinc/cargo-llms-txt) ## Table of Contents ### src/lib.rs - pub fn resolve_pkg_version - pub fn expand_globs - pub fn compute_hash - pub fn compute_hash_verbose - pub fn derive_name - pub fn find_git_root - pub fn load_sum_entry - pub fn save_sum_entry - pub fn find_duplicate_entries --- ## README.md ### stale A simple Rust-based CLI tool that accepts file path/glob inputs and runs or skips a bash command depending on whether the watched files have changed since the last successful run. #### How it works `stale` computes a combined xxHash3 hash over all files matched by the supplied glob patterns and compares it to an entry in a `.sum` file stored from the previous run. - **Files changed** (or no stored state) → the command is executed. On success the new hash is saved. - **Files unchanged** → the command is skipped and `stale` exits `0`. When no command is supplied `stale` exits `0` if files are unchanged and `1` if they have changed, so it composes naturally with shell `&&` / `||`. #### Installation ##### Shell installer (Ubuntu / Linux / macOS) ```bash curl -fsSL https://raw.githubusercontent.com/th1nkful/stale/main/install.sh | sh ``` Install a specific version or to a custom directory: ```bash curl -fsSL https://raw.githubusercontent.com/th1nkful/stale/main/install.sh | STALE_VERSION=0.2.0 sh curl -fsSL https://raw.githubusercontent.com/th1nkful/stale/main/install.sh | INSTALL_DIR=/usr/local/bin sh ``` ##### Homebrew (macOS/Linux) ```bash brew tap th1nkful/stale https://github.com/th1nkful/stale.git brew install th1nkful/stale/stale ``` ##### From source ```bash cargo install --path . ``` #### Usage ``` stale [OPTIONS] ... [-- ...] ``` ##### Arguments | Argument | Description | |---|---| | `...` | One or more file paths or glob patterns to watch | | `-- ...` | Command to execute when files have changed | ##### Options | Flag | Description | |---|---| | `-f, --sum-file ` | Path to the `.sum` file (default: `.stale.sum` at the git root, or the current directory if not inside a git repository) | | `-n, --name ` | Named entry in the sum file (default: short hash of the glob patterns and, when using git-root discovery, the working directory relative to the repository root) | | `-s, --string ` | Extra string(s) to include in the hash (e.g. version numbers, environment variables) | | `-p, --pkg ` | Look up a package version and include it in the hash (format: `manager:package`, e.g. `npm:express`, `uv:requests`) | | `--force` | Always run the command, even if files are unchanged | | `--skip-cleanup` | Skip the automatic removal of git conflict markers from the sum file | | `-v, --verbose` | Print per-file hashes and status messages | | `-h, --help` | Print help | | `-V, --version` | Print version | ##### Package managers (`--pkg`) | Prefix | File parsed | Example | |---|---|---| | `npm` / `js` | `package.json` | `npm:express`, `js:react` | | `uv` / `py` / `python` | `uv.lock` | `uv:requests`, `py:flask` | Adding a new package manager requires only a new match arm and resolver function in `lib.rs`. #### The `.sum` file State is stored in a plain text `.sum` file (default `.stale.sum`). By default `stale` walks up the directory tree to find the closest git repository root (a directory containing a `.git` entry — either a directory or a file, as used by worktrees and submodules) and places the file there, so you get a single `.stale.sum` per repository instead of one in every directory. The search stops at the user's home directory (`$HOME` / `%USERPROFILE%`) to avoid escaping the project tree. If no git root is found, the file is stored in the current directory. You can override this with `-f`. The file contains one ` ` entry per line: ``` a41dcbdfa685 e2ce01154a1476fa317b0ba5eb6b3563a3ea01e29201916212e3fef764d64c38 lint 3f4b2c9d1a8e7b6f0d5c2a1e9f8b7a6d5e4c3b2a1f0e9d8c7b6a5f4e3d2c1b0 test 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8 ``` - When no `--name` is given, the name is derived from a short hash of the glob patterns and the working directory relative to the git root — the same invocation from the same directory always reuses the same entry, while different subdirectories get distinct entries to avoid collisions. - Multiple invocations in the same directory (e.g. for lint and test) each get their own named entry in the shared `.stale.sum` file. - You can add `.stale.sum` to `.gitignore` or commit it to share the baseline state with your team. If `.stale.sum` is committed and a merge conflict occurs, git may leave conflict markers in the file. When stale next updates the file it automatically strips those markers so the sum file returns to a clean state. Pass `--skip-cleanup` to disable this conflict-marker stripping; the file is still rewritten and sorted, and any remaining conflict-marker lines are appended at the end of the file. #### Examples ```bash ### Re-run cargo test only when .rs source files change stale 'src/**/*.rs' -- cargo test ### Rebuild a Docker image only when relevant files change stale Dockerfile 'src/**' -- docker build -t myapp . ### Track lint and test independently in the same directory stale --name lint 'src/**/*.rs' -- cargo clippy stale --name test 'tests/**' -- cargo test ### Use a custom sum file stale -f .ci.sum 'src/**' -- make build ### Re-run tests when a specific package version changes stale -p npm:express 'src/**' -- npm test ### Re-run when a Python package is upgraded stale -p uv:requests '*.py' -- pytest ### Multiple package versions stale -p npm:express -p npm:react 'src/**' -- npm test ### Arbitrary version strings stale -s "$(jq -r '.dependencies.express' package.json)" 'src/**' -- npm test ### Environment-dependent strings stale -s "$NODE_ENV" -s "$(cat .tool-versions)" 'src/**' -- make build ### Shell composition: run a command only when files have changed stale 'src/**/*.rs' || cargo build ### Shell composition: confirm nothing has changed stale 'config/**' && echo "Config is up to date" ### Force a run regardless of file state stale --force 'src/**' -- cargo build ### Verbose output showing per-file hashes stale -v 'src/**/*.rs' -- cargo test ``` #### Exit codes | Code | Meaning | |---|---| | `0` | Files unchanged **or** command ran successfully | | `1` | Files changed (when no command is given) | | `2` | stale encountered an error | | other | Exit code forwarded from the executed command | --- ## src/lib.rs ### resolve_pkg_version ```rust pub fn resolve_pkg_version(query: &str, base_dir: &Path) -> Result ``` Resolve a package version query in the form `"manager:package_name"`. Supported managers: | Prefix | File parsed | Example | |---------------|------------------|----------------------------| | `npm` / `js` | `package.json` | `npm:express`, `js:react` | | `uv` / `py` | `uv.lock` | `uv:requests`, `py:flask` | Returns the resolved version string (e.g. `"^4.18.0"`). Adding a new package manager only requires a new match arm and a small resolver function — the rest of the pipeline is unchanged. ### expand_globs ```rust pub fn expand_globs(patterns: &[String]) -> Result> ``` Expand one or more glob patterns into a sorted, deduplicated list of file paths. ### compute_hash ```rust pub fn compute_hash(files: &[PathBuf], extra_strings: &[String]) -> Result ``` Compute a combined xxHash3-128 hash over the given files and optional extra strings. The hash is built by feeding each file's path and its contents into the hasher in sorted order, followed by any extra strings, so the result is deterministic. Uses xxHash3-128 for speed — roughly 10–20× faster than SHA-256 on modern hardware — while providing 128 bits of collision resistance, which is more than sufficient for change detection even in large repos with long-lived `.sum` files. ### compute_hash_verbose ```rust pub fn compute_hash_verbose(files: &[PathBuf], extra_strings: &[String]) -> Result<(String, BTreeMap)> ``` Compute a combined xxHash3-128 hash over the given files and optional extra strings, returning a per-file breakdown alongside the combined hash. The combined hash feeds the same data as [`compute_hash`] and therefore always produces an identical result. Per-file hashes are computed as a side-effect using xxHash3-64 on each file's raw contents (without the path prefix), so file contents are effectively hashed twice — once for the per-file digest and once as part of the combined stream. This is intentional: it keeps the combined hash identical to [`compute_hash`] while still providing useful per-file breakdowns for `--verbose` output. ### derive_name ```rust pub fn derive_name(patterns: &[String], extra_strings: &[String], prefix: Option<&str>) -> String ``` Derive a stable short name from a list of glob patterns and optional extra strings. The name is the first 12 hex characters of the xxHash3 hash of the sorted, newline-joined patterns followed by any extra strings. This gives a deterministic identifier so repeated invocations with the same patterns and strings always map to the same entry in the `.sum` file without requiring the user to supply `--name`. When `prefix` is supplied (e.g. the working directory relative to the git root), it is mixed into the hash so that running the same glob patterns from different subdirectories produces distinct entries and avoids collisions in a shared `.sum` file. ### find_git_root ```rust pub fn find_git_root(start: &Path, ceiling: Option<&Path>) -> Option ``` Discover the closest git repository root by walking up from `start`. Returns `Some(path)` when a directory containing a `.git` entry is found. The `.git` entry may be either a directory (normal repositories) or a file (git worktrees and submodules). When `ceiling` is provided the search stops before reaching that directory, preventing the walk from escaping beyond a known boundary (e.g. the user's home directory). If `ceiling` is `None` the walk continues to the filesystem root. ### load_sum_entry ```rust pub fn load_sum_entry(path: &Path, name: &str) -> Result> ``` Look up the hash stored for `name` in the `.sum` file at `path`. The file format is one ` ` pair per line; lines starting with `#` are treated as comments. Conflict marker lines are always skipped. Returns `None` if the file does not exist or the name is not present. ### save_sum_entry ```rust pub fn save_sum_entry(path: &Path, name: &str, hash: &str, skip_cleanup: bool) -> Result<()> ``` Write (or update) the `name` entry in the `.sum` file at `path`. The file is always rewritten with all entries sorted by name so the output is stable and deterministic regardless of insertion order. Conflict marker lines (`<<<<<<<`, `=======`, `>>>>>>>`) are **never** parsed as name/hash entries. When `skip_cleanup` is `false` (the default) they are silently dropped from the rewritten file. When `skip_cleanup` is `true` they are collected and appended verbatim at the end of the rewritten file, preserving them for manual resolution. ### find_duplicate_entries ```rust pub fn find_duplicate_entries(path: &Path) -> Result> ``` Scan the sum file for entry names that appear more than once. Duplicate names can arise when a merge conflict leaves two versions of the same entry in the file (one from each side of the conflict). Conflict marker lines are always skipped, so only genuine ` ` entries are considered. Returns the list of names that have more than one entry; the list is empty when the file is clean. ## src/bin/test_helper.rs ## src/main.rs