Skip to content

Repository files navigation

rsenv logo

License: BSD-3-Clause Rust

This is a complete rewrite of V1 with 100% functional compatibility and new features.

Why?

  • 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)

1. The Vault

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.


2. File Swapping

Swap temporarily replaces project files with your dev versions. Unlike guard, this is reversible — swap in when you start work, swap out when done.

demo

  ┌─ 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 versions
  • rsenv swap out — restore originals (no args = all files)
  • rsenv swap status — show what's swapped in, by which host
  • rsenv swap status --silent — exit code only: 0=clean, 1=dirty, 2=unmanaged
  • rsenv swap diff — show what changed in swapped-in files since swap-in, as a patch
  • rsenv 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.md

Like 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).


3. Environment Hierarchy

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 hierarchy
  • rsenv env select — fuzzy-pick an env, write to .envrc
  • rsenv env build <file> — merge and output variables
  • rsenv env envrc <file> — update the vars section of dot.envrc
  • rsenv env init — recreate the default env files (existing ones swept to <name>.bkp.<ext>)

4. File Guarding

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 symlink
  • rsenv guard list — show all guarded files
  • rsenv guard restore <file> — move back to project

5. SOPS Encryption

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.


Putting It All Together

  ┌─────────────────────────────────────────────────────────────────────────┐
  │                          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

See all features in the wiki

Quick Start

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

Full quick start guide

Full command reference

Documentation

Getting Started: Installation · Quick Start · Core Concepts

Features: Environment Variables · Vault Management · File Swapping · SOPS Encryption · Backup and Recovery

Reference: Commands · Configuration · Troubleshooting · Migration Guide

License

BSD-3-Clause

About

Developer Environments Evolved

Resources

Stars

42 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages