├── internal ├── env.go ├── git.go └── diff.go ├── .env ├── .gitignore ├── main.go ├── go.mod ├── cmd └── root.go ├── go.sum └── README.md /internal/env.go: -------------------------------------------------------------------------------- 1 | package internal 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DEBUG=false 2 | PORT=9090 3 | API_KEY=abc123 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | *.log 3 | .env.* 4 | .env 5 | !sample.env -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/ashishsalunkhe/goenvdiff/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /internal/git.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | ) 8 | 9 | func ReadEnvFromGit(ref string, path string) ([]byte, error) { 10 | cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", ref, path)) 11 | var out bytes.Buffer 12 | cmd.Stdout = &out 13 | err := cmd.Run() 14 | if err != nil { 15 | return nil, err 16 | } 17 | return out.Bytes(), nil 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ashishsalunkhe/goenvdiff 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/fatih/color v1.18.0 // indirect 7 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 8 | github.com/joho/godotenv v1.5.1 // indirect 9 | github.com/mattn/go-colorable v0.1.13 // indirect 10 | github.com/mattn/go-isatty v0.0.20 // indirect 11 | github.com/spf13/cobra v1.9.1 // indirect 12 | github.com/spf13/pflag v1.0.6 // indirect 13 | golang.org/x/sys v0.25.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /internal/diff.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | type DiffType int 10 | 11 | const ( 12 | Added DiffType = iota 13 | Removed 14 | Changed 15 | ) 16 | 17 | type DiffResult struct { 18 | Key string 19 | OldValue string 20 | NewValue string 21 | Type DiffType 22 | } 23 | 24 | func DiffEnvs(from, to map[string]string) []DiffResult { 25 | var results []DiffResult 26 | 27 | for k, v := range from { 28 | if newVal, ok := to[k]; ok { 29 | if newVal != v { 30 | results = append(results, DiffResult{Key: k, OldValue: v, NewValue: newVal, Type: Changed}) 31 | } 32 | } else { 33 | results = append(results, DiffResult{Key: k, OldValue: v, Type: Removed}) 34 | } 35 | } 36 | 37 | for k, v := range to { 38 | if _, ok := from[k]; !ok { 39 | results = append(results, DiffResult{Key: k, NewValue: v, Type: Added}) 40 | } 41 | } 42 | 43 | return results 44 | } 45 | 46 | func PrintDiff(results []DiffResult) { 47 | green := color.New(color.FgGreen).SprintFunc() 48 | red := color.New(color.FgRed).SprintFunc() 49 | yellow := color.New(color.FgYellow).SprintFunc() 50 | 51 | for _, r := range results { 52 | switch r.Type { 53 | case Added: 54 | fmt.Printf("%s %s added (%s)\n", green("+"), r.Key, r.NewValue) 55 | case Removed: 56 | fmt.Printf("%s %s removed (was %s)\n", red("-"), r.Key, r.OldValue) 57 | case Changed: 58 | fmt.Printf("%s %s changed from %s to %s\n", yellow("~"), r.Key, r.OldValue, r.NewValue) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/ashishsalunkhe/goenvdiff/internal" 9 | "github.com/joho/godotenv" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var fromRef, toRef, path string 14 | var jsonOutput bool 15 | 16 | var rootCmd = &cobra.Command{ 17 | Use: "goenvdiff", 18 | Short: "Compare .env files between Git branches or commits", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | fromBytes, err := internal.ReadEnvFromGit(fromRef, path) 21 | if err != nil { 22 | fmt.Printf("Error reading from %s: %v\n", fromRef, err) 23 | os.Exit(1) 24 | } 25 | 26 | toBytes, err := internal.ReadEnvFromGit(toRef, path) 27 | if err != nil { 28 | fmt.Printf("Error reading from %s: %v\n", toRef, err) 29 | os.Exit(1) 30 | } 31 | 32 | fromEnv, err := godotenv.Unmarshal(string(fromBytes)) 33 | if err != nil { 34 | fmt.Printf("Error parsing env from %s: %v\n", fromRef, err) 35 | os.Exit(1) 36 | } 37 | 38 | toEnv, err := godotenv.Unmarshal(string(toBytes)) 39 | if err != nil { 40 | fmt.Printf("Error parsing env from %s: %v\n", toRef, err) 41 | os.Exit(1) 42 | } 43 | 44 | diffs := internal.DiffEnvs(fromEnv, toEnv) 45 | 46 | if jsonOutput { 47 | jsonBytes, _ := json.MarshalIndent(diffs, "", " ") 48 | fmt.Println(string(jsonBytes)) 49 | } else { 50 | internal.PrintDiff(diffs) 51 | } 52 | }, 53 | } 54 | 55 | func Execute() { 56 | rootCmd.PersistentFlags().StringVar(&fromRef, "from", "main", "Git ref to compare from") 57 | rootCmd.PersistentFlags().StringVar(&toRef, "to", "feature", "Git ref to compare to") 58 | rootCmd.PersistentFlags().StringVar(&path, "path", ".env", "Path to env file") 59 | rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") 60 | rootCmd.Execute() 61 | } 62 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 3 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 7 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 8 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 9 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 10 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 11 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 12 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 13 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 15 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 16 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 17 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 18 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 20 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 21 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goenvdiff 2 | 3 | Git-aware `.env` drift detector for platform engineers, SREs, and developers. 4 | 5 | **goenvdiff** identifies differences in `.env` configuration files across Git branches or commits. It compares key-value pairs and shows what’s added, removed, or changed — useful during code reviews, DevOps audits, or deployment validations. 6 | 7 | ## Features 8 | 9 | * Git-aware comparison of `.env` files across branches or commits 10 | * Shows added, removed, and modified keys 11 | * Color-coded terminal output 12 | * JSON output support via `--json` for automation 13 | * Simple and extensible CLI using Cobra 14 | 15 | ## Installation & Environment Setup 16 | 17 | ### Requirements 18 | 19 | * Go 1.20 or higher 20 | * Git CLI installed 21 | * VS Code or any text editor 22 | 23 | ### Setup Locally 24 | 25 | ```bash 26 | git clone https://github.com/yourusername/goenvdiff.git 27 | cd goenvdiff 28 | go mod tidy 29 | go build -o goenvdiff 30 | ``` 31 | 32 | ### Optional: Install Globally 33 | 34 | ```bash 35 | go install github.com/yourusername/goenvdiff@latest 36 | ``` 37 | 38 | ## Usage 39 | 40 | ```bash 41 | goenvdiff --from main --to feature/login --path .env 42 | ``` 43 | 44 | ### Sample Output 45 | 46 | ``` 47 | + API_KEY added (abc123) 48 | - DEBUG removed (was true) 49 | ~ PORT changed from 8080 to 9090 50 | ``` 51 | 52 | ### JSON Mode 53 | 54 | ```bash 55 | goenvdiff --from main --to feature/login --path .env --json 56 | ``` 57 | 58 | ```json 59 | [ 60 | { "Key": "API_KEY", "OldValue": "", "NewValue": "abc123", "Type": 0 }, 61 | { "Key": "DEBUG", "OldValue": "true", "NewValue": "", "Type": 1 }, 62 | { "Key": "PORT", "OldValue": "8080", "NewValue": "9090", "Type": 2 } 63 | ] 64 | ``` 65 | 66 | ## Project Structure 67 | 68 | ``` 69 | . 70 | ├── main.go # Entry point; wires up CLI 71 | ├── cmd/ 72 | │ └── root.go # Cobra CLI setup and flag handling 73 | ├── internal/ 74 | │ ├── diff.go # Core diffing logic 75 | │ ├── git.go # Reads `.env` from Git refs (branches/commits) 76 | │ └── env.go # Placeholder for env validation or merging 77 | ├── go.mod # Go module metadata 78 | ├── .gitignore 79 | ├── README.md # Project documentation 80 | └── testdata/ # (Optional) Test fixtures for `.env` files 81 | ``` 82 | 83 | ## Architecture Overview 84 | 85 | ``` 86 | +------------+ +------------------+ +---------------+ 87 | | Git Commit | ---> | Read .env file | ---> | Parse KeyVals | 88 | +------------+ +------------------+ +---------------+ 89 | | | 90 | | v 91 | | +--------------------------+ 92 | +---> another Git ref ---> | Diff Key-Value Pairs | 93 | | - Added / Removed / Mod | 94 | +--------------------------+ 95 | | 96 | v 97 | +------------------------------------+ 98 | | Print Output or Export as JSON | 99 | +------------------------------------+ 100 | ``` 101 | 102 | ## Running Tests 103 | 104 | ```bash 105 | go test ./internal/... 106 | ``` 107 | 108 | ## Steps to Reproduce 109 | 110 | 1. Create `.env` files on two Git branches: 111 | 112 | ```bash 113 | echo "PORT=8080\nDEBUG=true" > .env 114 | git checkout -b main 115 | git add .env && git commit -m "main env" 116 | 117 | git checkout -b feature/login 118 | echo "PORT=9090\nAPI_KEY=xyz" > .env 119 | git commit -am "feature env" 120 | ``` 121 | 122 | 2. Run the tool: 123 | 124 | ```bash 125 | goenvdiff --from main --to feature/login --path .env 126 | ``` 127 | 128 | ## Roadmap 129 | 130 | * [ ] Support multi-file diffs (`.env.production`, `.env.development`) 131 | * [ ] HTML/Markdown export 132 | * [ ] Git pre-commit integration 133 | * [ ] Homebrew install formula 134 | 135 | ## License 136 | 137 | MIT — feel free to use, extend, and contribute. 138 | 139 | ## Contributing 140 | 141 | Feel free to fork, file issues, or submit PRs. Run `go fmt ./...` before committing. 142 | --------------------------------------------------------------------------------