Skip to content

File Swapping

sysid edited this page Sep 23, 2026 · 10 revisions

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!

The Swap Concept

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.

Commands

Initialize Swap Files

First, move your development override files to the vault:

rsenv swap init config/application.yml

This:

  1. Moves config/application.yml from project to vault/swap/config/application.yml
  2. Project file is removed (now only exists in vault)

Use swap in to put the vault version back into the project.

Swap In

Replace project files with vault versions:

rsenv swap in config/application.yml

This:

  1. Creates sentinel in vault: application.yml@@{hostname}@@rsenv_active (copy of vault content)
  2. Moves project file to vault backup: vault/swap/application.yml.rsenv_original
  3. Moves vault override to project (vault file is removed)

Swap Out

Restore original files:

rsenv swap out config/application.yml

This:

  1. Moves project file back to vault (preserving your changes)
  2. Restores backup from vault: vault/swap/application.yml.rsenv_original → project
  3. Removes sentinel from vault

Check Status

rsenv swap status

Output:

Swap status:
  config/application.yml: IN (swapped on myhost)
  config/database.yml: OUT (original in project)

Swap Out Entire Vault

Swap out all files in the current project's vault:

rsenv swap out

Swap Out All Vaults

Batch restore across all projects:

rsenv swap out --global

Or with a custom vault base directory:

rsenv swap out --global --vault-base ~/my-vaults

Useful before:

  • Committing changes
  • Switching git branches
  • Sharing code with others

Check Status Across All Vaults

See which vaults have active swaps:

rsenv swap status --global

Output:

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"
fi

Useful for:

  • CI/CD pipelines to detect unwanted swaps
  • Pre-commit hooks
  • Shell prompts (project state indicator)
  • Automated cleanup scripts

Delete Swap Files

Remove files from swap management entirely:

rsenv swap delete config/application.yml

This removes from the vault:

  1. The override file (vault/swap/config/application.yml)
  2. 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.

Inspecting Changes

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 tariffs

Rendering, and your pager

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

What the baseline is

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

Behaviour worth knowing

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

Scripting

# 0=clean, 1=changes, 2=unmanaged
rsenv swap diff --silent; echo $?

Checkpointing Into the Vault

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 repo

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

Use Cases

Local Development Overrides

# 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

Mock Service URLs

# 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

Local Credentials

# 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.local

Hostname Tracking

Swap state is tracked per hostname, preventing conflicts when the same vault is accessed from multiple machines (e.g., shared NFS home directories).

How It Works

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'

Resolving Host Conflicts

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

Detecting Swap State

Swap 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

Exporting it for other programs

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_SWAPPED

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

CI/CD Safety

# Fail CI if files are swapped
if ! rsenv swap status --silent; then
    echo "Error: Cannot build with swapped files"
    exit 1
fi

Why not a stored marker?

Up 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.envrc is content-addressed (dot.envrc.<hash>.enc) and SOPS-encrypted, so the hash flipped with swap state. rsenv sops status --global reported 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.

Dotfile Handling

Dotfiles (.gitignore, .env, .hidden/, etc.) in swap directories are automatically neutralized in the vault to prevent them from having active effects.

Why This Matters

Dotfiles in your vault could interfere with vault operations:

  • A .gitignore would affect what git tracks in the vault
  • A .envrc could load unexpected environment variables
  • Hidden directories might be invisible during maintenance

Naming Convention

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

Automatic Behavior

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)

Safety Check

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.

Workflow Example

Development Session

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

CI/CD Safety

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

Branch Switching

# Before switching branches
rsenv swap out --global

git checkout feature-branch

# After switching, swap back in if needed
rsenv swap in config/application.yml

File Locations

Vault Structure

vault/swap/
└── config/
    └── application.yml    # Your development version

Only change structure (move, delete, rename) when vault is in swapped-out state!

When Swapped In

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.

Best Practices

Swap Out Before Commits

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

Use swap out --global Liberally

# Add alias
alias swapout='rsenv swap out --global'

# Before any commit
swapout && git commit

Document Swap Files

Create 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)
EOF

Keep Swap Files Updated

When 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

Troubleshooting

"File already swapped on host X"

# 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.yml

Lost original file

The 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

Swap file not in vault

# Initialize it first
rsenv swap init path/to/file.yml

Multiple swapped files

# 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

Guard vs Swap

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

Related

rsenv Documentation

Getting Started
Features
Reference
Upgrading

Clone this wiki locally