sr rewrites text across a whole tree of files and keeps a journal of every
change, so any run can be inspected, diffed and undone.
It is built for the jobs where sed -i is too blunt and an editor's
project-wide replace is too manual: retiring an API, bumping versions in a dozen
manifests, migrating a config key, normalising encodings, bulk-renaming files.
$ sr find "old_api" -p '*.py' # where is it?
src/app.py:12:5: result = old_api.call()
src/util.py:3:1: import old_api
2 matches in 2 files (scanned 128) in 41ms
$ sr diff "old_api" "new_api" -p '*.py' # what exactly would change?
--- a/src/app.py
+++ b/src/app.py
@@ -12 +12 @@
-result = old_api.call()
+result = new_api.call()
$ sr replace "old_api" "new_api" -p '*.py'
[ok] src/app.py: 1 replacement(s)
[ok] src/util.py: 1 replacement(s)
Session 20260131T120530Z-9f3a1c
Rollback sr rollback 9f3a1c
$ sr rollback # changed your mind?
[ok] session 9f3a1c rolled back (2 files)- Why sr
- Install
- Quick start
- Commands
- Matching
- Choosing files
- Sessions, backups and rollback
- Automation: JSON, porcelain, exit codes
- Configuration
- Self-update
- Shell completion and man page
- Option reference
- Safety model
- Performance
- Troubleshooting
- Architecture
- Development
- Migrating from sr 6.x
- License
Most search-and-replace tools are thin wrappers around grep + sed. That
design has four consequences that show up exactly when the edit is large enough
to matter:
| The problem | What sr does instead |
|---|---|
Counting and replacing disagree. grep -c counts lines, sed s///g replaces occurrences, and -i/-w/-E are applied to one but not the other. The number you are shown is not the number of edits made. |
One matcher does the counting, the previewing and the replacing. -i, -w, --max-count and --line-range behave identically in find, diff and replace. |
Patterns are silently regexes. A search for 1.2.3 also matches 1x2y3; a $ in the replacement expands to something you did not write. |
Literal by default. . is a dot, $1 is $1. Regular expressions are opt-in (--regex, --ere, --pcre) and say so in every report. |
No undo. sed -i rewrites in place; the .bak files (if any) are a flat pile with no record of which run produced them. |
Every mutating run creates a session: content-addressed originals, a manifest with per-file SHA-256, an append-only journal and a diff. sr rollback restores it, detects files edited since, and journals the rollback itself so that can be undone too. |
| The blast radius is discovered afterwards. | The scan phase completes before the first write. The confirmation prompt, the progress bar and the summary all state real numbers, and --dry-run has literally no side effects — not even a .sr/ directory. |
Plus the things that make it usable in a pipeline: stable documented exit codes,
--json, --porcelain, --yes, an advisory lock so two runs cannot
interleave, encoding/BOM/line-ending preservation, and no third-party runtime
dependencies at all.
sr is a single-file Python application. Python 3.10 or newer is the only
requirement — no packages to install, no compilation, no service. Tested on
3.10 through 3.14; see Platforms for the OS story.
curl -fsSL https://raw.githubusercontent.com/paulmann/sr-search-replace/main/scripts/install.sh | bashThe installer resolves the newest release, downloads sr.pyz and sr.pyz.sha256,
verifies the checksum, smoke-tests the download and installs it to
/usr/local/bin/sr (override with --prefix ~/.local/bin). A failed
verification installs nothing.
Manual install (air-gapped machines)
curl -fsSLO https://github.com/paulmann/sr-search-replace/releases/latest/download/sr.pyz
curl -fsSLO https://github.com/paulmann/sr-search-replace/releases/latest/download/sr.pyz.sha256
sha256sum -c sr.pyz.sha256 # must print OK
install -m 0755 sr.pyz /usr/local/bin/srpipx install sr-search-replace # isolated, recommended
python3 -m pip install --user sr-search-replacenpm install -g sr-search-replaceThe npm package bundles the same zipapp; the launcher finds python3 and
reports a precise error if it is missing or too old.
git clone https://github.com/paulmann/sr-search-replace
cd sr-search-replace
make test # the full suite (~730 tests), no network
make install # zipapp + man page into PREFIX (default /usr/local)sr --version # version, interpreter, installation type
sr doctor # environment, filesystem capabilities, config, session store
sr selftest # end-to-end smoke test in a temporary directoryPlatforms. Officially Linux and macOS. On Windows use WSL2.
The Python entry point does import and run on native win32, and sr find /
--dry-run will work, but the write path is not supported there and is not
tested in CI: sr relies on POSIX rename semantics, os.chown, O_CREAT|O_EXCL
advisory locks, inode preservation and os.statvfs. The npm launcher refuses on
win32 for exactly that reason, and pyproject.toml carries no Windows
classifier. sr doctor reports which of those capabilities the current
filesystem actually provides.
# 1. look
sr find "TODO" -p '*.py'
sr diff "old" "new" -p '*.py'
# 2. do (creates a rollback session automatically)
sr replace "old" "new" -p '*.py'
# 3. review, and undo if needed
git diff # or: sr diff --session latest
sr rollback # restores every file of the last sessionA bare invocation is a shortcut for replace, and flags work in any position:
sr "*.py" "old" "new" == sr replace "*.py" "old" "new"
sr "old" "new" -p '*.py' -i == sr replace -i -p '*.py' "old" "new"Quote your globs.
sr replace *.py a blets the shell expand*.py;sr replace '*.py' a blets sr walk the tree. Both work, but only the second one recurses.
sr help is a real manual — 27 topics, generated from the same schema that
drives the parser, so it cannot drift out of date.
sr help # overview and quick start
sr help topics # the topic index
sr help matching # literal vs --regex/--ere/--pcre, ranges, counts
sr help rollback # drift detection, undoing a rollback
sr help replace # everything one command accepts
sr help --max-count # one option in depth, with an example
sr help examples # ~40 ready-to-run command lines
sr help --all # the whole manual in one document
sr help --json # the same content, machine readable
man sr # the same content as a man page| Command | Aliases | What it does |
|---|---|---|
sr replace GLOB SEARCH REPLACE |
r |
Rewrite files in place, journalled |
sr find GLOB SEARCH |
search, grep |
Show matches with file:line:col; never writes |
sr diff GLOB SEARCH REPLACE |
— | Preview the change as a unified diff |
sr rename GLOB SEARCH REPLACE |
mv |
Rename files/directories by pattern |
sr rollback [SESSION] |
undo, restore |
Restore files from a session |
sr sessions [list|show|delete|prune|size] |
backups |
Inspect the audit trail |
sr update [--check] |
upgrade |
Check for / install a newer release |
sr doctor |
diagnose |
Verify the installation and environment |
sr config [list|show|init|path|validate] |
— | Manage configuration files |
sr completions [bash|zsh|fish] |
— | Generate shell completion |
sr selftest |
smoke |
Built-in end-to-end smoke test |
sr help [TOPIC] |
— | The manual |
Every command accepts -h/--help.
sr replace "old_api" "new_api" -p '*.py' -p '*.pyx'
sr replace 'version = "(\d+)"' 'version = "\1.post0"' --regex -p setup.py
sr replace "Warning" "Notice" -i -w -p '*.md'
sr replace "old" "new" --dry-run # count only, write nothing
sr replace "old" "new" --interactive # confirm every hunk
sr replace "old" "new" --write=dir -o out # copies; originals untouched
sr replace "old" "new" --write=stdout | less # pipe the result
sr replace "old" "new" --line-range 1-20 -p 'header*'
sr replace "old" "new" --max-count 1
sr replace "old" "new" --encoding cp1251 -p '*.txt'
sr replace "MyClass" "YourClass" --rename -p '*.py' # content *and* file namessr find "deprecated" -p '*.py' # file:line:col + the line, highlighted
sr find "deprecated" --count # per-file counts and a total
sr find "deprecated" -l # file names only
sr find "deprecated" -A 2 -B 2 # with context
sr find 'v\d+\.\d+' --regex --porcelain | cut -d: -f1 | sort -usr rename ".txt" ".md" -p '*.txt'
sr rename --regex '^photo-(\d+)\.jpeg$' 'img_\1.jpg'
sr rename "old" "new" --dry-run # the plan, without executing itRenames never move a file to another directory, collisions are detected before
anything is executed, and the whole thing is journalled so sr rollback undoes
it in reverse order.
sr doctor --json | jq '.checks[] | select(.status != "ok")'
sr selftest # 19 end-to-end checks in ~50 ms
sr config list --show-origin # every option and where it came from
sr config init # documented template in ~/.config/sr/
sr completions --install # bash/zsh/fish, auto-detectedFour pattern kinds. Literal is the default, which is the single most
important difference from sed:
sr replace "1.2.3" "1.2.4" # a dot is a dot
sr replace '1\.2\.3' '1.2.4' --regex # now it is a regex| Flag | Syntax | Replacement semantics |
|---|---|---|
| (default) | literal text | verbatim — $1, \1 and & stay literal |
--regex |
Python re |
template: \1, \g<name>, \n, \\ |
--ere |
POSIX ERE (grep -E) |
template, plus [[:digit:]], \<, \> |
--pcre |
Perl (grep -P) |
template, plus \A, \z, (?<name>…) |
Modifiers, applied uniformly to every kind:
| Flag | Effect |
|---|---|
-i, --ignore-case |
case-insensitive |
-w, --word-boundary |
whole words only |
-m, --multiline |
the pattern may span line boundaries |
-L, --line-range R |
restrict edits to lines 10, 10-20, 10-, -20 |
-M, --max-count N |
at most N replacements per file |
--no-global |
only the first occurrence per line |
--no-escapes |
do not interpret \n, \t, \xNN in fixed patterns |
Escape sequences recognised in fixed patterns: \n \t \r \f \0 \e \\ \' \" \xNN.
\b is deliberately not one of them — C:\bin must stay C:\bin, not
become a backspace. Unknown escapes keep their backslash.
Counting rule: occurrences, not lines. A line with three hits counts three,
in find, in the confirmation prompt, in --max-count and in the summary —
because all of them ask the same matcher.
$ sr help matching # the full chapter, with a cheat sheetsr replace a b -p '*.py' # by name (repeatable)
sr replace a b --include 'src/**/*.py' # by path, ** supported
sr replace a b --exclude '*.min.js' --exclude 'vendor/*'
sr replace a b --file src/app.py --file src/main.py
git ls-files '*.py' | sr replace a b --files-from -
sr replace a b -C ~/projects/api # another directory
sr replace a b --no-recursive --max-depth 2
sr replace a b --hidden -p '.env*'
sr replace a b --gitignore # honour .gitignore
sr replace a b --max-size 1MB --newer-than 7dSafe by default: .git, .hg, .svn, node_modules, vendor, dist,
build, target, __pycache__, .venv, .cache, .gradle, .terraform,
.idea, .vscode are pruned before descending (so scanning them costs
nothing); .DS_Store, *.orig, *.rej, *.bak, *.swp, *~ are skipped;
hidden files are skipped; symlinks are skipped; binary files are skipped; and
sr's own .sr/ store is always pruned so a rollback can never feed on itself.
Add -v to see why a candidate was skipped — every rejection has a reason:
$ sr find "x" -v
[verbose] skipped assets/logo.png: binary content (use --binary to force)
[verbose] skipped vendor/lib.js: excluded directory (vendor)
[verbose] skipped dist/app.js: ignored by .gitignoreEvery mutating run writes a session:
<target>/.sr/sessions/20260131T120530Z-9f3a1c/
├── manifest.json who, what, when; per-file SHA-256, size, mode, uid/gid
├── journal.jsonl append-only op log, fsync-ed per file (crash-safe)
├── files/9f/9f3a….dat the original bytes, content addressed
└── diffs/src/app.py.diff
- Content addressed — identical originals are stored once, so 10 000 files sharing a header cost one blob. Every blob is re-hashed on read: a truncated or tampered backup is reported, never silently restored.
- Journal first, manifest last — a run killed halfway still has a usable journal, and sr rebuilds enough of the session from it to roll back.
- Add
.sr/to.gitignore. Use--backup-dirto keep sessions outside the tree (read-only checkout, or you simply do not want the directory).
--no-backupskips the store entirely — the right choice inside a clean git working tree, wheregit checkout -- .is your rollback.
sr sessions # what can I undo?
sr rollback # newest session
sr rollback 9f3a1c # by unique id suffix
sr rollback 2 # ordinal: 2 = the second newest
sr rollback latest --dry-run # show what would be restored
sr rollback latest --file src/app.py
sr diff --session latest # the diffs that run recordedDrift detection. The manifest stores the SHA-256 of each file after the run. Before restoring, sr hashes the file as it is now:
| Current state | Behaviour |
|---|---|
| unchanged since the session | restored silently |
| edited afterwards | reported as drifted and skipped unless --force |
| deleted | restored (the directory is recreated) |
That is what makes sr rollback safe to run hours later: it will not throw away
edits you made in the meantime.
The rollback is itself reversible. Unless you pass --no-safety-snapshot,
the pre-rollback state is recorded in a new session of kind rollback:
$ sr rollback --yes
[ok] session 9f3a1c rolled back (2 files)
Undo this rollback sr rollback 20260131T120918Z-4b7e21Retention. --keep N (default 20) and --keep-age 30d are applied after
every mutating run, so the store cannot grow without bound:
sr sessions size
sr sessions prune --keep 10 --keep-age 30dOne document on stdout; every diagnostic goes to stderr, so
sr … --json | jq is always safe.
sr replace "$OLD" "$NEW" --yes --json --no-update-check > report.json
jq -e '.totals.failed == 0' report.json
jq -r '.files[] | select(.status=="changed") | "\(.rel_path): \(.replacements)"' report.jsonAdd --diff to include each file's patch text as "patch".
Stable path:line:col:kind:text records, designed to be cut and piped:
sr find "TODO" --porcelain | cut -d: -f1 | sort -u| Code | Meaning |
|---|---|
0 |
success (for find/replace: at least one match) |
1 |
runtime error (I/O failure, corrupt session, internal bug) |
2 |
usage error (bad arguments, unreadable config, missing directory) |
3 |
no match — nothing to do (grep/diff convention) |
4 |
rollback failed for one or more files |
5 |
self-update failed (network, checksum, permissions) |
6 |
sr selftest failed: the installation is broken |
130 |
interrupted with Ctrl-C |
Full table, per-command behaviour and the reasoning behind the split:
docs/EXIT_CODES.md (generated from sr/exit_codes.py)
or sr help exit-codes.
if sr replace "$OLD" "$NEW" --yes -q; then
echo changed
elif [ $? -eq 3 ]; then
echo "nothing to do"
else
echo failed >&2; exit 1
fi--yes— never prompt (sr refuses to prompt when stdin is not a terminal and exits non-zero instead of hanging the job)--no-update-check— keep the network out of the job--jsonor--porcelain— parse, do not scrape--no-backupwhen the workspace is a fresh checkout, or--backup-dirpointed at a cache directory when you want the rollbackSR_NO_COLOR=1/--color never— no ANSI codes in the logsr selftestas a post-install step in the image
Precedence, lowest to highest:
built-in defaults
→ /etc/sr/sr.conf
→ $XDG_CONFIG_HOME/sr/sr.conf (~/.config/sr/sr.conf)
→ <project root>/.srrc , <target>/.srrc , <target>/.sr/sr.conf
→ $SR_CONFIG / --config-file FILE
→ SR_* environment variables
→ --config KEY=VALUE
→ command line flags
A small, forgiving INI dialect:
# ~/.config/sr/sr.conf
ignore-case = true # applies to every command
exclude = *.min.js, dist # comma or whitespace separated
[replace] # `sr replace` only
confirm-threshold = 500
keep = 50
[find]
context = 1Rules: keys are option names (- and _ interchangeable) · a bare key means
true · booleans accept true/false/yes/no/on/off/1/0 · sizes accept 512,
4KB, 10MB, 1.5GiB · durations accept 30s, 5m, 7d · repeating a key
builds a list · an unknown key or an invalid value is a hard error with
file:line, because a silently ignored typo is exactly what a bulk-edit tool
must not have.
sr config list --show-origin # every option, its value, where it came from
sr config show confirm-threshold
sr config init # documented template
sr config path # the files sr reads, in priority order
sr config validate # parse them all, report errorsEvery option also has an SR_* environment variable — the option name in upper
case with - → _:
SR_IGNORE_CASE=true sr replace "warning" "notice" -p '*.md'
export SR_EXCLUDE='*.min.js vendor/*'A project .srrc is worth committing: it turns the team's exclusion list,
confirmation threshold and backup policy into repository content instead of
tribal knowledge.
sr update --check # is there a newer release?
sr update # install it
sr update --version 7.1.0 # pin a specific version
sr update --channel prerelease
sr update --source npm # ask the npm registry instead of GitHubsr doctor reports the installation type, which decides how an update is
applied:
| Installation | What sr update does |
|---|---|
| standalone (zipapp / installed script) | downloads the release asset, verifies its SHA-256 against the published sr.pyz.sha256, smoke-tests it in a subprocess, keeps <file>.bak, then replaces the file atomically |
| pip / pipx | prints the exact upgrade command (the environment owns those files) |
| npm | prints npm install -g sr-search-replace@latest |
| source checkout | refuses, and tells you to git pull && make install |
Every step either succeeds or leaves the current installation untouched — a checksum mismatch or a failing smoke test installs nothing.
Background check. At most once per 24 h, sr looks for a newer release on a
daemon thread with a 2.5 s budget and caches the answer under
$XDG_CACHE_HOME/sr/update-check.json. It never blocks a run: on a slow or
absent network nothing is printed and the cache is used next time. Disable it
with --no-update-check or SR_NO_UPDATE_CHECK=1 — a good idea in CI and on
air-gapped machines.
sr update --version 7.0.0 && export SR_NO_UPDATE_CHECK=1 # pin
SR_GITHUB_TOKEN=… sr update --check # higher API limitsr completions --install # auto-detects bash / zsh / fish
sr completions bash >> ~/.bash_completion
sr completions zsh > "${fpath[1]}/_sr"
sr completions fish > ~/.config/fish/completions/sr.fish
man srBoth are generated from the live option schema (make completions,
make man), so a new flag appears in the parser, the config layer, the help
system, the completions and the man page from one declaration — and CI fails if
a committed artefact drifts out of date.
sr help pages its output through $PAGER on a terminal; --no-pager
disables that. sr … | head exits 0 rather than dumping a BrokenPipeError.
61 tunables. Every one is also a config-file key and an SR_* variable.
sr help options prints this table with examples; sr help --<flag> explains
one option in depth.
60 cross-command options. Each one is also a configuration key (see sr config list) and an SR_* environment variable.
Target selection (18)
| Flag | Environment | Default | What it does |
|---|---|---|---|
-C, --target DIR |
SR_TARGET |
. |
Directory to operate on (default: the current directory). (replace, find, diff, rename) |
-p, --pattern GLOB |
SR_PATTERN |
— | Only files whose name matches GLOB (repeatable). Default: every text file. (replace, find, diff, rename) |
--file PATH |
SR_FILE |
— | Explicit file to process, bypassing pattern matching (repeatable). (replace, find, diff, rename) |
--files-from FILE |
SR_FILES_FROM |
— | Read the list of target files from FILE, one per line ('-' for stdin). (replace, find, diff, rename) |
--include GLOB |
SR_INCLUDE |
— | Additional include glob, may contain '/' and '**' (repeatable). (replace, find, diff, rename) |
--exclude GLOB |
SR_EXCLUDE |
— | Exclude glob; wins over --include and --pattern (repeatable). (replace, find, diff, rename) |
--exclude-dir NAME |
SR_EXCLUDE_DIR |
— | Prune a directory by name (repeatable; adds to the built-in list). (replace, find, diff, rename) |
--no-default-excludes |
SR_NO_DEFAULT_EXCLUDES |
— | Do not apply the built-in exclude lists (node_modules, dist, *.bak …). (replace, find, diff, rename) |
-1, --no-recursive |
SR_NO_RECURSIVE |
— | Only the target directory itself, no subdirectories. (replace, find, diff, rename) |
--max-depth N |
SR_MAX_DEPTH |
— | Descend at most N levels below the target directory. (replace, find, diff, rename) |
--hidden |
SR_HIDDEN |
— | Also process dotfiles and dot-directories. (replace, find, diff, rename) |
--symlinks |
SR_SYMLINKS |
— | Follow symlinked files (they are skipped by default). (replace, find, diff, rename) |
--gitignore |
SR_GITIGNORE |
— | Honour .gitignore files (including the global excludesfile). (replace, find, diff, rename) |
--max-size SIZE |
SR_MAX_SIZE |
100.0 MB |
Skip files larger than SIZE (default 100MB; a bare number means megabytes). (replace, find, diff, rename) |
--min-size SIZE |
SR_MIN_SIZE |
— | Skip files smaller than SIZE. (replace, find, diff, rename) |
--newer-than AGE |
SR_NEWER_THAN |
— | Only files modified within AGE (e.g. 24h, 7d, 30m). (replace, find, diff, rename) |
--older-than AGE |
SR_OLDER_THAN |
— | Only files not modified within AGE. (replace, find, diff, rename) |
--binary |
SR_BINARY |
— | Process files that look binary instead of skipping them. (replace, find, diff, rename) |
Matching (12)
| Flag | Environment | Default | What it does |
|---|---|---|---|
-R, --regex |
SR_REGEX |
— | Treat PATTERN as a Python regular expression. (replace, find, diff, rename) |
--ere |
SR_ERE |
— | Treat PATTERN as a POSIX Extended Regular Expression (grep -E syntax). (replace, find, diff, rename) |
--pcre |
SR_PCRE |
— | Treat PATTERN as a Perl-compatible regular expression. (replace, find, diff, rename) |
--pattern-kind KIND |
SR_PATTERN_KIND |
fixed |
Explicit pattern kind: fixed, regex, ere or pcre — one of fixed, regex, ere, pcre. (replace, find, diff, rename) |
-i, --ignore-case |
SR_IGNORE_CASE |
— | Case-insensitive matching. (replace, find, diff, rename) |
-w, --word-boundary |
SR_WORD_BOUNDARY |
— | Match whole words only. (replace, find, diff, rename) |
-m, --multiline |
SR_MULTILINE |
— | Allow the pattern to span line boundaries ('.' matches newline). (replace, find, diff, rename) |
--no-escapes |
SR_NO_ESCAPES |
— | Do not interpret \n, \t, \xNN in fixed patterns/replacements. (replace, find, diff, rename) |
-L, --line-range RANGE |
SR_LINE_RANGE |
— | Restrict edits to a line range: 10, 10-20, 10- or -20. (replace, find, diff, rename) |
-M, --max-count N |
SR_MAX_COUNT |
— | Replace at most N occurrences per file. (replace, find, diff, rename) |
--no-global |
SR_NO_GLOBAL |
— | Replace only the first occurrence on each line. (replace, find, diff, rename) |
--context N |
SR_CONTEXT |
3 |
Lines of context around a match in preview/diff output (default 3). (replace, find, diff, rename) |
Action (13)
| Flag | Environment | Default | What it does |
|---|---|---|---|
-n, --dry-run |
SR_DRY_RUN |
— | Report what would change; never write, never create a session. (replace, find, diff, rename) |
--diff |
SR_DIFF |
— | Show a unified diff of every change (implies a preview). (replace, find, diff, rename) |
-I, --interactive |
SR_INTERACTIVE |
— | Confirm every hunk before it is written. (replace, find, diff, rename) |
--write MODE |
SR_WRITE |
in-place |
Where the result goes: in-place (default), stdout, or --output-dir — one of in-place, stdout, dir. (replace) |
-o, --output-dir DIR |
SR_OUTPUT_DIR |
— | Write modified copies under DIR, mirroring the tree (needs --write=dir). (replace) |
--suffix EXT |
SR_SUFFIX |
— | When writing copies, append EXT to the file name instead of mirroring. (replace) |
--rename |
SR_RENAME |
— | Also apply the replacement to file and directory names. (replace) |
--rename-only |
SR_RENAME_ONLY |
— | Rename matching files/directories without touching their content. (replace) |
-E, --encoding NAME |
SR_ENCODING |
— | Force an encoding (utf-8, cp1251, latin-1 …); default is auto-detect. (replace, find, diff, rename) |
--encoding-error POLICY |
SR_ENCODING_ERROR |
strict |
What to do with undecodable bytes: strict (skip), replace, ignore — one of strict, replace, ignore. (replace, find, diff, rename) |
--line-endings MODE |
SR_LINE_ENDINGS |
preserve |
Keep the file's line endings (default) or normalise to LF/CRLF — one of preserve, lf, crlf. (replace) |
--preserve-mtime |
SR_PRESERVE_MTIME |
— | Restore the original modification time after writing. (replace) |
-y, --yes |
SR_YES |
— | Answer 'yes' to every prompt; required for unattended runs in CI. (replace, rename, rollback, sessions, update) |
Safety & backups (9)
| Flag | Environment | Default | What it does |
|---|---|---|---|
--backup, --no-backup |
SR_BACKUP |
on | Store originals in a session so the run can be rolled back (default on). (replace, rename) |
--backup-dir DIR |
SR_BACKUP_DIR |
— | Where to keep sessions (default /.sr). Use an absolute path to keep them out of the tree. (replace, rename) |
--keep N |
SR_KEEP |
20 |
Keep at most N sessions; older ones are pruned after a run (default 20, 0 disables pruning). (replace, rename, sessions) |
--keep-age AGE |
SR_KEEP_AGE |
— | Also drop sessions older than AGE (e.g. 30d). (replace, rename, sessions) |
--atomic, --no-atomic |
SR_ATOMIC |
on | Write via a temporary file + rename (default) or rewrite the inode in place. (replace) |
--lock, --no-lock |
SR_LOCK |
on | Take an advisory lock so two sr runs cannot interleave (default on). (replace, rename) |
--confirm-threshold N |
SR_CONFIRM_THRESHOLD |
50 |
Ask for confirmation when more than N files would change (0 disables). (replace, rename) |
--allow-root |
SR_ALLOW_ROOT |
— | Permit a mutating run with euid 0 (refused by default). (replace, rename) |
-f, --force |
SR_FORCE |
— | Override safety guards (drifted files, read-only warnings, existing targets). (replace, rename, rollback) |
Output & reporting (8)
| Flag | Environment | Default | What it does |
|---|---|---|---|
-v, --verbose |
SR_VERBOSE |
— | Show per-file results and the reason skipped files were skipped. (all) |
-q, --quiet |
SR_QUIET |
— | Only errors; suitable for scripts. (all) |
--verbosity LEVEL |
SR_VERBOSITY |
normal |
Set the log level explicitly (overrides -v/-q) — one of quiet, normal, verbose, debug. (all) |
--color, --no-color WHEN |
SR_COLOR |
auto |
Colourise output. Honours NO_COLOR and CLICOLOR_FORCE — one of auto, always, never. (all) |
--json |
SR_JSON |
— | Emit a single JSON document on stdout; diagnostics go to stderr. (all) |
--porcelain |
SR_PORCELAIN |
— | Emit stable 'path:line:col:text' records, one per match. (replace, find, diff, rename) |
--progress, --no-progress |
SR_PROGRESS |
on | Show a progress bar on a terminal (default on). (replace, find, diff, rename) |
--summary, --no-summary |
SR_SUMMARY |
on | Print the run summary (default on). (replace, find, diff, rename) |
Command-local options (accepted by one command only)
These are declared next to their subparser rather than in the shared schema, because they exist for exactly one command. They are generated into this table from the live parser, so they cannot go stale.
Global
| Flag | What it does |
|---|---|
--help-all |
Show help for every subcommand and option |
-V, --version |
Print version, license and update information |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr replace
| Flag | What it does |
|---|---|
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
--glob GLOB |
File name pattern (repeatable); alternative to the positional GLOB |
sr find
| Flag | What it does |
|---|---|
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
--glob GLOB |
File name pattern (repeatable) |
-c, --count |
Print only the number of matches per file (and a total) |
-l, --files-with-matches |
Print only the names of files that contain a match |
-A, --after-context N |
Print N lines after each match |
-B, --before-context N |
Print N lines before each match |
--highlight |
Force match highlighting even when writing to a file |
sr diff
| Flag | What it does |
|---|---|
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
--session SESSION |
Instead of computing a diff, show the diff stored in a session |
sr rename
| Flag | What it does |
|---|---|
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
--directories |
Also rename directories that match (default: files only) |
sr rollback
| Flag | What it does |
|---|---|
--no-safety-snapshot |
Do not record the pre-rollback state (not recommended) |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr sessions
| Flag | What it does |
|---|---|
--limit N |
List at most N sessions (default 20) |
--files |
Include the per-file list in show |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr update
| Flag | What it does |
|---|---|
--check |
Only report whether an update exists |
--version VERSION |
Install a specific version (e.g. 7.1.0) |
--channel |
Release channel (default: stable) |
--source |
Where to fetch release information (default: github) |
--repo OWNER/NAME |
Override the GitHub repository |
--timeout SECONDS |
Network timeout (default 20) |
--path FILE |
Standalone file to update (default: auto-detect) |
--no-verify |
Install without SHA-256 verification. Verification is fail-closed by default, so this is the only way past a missing checksum — use it only for a mirror you trust |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr doctor
| Flag | What it does |
|---|---|
--offline |
Skip the network reachability probe |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr completions
| Flag | What it does |
|---|---|
--install |
Write the script into the shell's completion directory |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr config
| Flag | What it does |
|---|---|
--option NAME |
Option name for show, e.g. sr config show --option max-size |
--show-origin |
Show where each value comes from |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr selftest
| Flag | What it does |
|---|---|
--quick |
Run only the essential checks |
--debug |
Debug logging (same as -vv) |
--no-update-check |
Do not check for a newer release |
--config KEY=VALUE |
Override one configuration value (repeatable) |
--config-file FILE |
Read an extra configuration file (repeatable) |
sr help
| Flag | What it does |
|---|---|
--option NAME |
Explain one option, e.g. sr help --option max-count |
--all |
Same as sr --help-all |
--no-pager |
Never page the output |
Global flags: -h/--help, --help-all, -V/--version, -v/--verbose (-vv
= debug), -q/--quiet, --debug, --json, --porcelain, --color auto|always|never, --no-update-check, --config KEY=VALUE, --config-file FILE.
Threat model: sr runs with your privileges over your files, so the risks are accidental destruction, unintended scope and supply-chain attacks on the update path — not remote exploitation.
Accidental destruction
- literal matching by default: a pattern is never silently a regex
- binary files are skipped unless
--binaryor an explicit--encodingis given - undecodable files are skipped and reported — never rewritten with a guessed
encoding (BOM, UTF-8,
--encoding, then--encoding-error) - the scan phase completes before the first write, so the confirmation prompt
states real numbers (
--confirm-threshold, default 50 files) - VCS directories, build output and
.sr/are pruned by default - symlinks are skipped by default, so a link cannot smuggle a file outside the target tree into the edit set
- atomic writes by default (
--atomic): a crash can never leave a truncated file;--no-atomicpreserves the inode instead, for hard links - running as root is refused unless
--allow-root - rewriting
/,/etc,/usror$HOMEdirectly is refused unless--force - every mutating run is journalled and reversible; rollbacks are reversible too
- rollback refuses drifted files, refuses paths outside the session root, and re-hashes every blob before writing it
Unintended scope
-vprints every skipped candidate with the reason--dry-runand--diffmake the blast radius visible before committing--write=dir -o outproduces copies and touches nothing
Update path (supply chain)
- HTTPS only, enforced in code (
_require_httpsrejectsfile:and custom schemes beforeurlopensees them) - verification is fail-closed: a missing checksum asset, an unreachable or
empty checksum file, a malformed digest and a digest mismatch all abort the
update and install nothing —
scripts/install.shenforces the same five rules - the download is smoke-tested in a subprocess before it replaces anything
- the previous file is kept as
<file>.bak --no-verifyis the only way past that, and warns loudly- the background check is read-only, cached, non-blocking and can be disabled
Data handling
- file content never leaves the machine; the only network calls are the release lookup and the release download
- sessions store original bytes under
<target>/.sr— not encrypted; treat that directory with the same care as the files themselves and keep it out of git - no telemetry, no analytics, no crash reporting: a crash prints a traceback
locally (with
SR_DEBUG=1) and suggests opening an issue
See SECURITY.md for reporting a vulnerability.
Where the time goes, in order of impact: discovery (pruning is the whole game — excluded directories are never descended into), reading and decoding (every candidate is read once), matching (one compiled pattern reused for every file), writing (one atomic write plus an fsync per changed file).
On a laptop SSD a literal replacement across ~10 000 text files (≈120 MB) takes
a few seconds. The scan phase doubles the read cost of a mutating run — that is
the price of an accurate prompt and of a --dry-run with no side effects.
sr replace a b --files-from <(git ls-files '*.py') # skip discovery entirely
sr replace a b --no-backup # no blob writes (git is your net)
sr replace a b -q --no-progress --no-summary # script-friendly
sr replace a b --max-size 1MB --newer-than 7d # shrink the candidate setNo parallelism by default, deliberately. A bulk rewrite is I/O bound, and
parallel writers make the journal order — and therefore rollback — harder to
reason about. If you need throughput, shard by directory with -C and run the
shards yourself: each shard gets its own session and can be rolled back
independently.
Start with sr doctor (interpreter, installation type and writability, config
files, session store, filesystem capabilities, locking, disk, network) and
sr selftest (the whole pipeline end to end).
| Symptom | Cause and fix |
|---|---|
no files matched the target selection |
Unquoted glob (the shell expanded it) · hidden or excluded files · --gitignore · wrong root. Run with -v to see every rejection and its reason. |
skipped: binary content |
NUL byte or >30 % non-text bytes in the first 8 KiB. UTF-16 without a BOM looks binary. Use --encoding utf-16 (decodes it properly) or --binary (forces it). |
cannot decode as utf-8 |
It is not UTF-8. Pass --encoding cp1251 (or the real encoding), or --encoding-error replace to force a lossy decode. sr never guesses silently. |
| Line endings changed | They are preserved per file by default. --line-endings lf normalises on purpose. |
file changed after the session |
Correct behaviour: the file was edited after the session, so restoring would lose those edits. Review with sr diff --session <id>, then --force if you accept the loss. |
another sr process is using <session> |
A held lock. sr breaks locks whose owner process is gone; otherwise delete .sr/sessions/<id>/.lock. |
| Slower than expected | -v prints discovery statistics. Check that node_modules/dist are pruned; consider --files-from. |
| sr crashed | Re-run with SR_DEBUG=1 for a traceback and open an issue with sr doctor --json attached. |
sr help troubleshooting # the full chaptersr-cli, sr.py launchers (POSIX sh / thin Python)
sr/
cli.py preprocessing, dispatch, error handling
cli_support.py argparse actions, schema → parser plumbing
registry.py the subcommand table
options.py THE option schema (single source of truth)
config.py config files, env vars, precedence
context.py per-run context: console, config, prompts, guards
console.py colour, verbosity, text/json/porcelain output
pattern.py the matcher: literal/regex/ERE/PCRE, ranges, counts
discovery.py walking, pruning, globs, .gitignore, filters
textio.py encoding, BOM, line endings, binary sniffing
replace.py the engine: scan → confirm → write → record
find.py read-only search rendering (grep-like output)
diffview.py unified diffs and their statistics
atomic.py atomic, permission-preserving writes
session.py sessions: manifest, journal, blob store, restore
errors.py exception hierarchy → exit codes
exit_codes.py the frozen exit-code contract
util.py, version.py sizes, durations, hashes, ids, semver
help_system.py the manual: 27 topics, generated option reference
commands/ one module per subcommand (+ shared helpers)
tools/ build helpers: zipapp, man page
scripts/ install.sh, version sync, release notes
tests/ ~590 test functions → ~730 tests; hermetic (no network, no $HOME)
npm/ the npm package (launcher + bundled zipapp)
Data flow of sr replace:
argv → preprocess → argparse → Context (config merge, console)
→ PatternSpec (validated once)
→ DiscoveryOptions → discover() → candidate list
→ SCAN: read → decode → match → diff (nothing written)
→ confirm (accurate numbers)
→ WRITE: atomic write → session.record_replace()
→ close session (manifest) → prune → report → exit code
Invariants worth knowing:
- the scan phase never writes, so
--dry-runhas literally no side effects - counting and replacing share one matcher, so the reported number of replacements equals the number of edits made
- the journal is fsync-ed per file and the manifest only at the end, so an interrupted run is still recoverable
- blobs are content addressed and re-hashed on read: a corrupt backup is detected, never silently restored
- a session may never cause a write outside its own root, even if the manifest is edited by hand
git clone https://github.com/paulmann/sr-search-replace
cd sr-search-replace
python3 -m venv .venv && . .venv/bin/activate
make install-dev # editable install + pytest/ruff/mypy
make test # the full suite
make lint # ruff
make dist # build/sr.pyz (the release asset)
make man completions # regenerate the derived artefacts
make check # lint + types + tests + dist + man + versions + selftestConventions (all enforced by review or CI):
- a new option goes into
sr/options.pyand nowhere else — the parser, config layer, help text, man page and completions are all derived from it - every command module exposes
cmd_<name>(context) -> int - errors are
SrErrorsubclasses carrying an exit code and a one-line hint - no third-party runtime dependencies, ever
- tests must not touch the network, the real
$HOME, or the cwd
Release checklist: bump version in pyproject.toml → make sync-version V=x.y.z
→ update CHANGELOG.md → make release-check → commit, tag vx.y.z, push.
CI publishes to PyPI and npm, builds sr.pyz, and attaches sr.pyz +
sr.pyz.sha256 to the GitHub Release — which is exactly what sr update downloads
and verifies.
See CONTRIBUTING.md.
sr 7 is a rewrite in Python with a redesigned command line. The 6.x flags are not accepted.
| 6.x (bash) | 7.x |
|---|---|
sr "*.py" "a" "b" |
sr replace "a" "b" -p '*.py' |
sr -i / --ignore-case |
sr replace -i … (unchanged) |
sr -E / --extended-regex |
sr replace --ere … |
sr -w / --word-boundary |
sr replace -w … (unchanged) |
sr -m / --multiline |
sr replace -m … (unchanged) |
sr -nr / --no-recursive |
sr replace --no-recursive |
sr -nb / --no-backup |
sr replace --no-backup |
sr -nbf / --no-backup-folder |
sr replace --backup-dir <dir> |
sr -fb / --force-backup |
(backups are on by default) |
sr -xs / --max-size MB |
sr replace --max-size 10MB |
sr -xp / --exclude-patterns |
sr replace --exclude GLOB (repeatable) |
sr -xd / --exclude-dirs |
sr replace --exclude-dir NAME (repeatable) |
sr -sd / --search-dir DIR |
sr replace -C DIR |
sr -od / --output-dir DIRsr -mode copy |
sr replace --write=dir -o DIR |
sr --dry-run |
sr replace --dry-run (unchanged) |
sr --rollback[=DIR] |
sr rollback [SESSION] |
sr --rollback-list |
sr sessions |
sr --binary |
sr replace --binary (unchanged) |
sr --binary-method=… |
(one robust sniffer; no knob) |
sr --find-opts / --sed-opts / --grep-opts |
removed — those strings were injected into a shell command line. Use the typed equivalents: --include, --max-depth, --newer-than, --exclude-dir, … |
sr --delimiter CHAR |
removed — no shell quoting is involved any more, so no delimiter can collide |
SR_* variables |
still one per option, named after the new flags |
Behaviour changes worth knowing
- Patterns are literal by default. 6.x escaped the search string for
sed, which also meant-Ewas effectively broken; 7.x separates literal and regex cleanly. - Replacement counts are occurrences, not matching lines.
--dry-runwrites nothing at all (6.x created a backup directory).- Sessions live in
.sr/instead ofsr.backup.<timestamp>/in the project root. 6.x backup directories are left untouched — restore from them with the 6.x script from thev6.1.0release assets, or copy the files back by hand (the layout is a plain directory mirror). - Exit codes are documented and stable: "no match" is
3, not1. - 6.x required
bc,file, GNUsed/grepand bash 4 at runtime and failed with exit127when any of them was missing. 7.x needs Python 3.10+ and nothing else.
sr help compat # the same table, plus the reasoningMIT © Mikhail Deynekin — deynekin.com
sr help license
{ "version": "7.0.0", "dry_run": false, "root": "/home/me/project", "search": "old", "replacement": "new", "pattern_kind": "fixed", "duration_seconds": 0.412, "totals": { "scanned": 128, "changed": 7, "unchanged": 121, "skipped": 2, "failed": 0, "matches": 19, "replacements": 19, "renamed": 0, "bytes_read": 421887, "bytes_written": 20144 }, "session": { "id": "20260131T120530Z-9f3a1c", "…": "…" }, "files": [ { "path": "…", "rel_path": "src/app.py", "status": "changed", "matches": 3, "replacements": 3, "encoding": "utf-8", "size_before": 4012, "size_after": 4015, "diff": { "added": 3, "removed": 3, "changed": 6 } } ], "errors": [], "warnings": [], "exit_code": 0 }