← Files

sr — safe, auditable search & replace for source trees#

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)

Contents#


Why sr#

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 pipeline: discover, scan, confirm, write, record

Install#

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 | bash

The 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.

<details> <summary>Manual install (air-gapped machines)</summary>
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/sr
</details>

pipx / pip#

pipx install sr-search-replace       # isolated, recommended
python3 -m pip install --user sr-search-replace

npm#

npm install -g sr-search-replace

The npm package bundles the same zipapp; the launcher finds python3 and
reports a precise error if it is missing or too old.

From source#

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)

Verify any installation#

sr --version       # version, interpreter, installation type
sr doctor          # environment, filesystem capabilities, config, session store
sr selftest        # end-to-end smoke test in a temporary directory

Platforms. 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.


Quick start#

# 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 session

A 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 b lets the shell expand *.py;
sr replace '*.py' a b lets sr walk the tree. Both work, but only the
second one recurses.

The built-in manual#

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

Commands#

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.

replace#

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 names

find#

sr 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 -u

rename#

sr rename ".txt" ".md" -p '*.txt'
sr rename --regex '^photo-(\d+)\.jpeg$' 'img_\1.jpg'
sr rename "old" "new" --dry-run           # the plan, without executing it

Renames 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.

doctor, selftest, config, completions#

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-detected

Matching#

Four 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 sheet

Choosing files#

sr 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 7d

Safe 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 .gitignore

Sessions, backups and rollback#

Every 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
Session layout and what rollback guarantees

Rolling back#

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 recorded

Drift 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-4b7e21

Retention. --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 30d

Automation: JSON, porcelain, exit codes#

--json#

One 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.json
{
  "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
}

Add --diff to include each file's patch text as "patch".

--porcelain#

Stable path:line:col:kind:text records, designed to be cut and piped:

sr find "TODO" --porcelain | cut -d: -f1 | sort -u

Exit codes#

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

CI checklist#


Configuration#

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 = 1

Rules: 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 errors

Every 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.


Self-update#

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 GitHub

sr 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 limit

Shell completion and man page#

sr 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 sr

Both 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.


Option reference#

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.

<!-- BEGIN OPTIONS — generated by scripts/gen_options_table.py; do not edit -->

60 cross-command options. Each one is also a configuration key (see sr config list) and an SR_* environment variable.

<details open> <summary><b>Target selection</b> (18)</summary>
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)
</details> <details open> <summary><b>Matching</b> (12)</summary>
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)
</details> <details open> <summary><b>Action</b> (13)</summary>
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)
</details> <details open> <summary><b>Safety & backups</b> (9)</summary>
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 <target>/.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)
</details> <details open> <summary><b>Output & reporting</b> (8)</summary>
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)
</details> <details> <summary><b>Command-local options</b> (accepted by one command only)</summary>

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
</details> <!-- END OPTIONS -->

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.


Safety model#

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

Unintended scope

Update path (supply chain)

Data handling

See SECURITY.md for reporting a vulnerability.


Performance#

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 set

No 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.


Troubleshooting#

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 chapter

Architecture#

sr-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:


Development#

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 + selftest

Conventions (all enforced by review or CI):

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.


Migrating from sr 6.x#

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 DIR<br>sr -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

sr help compat        # the same table, plus the reasoning

License#

PolyForm Noncommercial 1.0.0 © Mikhail Deynekin — deynekin.com

sr help license