├── .editorconfig ├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go └── testdata ├── 1.yml ├── 2.yml └── diff.golden /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [Makefile] 10 | indent_style = tab 11 | indent_size = 4 12 | 13 | [*.go] 14 | indent_style = tab 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.21.1' 23 | 24 | - name: ci 25 | run: make ci 26 | 27 | - name: build_release 28 | run: make VERSION=${GITHUB_REF_NAME} release -j3 29 | if: github.ref_type == 'tag' 30 | 31 | - name: publish 32 | uses: softprops/action-gh-release@v1 33 | if: github.ref_type == 'tag' 34 | with: 35 | files: 'release/*' 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yamldiff 2 | vendor/ 3 | coverage/ 4 | release/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Sahil Muthoo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: setup lint test 3 | 4 | PKGS := $(shell go list ./... | grep -v /vendor) 5 | .PHONY: test 6 | test: setup 7 | go test $(PKGS) 8 | 9 | sources = $(shell find . -name '*.go' -not -path './vendor/*') 10 | .PHONY: goimports 11 | goimports: setup 12 | goimports -w $(sources) 13 | 14 | .PHONY: lint 15 | lint: setup 16 | $(BIN_DIR)/golangci-lint run 17 | 18 | COVERAGE := $(CURDIR)/coverage 19 | COVER_PROFILE :=$(COVERAGE)/cover.out 20 | TMP_COVER_PROFILE :=$(COVERAGE)/cover.tmp 21 | .PHONY: cover 22 | cover: setup 23 | rm -rf $(COVERAGE) 24 | mkdir -p $(COVERAGE) 25 | echo "mode: set" > $(COVER_PROFILE) 26 | for pkg in $(PKGS); do \ 27 | go test -v -coverprofile=$(TMP_COVER_PROFILE) $$pkg; \ 28 | if [ -f $(TMP_COVER_PROFILE) ]; then \ 29 | grep -v 'mode: set' $(TMP_COVER_PROFILE) >> $(COVER_PROFILE); \ 30 | rm $(TMP_COVER_PROFILE); \ 31 | fi; \ 32 | done 33 | go tool cover -html=$(COVER_PROFILE) -o $(COVERAGE)/index.html 34 | 35 | .PHONY: ci 36 | ci: setup lint test 37 | 38 | .PHONY: install 39 | install: setup 40 | go install $(PKGS) 41 | 42 | .PHONY: build 43 | build: setup 44 | go build $(PKGS) 45 | 46 | GOPATH ?= $(HOME)/go 47 | BIN_DIR := $(GOPATH)/bin 48 | GOIMPORTS := $(BIN_DIR)/goimports 49 | GOLANG_CI_LINT := $(BIN_DIR)/golangci-lint 50 | 51 | $(GOIMPORTS): 52 | go get -u golang.org/x/tools/cmd/goimports 53 | 54 | $(GOLANG_CI_LINT): 55 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(BIN_DIR) v1.54.2 56 | 57 | tools: $(GOIMPORTS) $(GOLANG_CI_LINT) 58 | 59 | setup: tools 60 | 61 | BINARY := yamldiff 62 | VERSION ?= latest 63 | PLATFORMS := darwin/amd64 linux/amd64 windows/amd64 64 | 65 | temp = $(subst /, ,$@) 66 | os = $(word 1, $(temp)) 67 | arch = $(word 2, $(temp)) 68 | 69 | .PHONY: $(PLATFORMS) 70 | $(PLATFORMS): setup 71 | mkdir -p $(CURDIR)/release 72 | CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build -ldflags="-X main.version=$(VERSION)" \ 73 | -o release/$(BINARY)-v$(VERSION)-$(os)-$(arch) 74 | 75 | .PHONY: release 76 | release: $(PLATFORMS) 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yamldiff 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/sahilm/yamldiff?cache=bust)](https://goreportcard.com/report/github.com/sahilm/yamldiff) 3 | 4 | A CLI tool to diff two YAML/JSON files. 5 | 6 | Nothing fancy about the code, all the heavy liftin' is done by: 7 | 8 | * [go-yaml](https://github.com/go-yaml/yaml/) - for YAML parsin' 9 | * [r3labs/diff](github.com/r3labs/diff/v3) - for diffin' 10 | * [aurora](https://github.com/logrusorgru/aurora) - for fancy printin' 11 | * [go-isatty](https://github.com/mattn/go-isatty) - for tty detectin' 12 | * [go-flags](https://github.com/jessevdk/go-flags) - for flaggin' 13 | * [The Go stdlib](https://golang.org/pkg/) - for everythin' 14 | 15 | Thanks to all the contributors of the above libraries. 16 | 17 | ## Usage 18 | 19 | `yamldiff /path/to/yamlfile1.yml /path/to/yamlfile2.yml`. The output is colorized by default. Colors 20 | can be suppressed by the `--no-color` flag. Colors will automatically be suppressed if `stdout` is not a `tty`, for example 21 | when piping/redirecting the output of `yamldiff`. 22 | 23 | ## License 24 | 25 | The MIT License (MIT) 26 | 27 | Copyright (c) 2023 Sahil Muthoo 28 | 29 | Permission is hereby granted, free of charge, to any person obtaining a copy 30 | of this software and associated documentation files (the "Software"), to deal 31 | in the Software without restriction, including without limitation the rights 32 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 33 | copies of the Software, and to permit persons to whom the Software is 34 | furnished to do so, subject to the following conditions: 35 | 36 | The above copyright notice and this permission notice shall be included in all 37 | copies or substantial portions of the Software. 38 | 39 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 40 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 41 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 42 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 43 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 44 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 45 | SOFTWARE. 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sahilm/yamldiff 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/jessevdk/go-flags v1.5.0 7 | github.com/logrusorgru/aurora v2.0.3+incompatible 8 | github.com/mattn/go-isatty v0.0.19 9 | github.com/r3labs/diff/v3 v3.0.1 10 | gopkg.in/yaml.v2 v2.4.0 11 | ) 12 | 13 | require ( 14 | github.com/vmihailenco/msgpack/v5 v5.4.0 // indirect 15 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 16 | golang.org/x/sys v0.13.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= 4 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 5 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 6 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 7 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 8 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/r3labs/diff/v3 v3.0.1 h1:CBKqf3XmNRHXKmdU7mZP1w7TV0pDyVCis1AUHtA4Xtg= 12 | github.com/r3labs/diff/v3 v3.0.1/go.mod h1:f1S9bourRbiM66NskseyUdo0fTmEE0qKrikYJX63dgo= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 15 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 16 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 17 | github.com/vmihailenco/msgpack/v5 v5.4.0 h1:hRM0digJwyR6vll33NNAwCFguy5JuBD6jxDmQP3l608= 18 | github.com/vmihailenco/msgpack/v5 v5.4.0/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 19 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 20 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 21 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 24 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 28 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 30 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/r3labs/diff/v3" 11 | 12 | "github.com/jessevdk/go-flags" 13 | "github.com/logrusorgru/aurora" 14 | "github.com/mattn/go-isatty" 15 | "gopkg.in/yaml.v2" 16 | ) 17 | 18 | var version = "latest" 19 | 20 | func main() { 21 | var opts struct { 22 | NoColor bool `long:"no-color" description:"disable colored output" required:"false"` 23 | Version func() `long:"version" description:"print version and exit"` 24 | } 25 | 26 | opts.Version = func() { 27 | fmt.Fprintf(os.Stderr, "%v\n", version) 28 | os.Exit(0) 29 | } 30 | 31 | files, err := flags.Parse(&opts) 32 | if err != nil { 33 | if err.(*flags.Error).Type == flags.ErrHelp { 34 | os.Exit(0) 35 | } 36 | os.Exit(1) 37 | } 38 | 39 | if len(files) < 2 { 40 | fmt.Fprintln(os.Stderr, "Two filenames must be supplied for comparison") 41 | os.Exit(1) 42 | } else if len(files) > 2 { 43 | fmt.Fprintln(os.Stderr, "Too many command line options, yamldiff can only compare two files") 44 | os.Exit(1) 45 | } 46 | 47 | formatter := newFormatter(opts.NoColor) 48 | 49 | file1 := files[0] 50 | file2 := files[1] 51 | errors := stat(file1, file2) 52 | failOnErr(formatter, errors...) 53 | 54 | yaml1, err := unmarshal(file1) 55 | if err != nil { 56 | failOnErr(formatter, err) 57 | } 58 | yaml2, err := unmarshal(file2) 59 | if err != nil { 60 | failOnErr(formatter, err) 61 | } 62 | 63 | computedDiff := computeDiff(formatter, yaml1, yaml2) 64 | if computedDiff != "" { 65 | fmt.Println(computedDiff) 66 | } 67 | } 68 | 69 | func stat(filenames ...string) []error { 70 | var errs []error 71 | for _, filename := range filenames { 72 | if filename == "-" { 73 | continue 74 | } 75 | _, err := os.Stat(filename) 76 | if err != nil { 77 | errs = append(errs, fmt.Errorf("cannot find file: %v. Does it exist", filename)) 78 | } 79 | } 80 | return errs 81 | } 82 | 83 | func unmarshal(filename string) (interface{}, error) { 84 | var contents []byte 85 | var err error 86 | if filename == "-" { 87 | contents, err = io.ReadAll(os.Stdin) 88 | } else { 89 | contents, err = os.ReadFile(filename) 90 | } 91 | if err != nil { 92 | return nil, err 93 | } 94 | var ret interface{} 95 | err = yaml.Unmarshal(contents, &ret) 96 | if err != nil { 97 | return nil, err 98 | } 99 | return ret, nil 100 | } 101 | 102 | func failOnErr(formatter aurora.Aurora, errs ...error) { 103 | if len(errs) == 0 { 104 | return 105 | } 106 | var errMessages []string 107 | for _, err := range errs { 108 | errMessages = append(errMessages, err.Error()) 109 | } 110 | fmt.Fprintf(os.Stderr, "%v\n\n", formatter.Red(strings.Join(errMessages, "\n"))) 111 | os.Exit(1) 112 | } 113 | 114 | func computeDiff(formatter aurora.Aurora, a interface{}, b interface{}) string { 115 | diffs := make([]string, 0) 116 | differ, err := diff.NewDiffer(diff.AllowTypeMismatch(true)) 117 | if err != nil { 118 | return err.Error() 119 | } 120 | changelog, err := differ.Diff(a, b) 121 | if err != nil { 122 | return err.Error() 123 | } 124 | for _, s := range changelog { 125 | pathStr := strings.Join(s.Path, ".") 126 | fromStr := formatter.Red(fmt.Sprintf("- %v", s.From)) 127 | toStr := formatter.Green(fmt.Sprintf("+ %v", s.To)) 128 | chunk := fmt.Sprintf("%s:\n%s\n%s\n", pathStr, fromStr, toStr) 129 | diffs = appendSorted(diffs, chunk) 130 | } 131 | return strings.Join(diffs, "\n") 132 | } 133 | 134 | func newFormatter(noColor bool) aurora.Aurora { 135 | var formatter aurora.Aurora 136 | if noColor || !isTerminal() { 137 | formatter = aurora.NewAurora(false) 138 | } else { 139 | formatter = aurora.NewAurora(true) 140 | } 141 | return formatter 142 | } 143 | 144 | func isTerminal() bool { 145 | fd := os.Stdout.Fd() 146 | switch { 147 | case isatty.IsTerminal(fd): 148 | return true 149 | case isatty.IsCygwinTerminal(fd): 150 | return true 151 | default: 152 | return false 153 | } 154 | } 155 | 156 | func appendSorted(ss []string, s string) []string { 157 | i := sort.SearchStrings(ss, s) 158 | ss = append(ss, "") 159 | copy(ss[i+1:], ss[i:]) 160 | ss[i] = s 161 | return ss 162 | } 163 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | var update = flag.Bool("update", false, "update .golden files") 13 | 14 | func TestYamlDiff(t *testing.T) { 15 | goInstall(t) 16 | goldenfile := "testdata/diff.golden" 17 | if *update { 18 | err := os.WriteFile(goldenfile, runYamldiff(t), 0644) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | } 23 | contents, err := os.ReadFile(goldenfile) 24 | want := string(contents) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | got := string(runYamldiff(t)) 29 | if got != want { 30 | t.Errorf("got: %v want: %v", got, want) 31 | } 32 | } 33 | 34 | func goInstall(t *testing.T) { 35 | install := exec.Command("go", "build") 36 | err := install.Run() 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | } 41 | 42 | func runYamldiff(t *testing.T) []byte { 43 | var out bytes.Buffer 44 | yamldiff := exec.Command("./yamldiff", "testdata/1.yml", "testdata/2.yml") 45 | yamldiff.Stdout = &out 46 | 47 | err := yamldiff.Start() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | done := make(chan bool) 52 | go func() { 53 | err = yamldiff.Wait() 54 | done <- true 55 | }() 56 | timeout := time.Millisecond * 1000 57 | select { 58 | case <-done: 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | case <-time.After(timeout): 63 | t.Fatalf("timed out after %v", timeout) 64 | } 65 | return out.Bytes() 66 | } 67 | -------------------------------------------------------------------------------- /testdata/1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: bar 3 | something: 4 | - is: 1 5 | - hello: world 6 | stuff: 200 7 | -------------------------------------------------------------------------------- /testdata/2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | something: 3 | - is: 2 4 | - hello: world 5 | -------------------------------------------------------------------------------- /testdata/diff.golden: -------------------------------------------------------------------------------- 1 | foo: 2 | - bar 3 | + 4 | 5 | something.0.is: 6 | - 1 7 | + 2 8 | 9 | stuff: 10 | - 200 11 | + 12 | 13 | --------------------------------------------------------------------------------