-
Notifications
You must be signed in to change notification settings - Fork 0
File Swapping
File swapping lets you temporarily replace project files with development-specific versions stored in your vault. Perfect for local overrides that shouldn't be committed.
Only change structure (move, delete, rename) when vault is in swapped-out state!
Unlike guarding (permanent move to vault), swapping is temporary:
NORMAL STATE AFTER "SWAP IN"
project/ project/
└── application.yml (original) ───► └── application.yml (from vault)
vault/swap/ vault/swap/
└── application.yml (dev version) └── application.yml.rsenv_original (backup)
Key difference from guarding:
- Guard: Permanent. File lives in vault, symlink in project.
- Swap: Temporary. Original backed up, vault version copied to project.
First, move your development override files to the vault:
rsenv swap init config/application.ymlThis:
- Moves
config/application.ymlfrom project tovault/swap/config/application.yml - Project file is removed (now only exists in vault)
Use swap in to put the vault version back into the project.
Replace project files with vault versions:
rsenv swap in config/application.ymlThis:
- Creates sentinel in vault:
application.yml@@{hostname}@@rsenv_active(copy of vault content) - Moves project file to vault backup:
vault/swap/application.yml.rsenv_original - Moves vault override to project (vault file is removed)
Restore original files:
rsenv swap out config/application.ymlThis:
- Moves project file back to vault (preserving your changes)
- Restores backup from vault:
vault/swap/application.yml.rsenv_original→ project - Removes sentinel from vault
rsenv swap statusOutput:
Swap status:
config/application.yml: IN (swapped on myhost)
config/database.yml: OUT (original in project)
Swap out all files in the current project's vault:
rsenv swap outBatch restore across all projects:
rsenv swap out --globalOr with a custom vault base directory:
rsenv swap out --global --vault-base ~/my-vaultsUseful before:
- Committing changes
- Switching git branches
- Sharing code with others
See which vaults have active swaps:
rsenv swap status --globalOutput:
Active Swaps:
myproject-a1b2c3d4:
config.yml [in (myhost)]
another-proj-d5e6f7g8:
settings.json [in (myhost)]
For shell scripts, use --silent to get exit code only:
# Project-level: 0=clean, 1=dirty, 2=unmanaged
rsenv swap status --silent
case $? in
0) echo "Clean — nothing swapped in" ;;
1) echo "Dirty — files swapped in" ;;
2) echo "Not an rsenv-managed project" ;;
esac
# Global: 0=clean, 1=dirty (across all vaults)
if rsenv swap status --global --silent; then
echo "All clean"
else
echo "Some files are swapped in"
fiUseful for:
- CI/CD pipelines to detect unwanted swaps
- Pre-commit hooks
- Shell prompts (project state indicator)
- Automated cleanup scripts
Remove files from swap management entirely:
rsenv swap delete config/application.ymlThis removes from the vault:
- The override file (
vault/swap/config/application.yml) - Any backup file (
config.yml.rsenv_original)
Project files are not touched.
Safety: Refuses to delete if the file is currently swapped in. Swap out first.
All-or-nothing: If any file fails validation, no files are deleted.
While a file is swapped in, its content lives in the project and the vault holds only the
sentinel. Neither git diff shows anything: the project repo ignores (or never tracked) the
swapped path, and the vault has no live copy. rsenv swap diff closes that gap without
leaving the project directory.
$ rsenv swap diff --stat
thoughts:
M docs/SEARCH.md
A research/2026-09-13-ranking.md
D todo.md
$ rsenv swap diff thoughts/docs/SEARCH.md
thoughts:
M docs/SEARCH.md
diff --git a/thoughts/docs/SEARCH.md b/thoughts/docs/SEARCH.md
--- a/thoughts/docs/SEARCH.md
+++ b/thoughts/docs/SEARCH.md
@@ -12,6 +12,7 @@
ranking applied after filter
+geo-distance sort disabled for tariffsThe patch is the default view, as in git diff; --stat asks for the summary alone.
Output is emitted in git's patch format and piped through the pager git var GIT_PAGER
reports, which resolves $GIT_PAGER then core.pager then $PAGER exactly as git does. A
core.pager = delta setup therefore renders these diffs with the same syntax highlighting,
side-by-side layout and file hyperlinks it gives git — no rsenv-specific configuration.
| Output is not a terminal | Paging is skipped, so pipes and scripts get plain text |
--no-pager |
Skips paging even on a terminal |
| Pager missing or broken | Falls back to plain stdout rather than losing the diff |
| Quitting the pager early | Exits cleanly (a closed pipe is a user action, not an error) |
Patch headers are project-relative (a/thoughts/docs/SEARCH.md, not a/docs/SEARCH.md),
because a viewer resolves them against the working directory — an entry-relative path would
make delta's file links point at something that does not exist.
Markers: A added, D deleted, M modified, T type changed (file/dir/symlink).
Path arguments follow git pathspec semantics — a swapped entry, a path inside one, or an ancestor of several all work, and each selects everything at or below it:
rsenv swap diff thoughts # the whole swapped entry
rsenv swap diff thoughts/concepts.md # one file inside it
rsenv swap diff thoughts/research # one subdirectory of it
rsenv swap diff . # every swapped entry in the projectThe comparison base is the sentinel — the copy of the vault override taken at swap-in.
So swap diff answers:
What have I changed since I swapped in?
which is exactly the content swap out will write back into the vault.
It does not answer "how does my override differ from the committed original". That
question is already answered by git diff for a tracked file, because while swapped in the
project file is the override. The stashed original lives in *.rsenv_original.
The baseline resets on every swap out/in cycle, since swap out writes the project
content back into the vault and the next swap in takes a fresh sentinel.
| Case | Behaviour |
|---|---|
| Dot-files | Reported under the project name (.gitignore), not the neutralized vault name (dot.gitignore) |
| Symlinks | Compared by target; never followed (vault symlinks are often dangling where they sit) |
| Binary files | Reported as changed, never rendered as a patch |
| Swapped out files | Skipped — there is no sentinel to compare against |
| Swapped in by another host | Skipped — that sentinel is another machine's baseline |
# 0=clean, 1=changes, 2=unmanaged
rsenv swap diff --silent; echo $?swap diff shows you what changed. vault commit saves it.
While content is swapped in, the live bytes are in the project and the vault holds only the frozen sentinel. The project's own git ignores those paths, so that work is in no git repo at all until you swap out. A long-lived swap is therefore unbacked work.
rsenv swap out # required first: vault commit never swaps
rsenv vault commit -a # commit, generated message
rsenv vault commit # same, but your editor opens prefilled
rsenv vault commit -a --push # and push the vault repoThe commit is scoped to vaults/<name>-<id>/ alone — other projects' vaults stay untouched
even when they have pending changes. The message records the project's HEAD commit, which
is the link between a state in the vault and the project state it belongs to:
vault(myproject): checkpoint @ a3bddf6
project: /home/you/dev/myproject
project-commit: a3bddf6028e11155c9f2bd66776d395a99a3ef83 (main)
M swap/thoughts/notes.md
| Behaviour worth knowing | |
|---|---|
| Pre-condition | The project must be swapped out; it refuses and names the paths otherwise |
| Nothing changed | Says so and exits without committing |
| Editor quit without saving | Commit aborted; the vault is left untouched |
| Project is dirty | Recorded as (main, dirty) — the hash alone would be a half-truth |
Plaintext in envs/ or guarded/
|
Refuses to commit and names the file |
-s |
Not an option here; -a selects the generated message |
See Command Reference for the full option list.
# vault/swap/config/application.yml (development version)
server:
port: 8080
database:
host: localhost
name: myapp_dev
logging:
level: DEBUG# project/config/application.yml (production version)
server:
port: 443
database:
host: prod-db.internal
name: myapp
logging:
level: WARN# Create dev version with mock services
rsenv swap init config/services.yml
# Edit vault version
vim $RSENV_VAULT/swap/config/services.yml
# Change: payment_api: https://api.stripe.com
# To: payment_api: http://localhost:8081/mock# Your local database password
rsenv swap init .env.local
# Swap in when developing
rsenv swap in .env.local
# Swap out before commits
rsenv swap out .env.localSwap state is tracked per hostname, preventing conflicts when the same vault is accessed from multiple machines (e.g., shared NFS home directories).
When you swap in, rsenv creates:
application.yml@@myhost@@rsenv_active
If someone else swaps in from a different host:
Error: File already swapped on host 'otherhost'
To swap in when another host has the file swapped:
# Option 1: Swap out on the other host first
# (on otherhost) rsenv swap out config/application.yml
# Option 2: Manually remove the sentinel from vault
rm $RSENV_VAULT/swap/config/application.yml@@otherhost@@rsenv_active
rsenv swap in config/application.ymlSwap state is derived, never stored. The authoritative source is the set of sentinels
under $RSENV_VAULT/swap/, which rsenv swap status --silent reports as an exit code:
| Exit code | Meaning |
|---|---|
0 |
Clean - nothing swapped in |
1 |
Dirty - at least one file swapped in |
2 |
Unmanaged - no vault for this directory |
Prompt tools (starship), editors and scripts read RSENV_SWAPPED from the environment, so
derive it once at top level in dot.envrc - direnv then exports it like any other var:
# at the END of dot.envrc, so rsenv is already on PATH
rsenv swap status --silent >/dev/null 2>&1
[[ $? -eq 1 ]] && export RSENV_SWAPPED=1 || unset RSENV_SWAPPEDunset rather than =0 keeps the variable absent when nothing is swapped, so consumers
can test presence (${RSENV_SWAPPED-}) and it does not clutter the environment. direnv
tracks the removal and unexports it on the next evaluation.
Line order does not matter for what gets exported - direnv publishes the final environment - but it does matter for anything in the file that reads the variable.
The exported value is a snapshot from the last direnv evaluation. rsenv invalidates that
snapshot itself: a swap that actually changes state bumps the mtime
of the vault's dot.envrc, which direnv watches through the project's .envrc symlink.
Every shell sitting in the project - not just the one that ran the command - re-evaluates
at its next prompt and re-derives RSENV_SWAPPED. rsenv swap out -g therefore clears
the marker everywhere, which is what makes it usable as a prompt indicator.
Only the timestamp moves; the bytes never do. direnv allow is keyed on
sha256(path + content) and SOPS staleness on content alone, so neither is disturbed.
swapin() {
rsenv swap in thoughts # direnv reloads by itself at the next prompt
}A snapshot is still a snapshot: a shell that never reaches a prompt (a long-running
process, a script that already captured the environment) keeps the old value. Code that
must be correct regardless - a guard in an interactive shell function, say - should ask
rsenv swap status --silent at the point of use rather than trust the exported variable.
# Fail CI if files are swapped
if ! rsenv swap status --silent; then
echo "Error: Cannot build with swapped files"
exit 1
fiUp to v5.5.0 rsenv appended a literal export RSENV_SWAPPED=1 to the vault's dot.envrc
on swap-in and removed it on swap-out. That was wrong on two counts:
-
dot.envrcis content-addressed (dot.envrc.<hash>.enc) and SOPS-encrypted, so the hash flipped with swap state.rsenv sops status --globalreported every swapped-in vault as stale, and the pre-commit hook - which checks all vaults - blocked unrelated commits. Re-encrypting instead just baked transient state into the ciphertext and rewrote a fresh encrypted blob on every swap cycle. - The marker was appended at end-of-file, i.e. after the code that read it, so within a single direnv evaluation it had no effect at all.
scripts/migrate_swapped_marker.py in the rs-env repo migrates existing vaults; run
rsenv sops encrypt --global afterwards.
The mtime bump described above is not a stored marker: a timestamp is metadata, so the
content hash, the ciphertext and direnv allow all stay exactly as they were. It carries
no state either - it only tells direnv "ask rsenv again".
scripts/fix_swap_guards.py migrates shell guards in existing vaults from the exported
variable to a live rsenv swap status --silent check; same dry-run-first workflow, and it
also needs rsenv sops encrypt --global afterwards.
Dotfiles (.gitignore, .env, .hidden/, etc.) in swap directories are automatically neutralized in the vault to prevent them from having active effects.
Dotfiles in your vault could interfere with vault operations:
- A
.gitignorewould affect what git tracks in the vault - A
.envrccould load unexpected environment variables - Hidden directories might be invisible during maintenance
rsenv uses a dot. prefix to neutralize dotfiles:
| In Project | In Vault |
|---|---|
.gitignore |
dot.gitignore |
.envrc |
dot.envrc |
.hidden/config |
dot.hidden/config |
.a/.b/.c |
dot.a/dot.b/dot.c |
| Operation | Dotfiles in vault |
|---|---|
swap init |
Neutralized (dot.xxx) |
swap out |
Neutralized (dot.xxx) |
swap in |
Restored (.xxx in project) |
guard add |
Neutralized (dot.xxx) |
swap in refuses if a bare dotfile (e.g., .gitignore without dot.gitignore) exists in the vault. This prevents conflicts. Delete or rename the conflicting file first.
# Start work
cd ~/myproject
rsenv swap in config/application.yml
# Develop with local config...
# Before commit
rsenv swap out config/application.yml
git add -A
git commit -m "Feature complete"In your CI pipeline, ensure nothing is swapped:
# In CI script - exit code 1 means something is swapped in
if ! rsenv swap status --silent; then
echo "Error: Files are swapped in"
exit 1
fi# Before switching branches
rsenv swap out --global
git checkout feature-branch
# After switching, swap back in if needed
rsenv swap in config/application.ymlvault/swap/
└── config/
└── application.yml # Your development version
Only change structure (move, delete, rename) when vault is in swapped-out state!
project/config/
└── application.yml # Vault's version (moved from vault)
vault/swap/config/
├── application.yml.rsenv_original # Backup of project's original
└── application.yml@@myhost@@rsenv_active # Sentinel (copy of vault content)
Note: All swap artifacts (.rsenv_original, @@rsenv_active) live in the vault, not the project.
Never commit swapped-in files. Add a pre-commit hook:
#!/bin/bash
# .git/hooks/pre-commit
if rsenv swap status --quiet 2>/dev/null; then
echo "Error: Files are swapped in. Run 'rsenv swap out' first."
exit 1
fi# Add alias
alias swapout='rsenv swap out --global'
# Before any commit
swapout && git commitCreate a README in your swap directory:
cat > $RSENV_VAULT/swap/README.md << 'EOF'
# Swap Files
## config/application.yml
Local development config:
- Uses localhost database
- Debug logging enabled
- Mock payment API
## .env.local
Local credentials (not in git)
EOFWhen the original changes, update your swap version:
# See what changed
diff project/config/application.yml $RSENV_VAULT/swap/config/application.yml
# Update swap file
vim $RSENV_VAULT/swap/config/application.yml# Check status
rsenv swap status
# Remove the sentinel from vault and swap in
rm $RSENV_VAULT/swap/file.yml@@otherhost@@rsenv_active
rsenv swap in file.ymlThe original is backed up with .rsenv_original suffix:
# Find backup
ls -la *.rsenv_original
# Manual restore
mv file.yml.rsenv_original file.yml
rm file.yml@@*@@rsenv_active# Initialize it first
rsenv swap init path/to/file.yml# Swap out entire vault (default)
rsenv swap out
# Swap out specific files
rsenv swap out file1.yml file2.yml file3.yml
# Or swap out all vaults
rsenv swap out --global| Aspect | Guard | Swap |
|---|---|---|
| Purpose | Protect secrets | Development overrides |
| Duration | Permanent | Temporary |
| Mechanism | Symlink | File copy |
| Original location | Vault (always) | Project (when swapped out) |
| Git sees | Symlink | Real file |
| Use case | API keys, certs | Local configs, mock URLs |
- Core Concepts - Understanding vault categories
- Vault Management - File guarding (permanent)
- Configuration - rsenv settings
rsenv Documentation