You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split the module so a Go program can obtain the same environment internally, with no wrapper process at all.
res, err:=env.Apply() // that is the whole of it
Split out of #35 on 2026-08-23, so that #35 could ship the hand-off on its own.
The decision record is ADR-002, still Proposed while the questions below are open. It carries the reasoning; this issue carries the summary. Where they disagree, the ADR is right.
Why a library at all
Not symmetry with the command. The value is semantic parity: one file means one thing whether it is read by envrun cmd in CI or imported in a test binary. godotenvaccepts what envrun rejects — interpolation, quoted multiline values, lax names — so a team using both would have its two halves reading different dialects of one file, and would find out only when they disagreed. A single shared core is what makes "the same file means the same thing" a promise rather than a slogan, and envrun's deliberate narrowness is what makes the promise keepable.
Two entry points, because envrun has two callers wanting opposite things:
env.Load(paths ...string) (Result, error) — resolve and read, applying nothing. The command's entry point: it must not put the variables into its own process, because what it needs is the set to hand to the command. Also the entry point for any caller that cannot afford the mutation.
env.Apply(paths ...string) (Result, error) — Load, then apply to this process, leaving an already-set name alone so the inherited value wins, exactly as the command's merge does. It belongs at the top of main or in TestMain: there is no concurrency-safe way to mutate a process environment.
Result carries Path (which file was used), Env (a map[string]string of what the file declared, never the merge — #39 needs precisely that set), and Notes (non-fatal findings).
Rejections stay fatal but become inspectable two ways. Every problem wraps one of ErrNotAPair, ErrInvalidName or ErrNUL, and the file's ParseError wraps them all through Unwrap() []error — so errors.Is(err, env.ErrInvalidName) answers did it fail for this reason? without the caller knowing ParseError exists, while errors.AsType[*env.ParseError] reaches Path and []Problem{Line, Err, Name} for counting and locating. No value ever leaves the package inside a message: names and line numbers only, because a malformed line may hold a secret.
paths... is a search path — first match wins — with -f overriding it. Composition (.env then .env.local) stays a separate future feature rather than a second meaning for the same parameter.
Not in scope
No autoload package. The blank import was the shape this issue originally set out to deliver, and it is declined as currently imagined: the blank import and usefulness are mutually exclusive, since checking autoload.Err() makes the import non-blank and env.Apply() is then shorter; checking nothing contradicts envrun's own rule that a malformed file fails rather than proceeds; panicking does not rescue it, because that precedent (sql.Register, MustCompile) is for programmer error and a .env file is input; and its search rule is the one change here that no compiler would catch. envrun go test ./... and a three-line TestMain already serve its best habitat better. The ADR states five arguments; a future proposal should answer them rather than start fresh.
No logger parameter, in any form — io.Writer, *log.Logger or *slog.Logger. Result already hands back everything observed, so a logger would be a second channel for the same findings, and each form trades reach against fidelity in a way returning the data avoids entirely.
example/import, in-module and adding no direct dependencies, so go build ./... compile-tests it on every CI run. An ExampleApply in a _test.go complements it, since only Example functions appear on pkg.go.dev.
Open
When duplicate detection arrives, and with it Notes becoming a struct — a name, a primary line and a related line — rather than []string. The semantics are settled (last wins); only the timing is not.
Split the module so a Go program can obtain the same environment internally, with no wrapper process at all.
Split out of #35 on 2026-08-23, so that #35 could ship the hand-off on its own.
The decision record is ADR-002, still
Proposedwhile the questions below are open. It carries the reasoning; this issue carries the summary. Where they disagree, the ADR is right.Why a library at all
Not symmetry with the command. The value is semantic parity: one file means one thing whether it is read by
envrun cmdin CI or imported in a test binary.godotenvaccepts what envrun rejects — interpolation, quoted multiline values, lax names — so a team using both would have its two halves reading different dialects of one file, and would find out only when they disagreed. A single shared core is what makes "the same file means the same thing" a promise rather than a slogan, and envrun's deliberate narrowness is what makes the promise keepable.Layout
github.com/fgm/envrun— the CLI stays at the rootgithub.com/fgm/envrun/env— load, parse, merge, applyShape
Two entry points, because envrun has two callers wanting opposite things:
env.Load(paths ...string) (Result, error)— resolve and read, applying nothing. The command's entry point: it must not put the variables into its own process, because what it needs is the set to hand to the command. Also the entry point for any caller that cannot afford the mutation.env.Apply(paths ...string) (Result, error)—Load, then apply to this process, leaving an already-set name alone so the inherited value wins, exactly as the command's merge does. It belongs at the top ofmainor inTestMain: there is no concurrency-safe way to mutate a process environment.ResultcarriesPath(which file was used),Env(amap[string]stringof what the file declared, never the merge — #39 needs precisely that set), andNotes(non-fatal findings).Rejections stay fatal but become inspectable two ways. Every problem wraps one of
ErrNotAPair,ErrInvalidNameorErrNUL, and the file'sParseErrorwraps them all throughUnwrap() []error— soerrors.Is(err, env.ErrInvalidName)answers did it fail for this reason? without the caller knowingParseErrorexists, whileerrors.AsType[*env.ParseError]reachesPathand[]Problem{Line, Err, Name}for counting and locating. No value ever leaves the package inside a message: names and line numbers only, because a malformed line may hold a secret.paths...is a search path — first match wins — with-foverriding it. Composition (.envthen.env.local) stays a separate future feature rather than a second meaning for the same parameter.Not in scope
autoloadpackage. The blank import was the shape this issue originally set out to deliver, and it is declined as currently imagined: the blank import and usefulness are mutually exclusive, since checkingautoload.Err()makes the import non-blank andenv.Apply()is then shorter; checking nothing contradicts envrun's own rule that a malformed file fails rather than proceeds; panicking does not rescue it, because that precedent (sql.Register,MustCompile) is for programmer error and a.envfile is input; and its search rule is the one change here that no compiler would catch.envrun go test ./...and a three-lineTestMainalready serve its best habitat better. The ADR states five arguments; a future proposal should answer them rather than start fresh.io.Writer,*log.Loggeror*slog.Logger.Resultalready hands back everything observed, so a logger would be a second channel for the same findings, and each form trades reach against fidelity in a way returning the data avoids entirely..envpackage. A feature is not adopted on the grounds that other.envlibraries carry it. Variable interpolation is declined on that basis; see - Change the order in which the environment variables from the .env f… #6.Demo
example/import, in-module and adding no direct dependencies, sogo build ./...compile-tests it on every CI run. AnExampleApplyin a_test.gocomplements it, since onlyExamplefunctions appear on pkg.go.dev.Open
Notesbecoming a struct — a name, a primary line and a related line — rather than[]string. The semantics are settled (last wins); only the timing is not.PATHhas no effect at all.fail/notehelpers as they stand, an injectedio.Writer, orlog/slog.