This is a complete rewrite of V1 with 100% functional compatibility and new features.
- Working on several projects, with different teams, many repos is my reality.
- I need a personal workspace, which is attached to the project, but does not become part of its official repository (e.g. patched docker-compose.yml, bespoke Java test classes, etc.)
- My environment configurations are not DRY, they share variables, often follow even a hierarchy (globlal -> company -> region -> stage)
The vault is a directory outside the project that holds a personal workspace: env files, secrets, dev overrides.
It is linked to the project via a single .envrc symlink — the only trace of rsenv in your repo.
YOUR PROJECT THE VAULT
~/projects/myapp/ ~/.rsenv/vaults/myapp-a1b2c3d4/
┌─────────────────────────┐ ┌──────────────────────────────┐
│ │ │ │
│ .envrc ───── symlink ──────────────────── dot.envrc │
│ │ │ │
│ src/ │ │ envs/ │
│ Makefile │ │ local.env │
│ docker-compose.yml │ │ prod.env │
│ config/ │ │ │
│ secrets.yaml │ │ guarded/ (secrets) │
│ database.yml │ │ swap/ (dev overrides)│
│ │ │ .rsenv.toml (vault config) │
└─────────────────────────┘ └──────────────────────────────┘
git-tracked outside project git
minimal footprint: your personal workspace
ONE symlink
How it connects: rsenv vault init creates the vault, moves the .envrc
there (as dot.envrc), and creates the symlink.
Swap temporarily replaces project files with your dev versions. Unlike guard, this is reversible — swap in when you start work, swap out when done.
┌─ NORMAL STATE (swapped out) ──────────────────────────────────────────┐
│ │
│ Project Vault/swap │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ docker-compose.yml │ │ docker-compose.yml │ │
│ │ (official version) │ │ (your dev version) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
│ rsenv swap in
▼
┌─ SWAPPED IN (working) ────────────────────────────────────────────────┐
│ │
│ Project Vault/swap │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ docker-compose.yml │ │ docker-compose.yml │ │
│ │ (your dev version) │ │ .rsenv_original │ │
│ │ ◄── moved here │ │ ◄── backup of official │ │
│ └──────────────────────┘ │ │ │
│ │ docker-compose.yml │ │
│ │ @@<hostname>@@rsenv_active │
│ │ ◄── sentinel (who did it) │
│ └──────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
│ rsenv swap out
▼
back to normal state
(your changes to dev version are PRESERVED)
Hostname tracking: The sentinel <file>@@<hostname>@@rsenv_active records which
machine swapped the file in, preventing conflicts when sharing vaults. It also holds a copy
of the vault content as of swap-in, which is the baseline rsenv swap diff compares against.
Key commands:
rsenv swap init <files>— set up files for swapping (first time)rsenv swap in— replace project files with vault versionsrsenv swap out— restore originals (no args = all files)rsenv swap status— show what's swapped in, by which hostrsenv swap status --silent— exit code only: 0=clean, 1=dirty, 2=unmanagedrsenv swap diff— show what changed in swapped-in files since swap-in, as a patchrsenv swap diff --stat— same, summary only (no patch)rsenv vault commit— commit this project's vault data (requires it to be swapped out)rsenv vault commit -a— same, with a generated commit message
Checkpointing your work into the vault: while content is swapped in, the live bytes are
in the project and the vault holds only a frozen sentinel — so that work is in no git repo
until you swap out. rsenv swap out closes that window; rsenv vault commit then records it
in one commit scoped to this project's vault directory alone, never sweeping in other
projects. It refuses while anything is still swapped in, so it can never commit a sentinel in
place of your work.
$ rsenv swap out
$ rsenv vault commit -a
✓ Committed b4247db to vault (2 files)
M swap/thoughts/notes.md
A swap/thoughts/research/2026-09-13-ranking.md
Linked to: a3bddf6 (main)The message records the project's HEAD commit — 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
vault commit never swaps anything itself — swapping stays your explicit action, which is
also what refreshes RSENV_SWAPPED: a swap cannot write a running shell's environment
(a child process cannot change its parent's), so instead it bumps the mtime of the vault's
dot.envrc. direnv watches that file through the .envrc symlink and re-evaluates at the
next prompt — in every shell sitting in the project, including the ones rsenv swap out -g
never visited. The file's bytes are untouched, so the content hash, the SOPS ciphertext and
direnv allow are all unaffected.
Seeing your changes while swapped in: while a file is swapped in, its content lives in
the project and the vault holds only the sentinel, so neither git diff shows anything.
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.mdLike git diff, the patch is the default view and is piped through your configured pager —
swap diff asks git for it (git var GIT_PAGER), so a core.pager = delta setup renders
these diffs exactly as it renders git's. Paging is skipped when output is not a terminal,
keeping pipes and scripts plain. Patch headers are project-relative, so a viewer can open
the file they point at.
Dot-file names are reported as they appear in the project (.gitignore), not in their
neutralized vault form (dot.gitignore).
Env files form a tree using the # rsenv: parent.env directive.
Children inherit all parent variables and can override them.
File contents: Resulting tree:
┌─ base.env ─────────────────┐ base.env
│ export DB_HOST=localhost │ / \
│ export DB_PORT=5432 │ local.env cloud.env
│ export LOG_LEVEL=info │ / \
└────────────────────────────┘ staging.env prod.env
┌─ cloud.env ────────────────┐
│ # rsenv: base.env │ ◄── links to parent
│ export DB_HOST=rds.aws.com │ ◄── overrides parent
└────────────────────────────┘
┌─ prod.env ─────────────────┐
│ # rsenv: cloud.env │ ◄── links to parent
│ export LOG_LEVEL=error │ ◄── overrides grandparent
└────────────────────────────┘
Build result — rsenv env build prod.env merges the chain:
prod.env ──inherits──► cloud.env ──inherits──► base.env
Merged output (child wins):
┌────────────────────────────────────────────┐
│ export DB_HOST=rds.aws.com ◄ cloud.env │
│ export DB_PORT=5432 ◄ base.env │
│ export LOG_LEVEL=error ◄ prod.env │
└────────────────────────────────────────────┘
Quoting — rsenv preserves the source's quote style, matching shell semantics:
export PW='p@$$w0rd' ◄ single quotes → emitted literal (no expansion)
export DIR="$HOME/bin" ◄ double quotes → $HOME expands when sourced
Store secrets that contain $, backticks, or other shell metacharacters in
single quotes so they survive sourcing verbatim.
Key commands:
rsenv env tree— visualize the hierarchyrsenv env select— fuzzy-pick an env, write to.envrcrsenv env build <file>— merge and output variablesrsenv env envrc <file>— update the vars section ofdot.envrcrsenv env init— recreate the default env files (existing ones swept to<name>.bkp.<ext>)
Guard permanently moves sensitive files to the vault and leaves a symlink behind. Git sees the symlink, not the secret.
BEFORE rsenv guard add config/secrets.yaml
═══════ ═══════════════════════════════════
Project Project Vault/guarded
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ config/ │ │ config/ │ │ config/ │
│ secrets.yaml │ ──guard──► │ secrets.yaml ──────► │ secrets.yaml │
│ (real file) │ │ (symlink) │ │ (real file) │
└───────────────────┘ └───────────────────┘ └───────────────────┘
git tracks: real file git tracks: symlink safe, outside git
(dangerous) (harmless)
Dotfile neutralization: Dotfiles are renamed in the vault to prevent
side effects: .gitignore → dot.gitignore, .envrc → dot.envrc.
Key commands:
rsenv guard add <file>— move to vault, create symlinkrsenv guard list— show all guarded filesrsenv guard restore <file>— move back to project
Vault contents can be encrypted at rest using SOPS (with GPG or Age). rsenv uses content-addressed filenames to detect staleness.
Plaintext Encrypted
secrets.env ──encrypt──► secrets.env.a1b2c3d4.enc
^^^^^^^^
SHA-256 hash prefix of plaintext
Modify secrets.env → hash changes → rsenv detects "stale"
Re-encrypt → new hash → secrets.env.f9e8d7c6.enc
Status categories:
┌──────────────────┬──────────────────────────────────┬──────────────┐
│ Status │ Meaning │ Action │
├──────────────────┼──────────────────────────────────┼──────────────┤
│ current │ Hash matches, up-to-date │ None │
│ stale │ Plaintext changed since encrypt │ Re-encrypt │
│ pending_encrypt │ No encrypted version exists │ Encrypt │
│ orphaned │ .enc exists but plaintext gone │ Can delete │
└──────────────────┴──────────────────────────────────┴──────────────┘
A pre-commit hook (rsenv hook install) blocks commits when files are
stale or unencrypted. Plaintext files are auto-added to .gitignore.
┌─────────────────────────────────────────────────────────────────────────┐
│ rsenv workflow │
│ │
│ 1. rsenv vault init create vault, link via .envrc symlink │
│ 2. rsenv env select pick environment, export variables │
│ 3. rsenv guard add .env move secrets to vault (permanent) │
│ 4. rsenv swap in swap in dev overrides (temporary) │
│ 5. rsenv sops encrypt encrypt vault at rest │
│ │
│ ... work ... │
│ │
│ 6. rsenv swap out restore originals, no traces │
│ 7. rsenv sops encrypt re-encrypt if changed │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Defense in depth:
├── Vault location ──── secrets live outside project directory/git
├── Symlinks ────────── git commits harmless symlinks, not secrets
├── SOPS encryption ─── vault contents encrypted at rest
└── .gitignore sync ─── plaintext auto-ignored by git
# macOS (Homebrew)
brew tap sysid/rsenv
brew install rsenv
# Or via Cargo
cargo install rsenv
rsenv vault init # Create vault for project
rsenv guard add .env # Move .env to vault, create symlink
rsenv env tree # View environment hierarchy
rsenv env select # Interactive environment selectionGetting Started: Installation · Quick Start · Core Concepts
Features: Environment Variables · Vault Management · File Swapping · SOPS Encryption · Backup and Recovery
Reference: Commands · Configuration · Troubleshooting · Migration Guide
BSD-3-Clause

