.env Merge

Per-key merge for dotenv files, with secret detection and masked diffs.

Overview

.env files are a special hell. Half the industry stores them in .gitignore and ships them out of band. The other half commits .env.example and reconstructs production values by hand. Either way, when two developers touch the same .env file — adding a feature-flag here, bumping a timeout there — Git's line merge produces spurious conflicts because every key sits on its own line and any new key shifts the blame map.

Aura treats .env as what it is: an ordered list of KEY=VALUE bindings. Its merger operates per key. Adding STRIPE_TIMEOUT=30 and adding SENTRY_DSN=https://... to the same file are disjoint operations. Only modifying the same key on both sides is a conflict.

On top of that, Aura knows .env files carry secrets. Its diff and conflict output masks values that look sensitive, so secrets do not leak into terminal scrollback, CI logs, or screenshots.

A secret that appears in a terminal is a secret that appears on LinkedIn six months later. Mask by default.

How It Works

Parse into an ordered map

Aura's dotenv parser accepts the common dialects:

  • KEY=value
  • KEY="quoted value" with \n and \t escapes
  • KEY='single-quoted, no escapes'
  • KEY= (empty)
  • export KEY=value
  • # comment lines
  • blank lines

The result is an ordered map of entries:

Entry {
  key:     "STRIPE_API_KEY",
  value:   "sk_live_xxx",
  quote:   None | Double | Single,
  export:  bool,
  leading: comment[],
  trailing: comment[],
}

Order is preserved for round-trip fidelity. Comments attach to the next key (or to the footer if no next key exists).

Three-way diff per key

Exactly the table from JSON deep merge, applied per key:

| Ancestor | Ours | Theirs | Action | |----------|------|--------|--------| | present | unchanged | modified | take theirs | | present | modified | unchanged | keep ours | | present | modified | modified, same value | dedupe | | present | modified | modified, different | conflict | | absent | inserted | absent | keep ours | | absent | absent | inserted | take theirs | | absent | inserted | inserted, same value | dedupe | | absent | inserted | inserted, different | conflict | | present | deleted | modified | conflict | | present | deleted | deleted | delete |

Secret detection

Before printing anything to a terminal, Aura scans each entry to decide whether to mask its value. The classifier uses:

  1. Key name heuristics. Substrings: KEY, SECRET, TOKEN, PASSWORD, PASS, PWD, DSN, PRIVATE, CREDENTIAL, CERT, SALT, SEED, SIGNATURE, WEBHOOK. Names like PUBLIC_KEY, PUBLISHABLE_KEY, NEXT_PUBLIC_* are allow-listed.
  2. Value shape heuristics. Vendor-prefixed tokens (sk_live_, pk_test_, xoxb-, AKIA, ghp_, eyJ... JWTs), base64 blobs longer than 24 chars, PEM headers (-----BEGIN).
  3. Project overrides. .aura/secrets.json can force-mask or force-show specific keys.

Masked output renders as KEY=<redacted:sk_live_…3f> — enough hint to disambiguate two different values, none of the actual secret.

Output

Merged files are serialized in the observed style:

  • Original ordering of surviving keys.
  • New keys from ours appended after their nearest sibling, then new keys from theirs.
  • Original quote style preserved per key.
  • Comments travel with their keys.

Examples

A: Two new keys

Ancestor .env.example:

DATABASE_URL=postgres://localhost/app
PORT=3000

Ours:

DATABASE_URL=postgres://localhost/app
PORT=3000
STRIPE_API_KEY=sk_test_placeholder

Theirs:

DATABASE_URL=postgres://localhost/app
PORT=3000
SENTRY_DSN=https://public@sentry.io/1

Per-key diff:

ours    = [Insert("STRIPE_API_KEY")]
theirs  = [Insert("SENTRY_DSN")]
disjoint -> clean merge

Merged (displayed on terminal):

DATABASE_URL=postgres://localhost/app
PORT=3000
STRIPE_API_KEY=<redacted:sk_test_…der>
SENTRY_DSN=https://public@sentry.io/1

On disk, the real values are written — masking is a display concern, not a storage concern.

B: Same key, different values

Ours bumps PORT to 8080. Theirs bumps it to 4000. Aura raises:

CONFLICT: .env :: PORT
  ancestor: 3000
  ours:     8080
  theirs:   4000

Non-secret, so values are shown in full. A resolve decision affects only this key; the rest of the file merges cleanly.

C: Conflict on a secret, masked

CONFLICT: .env :: DATABASE_URL
  ancestor: postgres://localhost/app
  ours:     <redacted:postgres://u:***@prod-a…pp>
  theirs:   <redacted:postgres://u:***@prod-b…pp>

The conflict is real and must be resolved, but the competing values are not splashed to the terminal. The interactive conflict picker offers a reveal keystroke that prints the full values under a one-line confirmation, useful when the masked hint is ambiguous.

D: Reformatting without semantic change

Ours reformats PORT=3000 to PORT="3000". Theirs adds LOG_LEVEL=info. Aura treats the quote change as a style-only edit (value unchanged), applies theirs's insert, and keeps ours's quote style:

DATABASE_URL=postgres://localhost/app
PORT="3000"
LOG_LEVEL=info

E: Deletion + modification

Ours removes a deprecated key OLD_API_URL. Theirs changes its value. Aura raises a conflict: ours wanted it gone, theirs wanted it different. The picker offers keep deleted (ours), keep modified (theirs), or keep both (theirs wins, key stays).

Edge Cases

Multiline values. Values containing \n (either literal in double-quoted form or as real newlines when the parser dialect allows) are treated as opaque scalars. Conflicting multiline values raise a normal conflict; the picker prints them with line numbers.

Interpolation. Some tools support ${OTHER_KEY} interpolation. Aura does not evaluate it — it merges the literal string. A change from ${API_HOST} to a hard-coded URL is a value change like any other.

Export prefix. export FOO=bar is parsed as a normal entry with export: true. The flag is preserved through merge; if one side adds export and the other removes it, Aura takes the non-default (keeps the export).

Comment-only files. .env.example files sometimes hold only commented documentation. Aura still parses them structurally; comments merge cleanly unless both sides rewrite the same comment block.

Duplicate keys. Dotenv behavior varies by loader — last-wins in most, first-wins in some. Aura preserves the order and emits a warning. Merges pick the later occurrence as authoritative.

Gitignored .env files. If your real .env is not tracked, Aura cannot three-way merge it — there is no ancestor. Aura detects this and offers a two-way reconciliation: aura env reconcile prompts per differing key with masked output, using the last local snapshot (see snapshots) as an implicit ancestor when available.

.env.example drift. A common workflow: keep keys in sync between .env.example (committed) and .env (local). Aura can enforce this with aura env lint, which reports keys present in one but not the other.

Leaked mask. If a user explicitly passes --no-mask, Aura prints values in full and records the choice in the audit log. There is no way to opt out silently.

Key ordering conventions. Some teams alphabetize .env files; others group by feature. Aura preserves the observed ordering on the ours side and inserts new keys from theirs at the nearest grouped position — after the last key that shares a prefix, or at the end of the file when no grouping is evident. The ordering heuristic is deliberately simple; a project that needs strict alphabetical order can run aura env sort as a post-merge hook.

Diff output. The default aura diff command renders .env changes in a key-oriented form:

.env
  + LOG_LEVEL = info
  ~ PORT      = 3000 -> 8080
  - OLD_API_URL
  = SENTRY_DSN = <redacted:https://…sentry.io/1>

Additions, modifications, deletions, and unchanged-but-secret entries each get a distinct marker. This output format is stable and suitable for parsing by review tools.

File-name variants. .env, .env.local, .env.production, .env.example, .env.test — Aura recognizes the common variants and applies dotenv merge to each. The suffix is preserved in the path; values do not bleed across variants.

Per-environment precedence. In projects that layer .env.production over .env, Aura merges each file independently. There is no cross-file coupling during merge; precedence is a runtime concern of whichever loader the application uses.

False positives on detection. A key like MONKEY_SEED will match the SEED substring and get masked even though it may not be sensitive. The conservative default is to over-mask; an explicit allow-list in .aura/secrets.json overrides the heuristic:

{
  "never_mask": ["MONKEY_SEED", "DEMO_TOKEN"],
  "always_mask": ["LEGACY_KEY"]
}

Secret scanning integration. Aura's masker shares its rules with aura audit secrets, a separate command that scans the working tree for committed secrets. The same regexes power both features, so a key recognized as sensitive in .env is also flagged if it accidentally lands in a source file.

CI environment. In CI, the terminal is not interactive and mask reveals are never permitted. Aura detects CI=true and NO_COLOR=1 and adjusts — values remain masked regardless of flags, and conflict resolution must be scripted via aura resolve invocations in the job definition.

Audit log retention. Every resolution on an .env file is recorded with masked values by default. Reveals are recorded with a marker that reveal happened, but never with the revealed value. This keeps audit logs safe to archive without redaction.

Shell-sourced dotenv variants. Some projects use full shell syntax (FOO=$(date)) in files named .env. Aura's parser accepts simple command substitutions as opaque values but flags them; treating a dynamic value as a static string on one side and a different string on the other would be misleading. For these repos, text merge may be a better default.

See Also