├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── run-tests.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── argo-compare │ ├── cli.go │ ├── cli_test.go │ ├── compare.go │ ├── compare_test.go │ ├── git.go │ ├── git_test.go │ ├── interfaces │ └── interfaces.go │ ├── main.go │ ├── main_test.go │ ├── target.go │ ├── target_test.go │ └── utils │ ├── cmdrunner.go │ ├── cmdrunner_test.go │ ├── file-reader.go │ ├── file-reader_test.go │ ├── globber.go │ ├── globber_test.go │ ├── helm-chart-processor.go │ ├── helm-chart-processor_test.go │ ├── osfs.go │ └── osfs_test.go ├── codecov.yml ├── go.mod ├── go.sum ├── internal ├── helpers │ ├── helpers.go │ └── helpers_test.go └── models │ ├── application.go │ ├── application_test.go │ └── repo.go ├── patch └── diff-so-fancy.patch └── testdata ├── disposable └── .gitkeep ├── dynamic └── deployment.yaml ├── repo.git ├── HEAD ├── config ├── description ├── hooks │ ├── applypatch-msg.sample │ ├── commit-msg.sample │ ├── fsmonitor-watchman.sample │ ├── post-update.sample │ ├── pre-applypatch.sample │ ├── pre-commit.sample │ ├── pre-merge-commit.sample │ ├── pre-push.sample │ ├── pre-rebase.sample │ ├── pre-receive.sample │ ├── prepare-commit-msg.sample │ ├── push-to-checkout.sample │ └── update.sample ├── info │ └── exclude ├── objects │ ├── 14 │ │ └── 4ee8cf8a2bc327c05e5478f46c8b4d867c2d53 │ ├── 41 │ │ └── 334bbafb63ec09623eb5e970ef98d39bb3840f │ ├── 42 │ │ └── fa0288a96d4c0c0ed530f982f13b31fde03da6 │ ├── 61 │ │ └── 95e9b8bde1e012ccf756216fa225d35fa33690 │ ├── 63 │ │ └── 988c89a49d0b3a2afa0764ef4ea8e76d501b46 │ ├── 93 │ │ └── 9b376ec8b6af2ba467bd0ca7d57499159b3ba3 │ ├── 5e │ │ └── c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0 │ ├── 8f │ │ └── c4774c9313d27406df5e3ff71fa4ec68c0deb7 │ ├── b8 │ │ └── 5e290803976b2a52702a0a699706f6e4c4ed86 │ ├── bd │ │ └── a469dd11cdfa8cc28bc7e044c360737327e3ce │ └── fc │ │ └── a984329cd120e7b7fe648cb534a2add7291d41 └── packed-refs ├── test-values.yaml ├── test.yaml └── test2.yaml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write # needed to write releases 10 | packages: write # needed to write packages 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up QEMU # required for multi architecture build - https://goreleaser.com/cookbooks/multi-platform-docker-images/?h=multi#other-things-to-pay-attention-to 17 | uses: docker/setup-qemu-action@v2 18 | 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags) 22 | 23 | - name: Load secrets from 1Password 24 | uses: 1password/load-secrets-action@v1 25 | with: 26 | export-env: true 27 | env: 28 | OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} 29 | GITHUB_TOKEN: op://github-actions/github/token 30 | 31 | - name: Login to GHCR 32 | uses: docker/login-action@v2 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ env.GITHUB_TOKEN }} 37 | 38 | - uses: actions/setup-go@v4 39 | with: 40 | go-version-file: go.mod 41 | 42 | - uses: goreleaser/goreleaser-action@v5 43 | with: 44 | version: v1.25.1 45 | args: release --rm-dist 46 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | permissions: 14 | checks: write 15 | pull-requests: write 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Fetch target branch 20 | run: | 21 | git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }} 22 | 23 | - name: Run Gosec Security Scanner 24 | uses: securego/gosec@master 25 | with: 26 | args: ./... 27 | 28 | - uses: actions/setup-go@v4 29 | with: 30 | go-version-file: go.mod 31 | 32 | - name: Load secrets from 1Password 33 | uses: 1password/load-secrets-action@v1 34 | with: 35 | export-env: true 36 | env: 37 | OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} 38 | SONAR_TOKEN: op://github-actions/sonarcloud/token 39 | GITHUB_TOKEN: op://github-actions/github/token 40 | CODECOV_TOKEN: op://github-actions/codecov/argo-compare 41 | 42 | - name: Install project dependencies 43 | run: make install-deps 44 | 45 | - name: Run tests 46 | run: make test-coverage 47 | 48 | - name: Upload coverage reports to Codecov 49 | uses: codecov/codecov-action@v3 50 | with: 51 | token: ${{ env.CODECOV_TOKEN }} 52 | files: coverage.out 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea 3 | 4 | # build artifacts 5 | bin/ 6 | dist/ 7 | 8 | # local env files 9 | .env 10 | .envrc 11 | 12 | # coverage files 13 | coverage.out 14 | coverage.html 15 | report.xml 16 | 17 | # generated mocks 18 | cmd/argo-compare/mocks/ 19 | 20 | # files generated by tests 21 | testdata/disposable 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | issues: 2 | max-per-linter: 0 3 | max-same-issues: 0 4 | 5 | linters: 6 | disable-all: true 7 | enable: 8 | - durationcheck 9 | - errcheck 10 | - exportloopref 11 | - forcetypeassert 12 | - godot 13 | - gofmt 14 | - gosimple 15 | - ineffassign 16 | - makezero 17 | - misspell 18 | - nilerr 19 | - predeclared 20 | - staticcheck 21 | - tenv 22 | - unconvert 23 | - unparam 24 | - unused 25 | - vet 26 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: argo-compare 2 | 3 | before: 4 | hooks: 5 | - make install-deps mocks 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | mod_timestamp: '{{ .CommitTimestamp }}' 18 | main: ./cmd/argo-compare 19 | 20 | brews: 21 | - repository: 22 | owner: shini4i 23 | name: homebrew-tap 24 | folder: Formula 25 | homepage: https://github.com/shini4i/argo-compare 26 | dependencies: 27 | - name: helm 28 | description: "A comparison tool for displaying the differences between ArgoCD Applications in different Git branches" 29 | license: "MIT" 30 | 31 | dockers: 32 | - image_templates: [ "ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-amd64" ] 33 | dockerfile: Dockerfile 34 | use: buildx 35 | build_flag_templates: 36 | - "--platform=linux/amd64" 37 | - "--label=org.opencontainers.image.created={{.Date}}" 38 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 39 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 40 | - "--label=org.opencontainers.image.version={{.Version}}" 41 | - "--label=org.opencontainers.image.licenses=MIT" 42 | goos: linux 43 | goarch: amd64 44 | extra_files: 45 | - patch/diff-so-fancy.patch 46 | - image_templates: [ "ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-arm64" ] 47 | dockerfile: Dockerfile 48 | use: buildx 49 | build_flag_templates: 50 | - "--platform=linux/arm64" 51 | - "--label=org.opencontainers.image.created={{.Date}}" 52 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 53 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 54 | - "--label=org.opencontainers.image.version={{.Version}}" 55 | - "--label=org.opencontainers.image.licenses=MIT" 56 | goos: linux 57 | goarch: arm64 58 | extra_files: 59 | - patch/diff-so-fancy.patch 60 | 61 | docker_manifests: 62 | - name_template: 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}' 63 | image_templates: 64 | - 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}-amd64' 65 | - 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}-arm64' 66 | 67 | snapshot: 68 | name_template: '{{ incpatch .Version }}-dev' 69 | 70 | changelog: 71 | use: 72 | github-native 73 | 74 | release: 75 | prerelease: auto 76 | draft: false 77 | 78 | footer: | 79 | ## Docker Images 80 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}` 81 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-amd64` 82 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-arm64` 83 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | - id: no-commit-to-branch 10 | args: 11 | - --branch=main 12 | - repo: https://github.com/hadolint/hadolint 13 | rev: v2.12.0 14 | hooks: 15 | - id: hadolint 16 | args: 17 | - --ignore=DL3018 18 | - repo: https://github.com/dnephin/pre-commit-golang 19 | rev: v0.5.1 20 | hooks: 21 | - id: go-fmt 22 | - id: go-mod-tidy 23 | - id: go-imports 24 | - id: golangci-lint 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.20 AS downloader 2 | 3 | ARG TARGETARCH 4 | 5 | ENV HELM_VERSION=3.15.2 6 | ENV DIFF_SO_FANCY_VERSION=1.4.4 7 | 8 | WORKDIR /tmp 9 | 10 | RUN apk add --no-cache wget git patch \ 11 | && wget --progress=dot:giga -O helm.tar.gz "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" \ 12 | && tar -xf helm.tar.gz "linux-${TARGETARCH}/helm" \ 13 | && mv "linux-${TARGETARCH}/helm" /usr/bin/helm 14 | 15 | RUN git clone -b v${DIFF_SO_FANCY_VERSION} https://github.com/so-fancy/diff-so-fancy /diff-so-fancy \ 16 | && mv /diff-so-fancy/diff-so-fancy /usr/local/bin/diff-so-fancy \ 17 | && mv /diff-so-fancy/lib /usr/local/bin \ 18 | && chmod +x /usr/local/bin/diff-so-fancy 19 | 20 | # We need to apply a patch to the diff-so-fancy to not treat files in different directories as 21 | # renamed files. This is because we need to render manifests 22 | # from source and destination branches to different directories. 23 | COPY patch/diff-so-fancy.patch /tmp/diff-so-fancy.patch 24 | 25 | WORKDIR /usr/local/bin 26 | 27 | RUN patch < /tmp/diff-so-fancy.patch 28 | 29 | FROM alpine:3.20 30 | 31 | RUN apk add --no-cache perl ncurses \ 32 | && adduser --disabled-password --gecos '' app 33 | 34 | COPY --from=downloader /usr/bin/helm /usr/bin/helm 35 | COPY --from=downloader /usr/local/bin/lib /usr/local/bin/lib 36 | COPY --from=downloader /usr/local/bin/diff-so-fancy /usr/local/bin/diff-so-fancy 37 | 38 | COPY argo-compare /bin/argo-compare 39 | 40 | USER app 41 | 42 | ENTRYPOINT ["/bin/argo-compare"] 43 | CMD ["--help"] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Vadim Gedz 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 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: ## Print this help 5 | @echo "Usage: make [target]" 6 | @grep -E '^[a-z.A-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 7 | 8 | .PHONY: install-deps 9 | install-deps: ## Install dependencies 10 | @echo "===> Installing dependencies" 11 | @go install go.uber.org/mock/mockgen@latest 12 | @go install github.com/jstemmer/go-junit-report@latest 13 | 14 | .PHONY: mocks 15 | mocks: ## Generate mocks 16 | @echo "===> Generating mocks" 17 | @mockgen --source=cmd/argo-compare/interfaces/interfaces.go --destination=cmd/argo-compare/mocks/interfaces.go --package=mocks 18 | 19 | .PHONY: test 20 | test: mocks ## Run tests 21 | @go test -v ./... -count=1 22 | 23 | .PHONY: test-coverage 24 | test-coverage: mocks ## Run tests with coverage 25 | @mkdir -p testdata/repo.git/refs/heads testdata/repo.git/refs/tags 26 | @go test -v -coverprofile=coverage.out ./... -count=1 2>&1 | tee /dev/stderr | go-junit-report -set-exit-code > report.xml 27 | 28 | .PHONY: test-coverage-html 29 | test-coverage-html: test-coverage ## Run tests with coverage and open HTML report 30 | @go tool cover -html=coverage.out -o coverage.html 31 | 32 | .PHONY: ensure-dir 33 | ensure-dir: 34 | @mkdir -p bin 35 | 36 | .PHONY: build 37 | build: ensure-dir ## Build the binary 38 | @CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/argo-compare ./cmd/argo-compare 39 | 40 | .PHONY: clean 41 | clean: ## Remove build artifacts 42 | @rm -rf bin 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Argo Compare 4 | 5 | A comparison tool for displaying the differences between applications in different Git branches 6 | 7 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/shini4i/argo-compare/run-tests.yml?branch=main) 8 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/shini4i/argo-compare) 9 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/shini4i/argo-compare) 10 | [![codecov](https://codecov.io/gh/shini4i/argo-compare/branch/main/graph/badge.svg?token=48E1OZHLPY)](https://codecov.io/gh/shini4i/argo-compare) 11 | [![Go Report Card](https://goreportcard.com/badge/github.com/shini4i/argo-compare)](https://goreportcard.com/report/github.com/shini4i/argo-compare) 12 | ![GitHub](https://img.shields.io/github/license/shini4i/argo-compare) 13 | 14 | Showcase 15 | 16 | Example output of `argo-compare` with `diff-so-fancy` 17 |
18 | 19 | ## General information 20 | 21 | This tool will show what would be changed in the manifests rendered by helm after changes to the specific Application 22 | are merged into the target branch. 23 | 24 | ### How to install 25 | 26 | The binary can be installed using homebrew: 27 | 28 | ```bash 29 | brew install shini4i/tap/argo-compare 30 | ``` 31 | 32 | ### How to use 33 | 34 | The simplest usage scenario is to compare all changed files in the current branch with the target branch: 35 | 36 | ```bash 37 | argo-compare branch 38 | ``` 39 | 40 | If you want to compare only specific file, you can use the `--file` flag: 41 | 42 | ```bash 43 | argo-compare branch --file 44 | ``` 45 | 46 | By default, argo-compare will print only changed files content, but if this behavior is not desired, you can use one of the following flags: 47 | ```bash 48 | # In addition to the changed files, it will print all added manifests 49 | argo-compare branch --print-added-manifests 50 | # In addition to the changed files, it will print all removed manifests 51 | argo-compare branch --print-removed-manifests 52 | # Print all changed, added and removed manifests 53 | argo-compare branch --full-output 54 | ``` 55 | 56 | To use an external diff tool, you can set `EXTERNAL_DIFF_TOOL` environment variable. Each file diff will be passed in a pipe to the external tool. 57 | ```bash 58 | EXTERNAL_DIFF_TOOL=diff-so-fancy argo-compare branch 59 | ``` 60 | 61 | Additionally, you can try this tool using docker container: 62 | ```bash 63 | docker run -it --mount type=bind,source="$(pwd)",target=/apps --env EXTERNAL_DIFF_TOOL=diff-so-fancy --workdir /apps ghcr.io/shini4i/argo-compare: branch --full-output 64 | ``` 65 | 66 | #### Password Protected Repositories 67 | Using password protected repositories is a bit more challenging. To make it work, we need to expose JSON as an environment variable. 68 | The JSON should contain the following fields: 69 | 70 | ```json 71 | { 72 | "url": "https://charts.example.com", 73 | "username": "username", 74 | "password": "password" 75 | } 76 | ``` 77 | How to properly expose it depends on the specific use case. 78 | 79 | A bash example: 80 | ```bash 81 | export REPO_CREDS_EXAMPLE={\"url\":\"https://charts.example.com\",\"username\":\"username\",\"password\":\"password\"} 82 | ``` 83 | 84 | Where `EXAMPLE` is an identifier that is not used by the application. 85 | 86 | Argo Compare will look for all `REPO_CREDS_*` environment variables and use them if `url` will match the `repoURL` from Application manifest. 87 | 88 | 89 | ## How it works 90 | 91 | 1) First, this tool will check which files are changed compared to the files in the target branch. 92 | 2) It will get the content of the changed Application files from the target branch. 93 | 3) It will render manifests using the helm template using source and target branch values. 94 | 4) It will get rid of helm related labels as they are not important for the comparison. (It can be skipped by providing `--preserve-helm-labels` flag) 95 | 5) As the last step, it will compare rendered manifest from the source and destination branches and print the 96 | difference. 97 | 98 | ## Current limitations 99 | 100 | - Works only with Applications that are using helm repositories and helm values present in the Application yaml. 101 | - Does not support password protected repositories. 102 | 103 | ## Roadmap 104 | 105 | - [ ] Add support for Application using git as a source of helm chart 106 | - [x] Add support for providing credentials for password protected helm repositories 107 | - [ ] Add support for posting diff as a comment to PR (GitHub)/MR(GitLab) 108 | 109 | ## Contributing 110 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 111 | -------------------------------------------------------------------------------- /cmd/argo-compare/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/alecthomas/kong" 8 | ) 9 | 10 | var CLI struct { 11 | Debug bool `help:"Enable debug mode" short:"d"` 12 | DropCache DropCache `help:"Drop cache directory"` 13 | Version kong.VersionFlag `help:"Show version" short:"v"` 14 | 15 | Branch struct { 16 | Name string `arg:"" type:"string"` 17 | File string `help:"Compare a single file" short:"f"` 18 | Ignore []string `help:"Ignore a specific file" short:"i"` 19 | PreserveHelmLabels bool `help:"Preserve Helm labels during comparison"` 20 | PrintAddedManifests bool `help:"Print added manifests"` 21 | PrintRemovedManifests bool `help:"Print removed manifests"` 22 | FullOutput bool `help:"Print full output"` 23 | } `cmd:"" help:"target branch to compare with" type:"string"` 24 | } 25 | 26 | var ( 27 | targetBranch string 28 | fileToCompare string 29 | filesToIgnore []string 30 | preserveHelmLabels bool 31 | printAddedManifests bool 32 | printRemovedManifests bool 33 | ) 34 | 35 | type DropCache bool 36 | 37 | func (d *DropCache) BeforeApply(app *kong.Kong) error { 38 | fmt.Printf("===> Purging cache directory: %s\n", cacheDir) 39 | 40 | if err := os.RemoveAll(cacheDir); err != nil { 41 | return err 42 | } 43 | 44 | // it is required to be able to unit test this function 45 | if app != nil { 46 | app.Exit(0) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/argo-compare/cli_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDropCacheBeforeApply(t *testing.T) { 12 | t.Run("cacheDir successfully removed", func(t *testing.T) { 13 | tempDir, err := os.MkdirTemp(testsDir, "test-") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | defer func(path string) { 18 | err := os.RemoveAll(path) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | }(tempDir) // clean up in case of failure 23 | 24 | cacheDir = tempDir 25 | 26 | d := DropCache(true) 27 | err = d.BeforeApply(nil) 28 | assert.Nil(t, err) 29 | 30 | // check if cacheDir does not exist anymore 31 | _, err = os.Stat(cacheDir) 32 | assert.True(t, os.IsNotExist(err)) 33 | }) 34 | 35 | t.Run("cacheDir removal fails", func(t *testing.T) { 36 | // Test case 2: cacheDir removal fails 37 | tmpDir, err := os.MkdirTemp("", "test-failure-") 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | defer func(path string) { 42 | if err := os.Chmod(tmpDir, 0700); err != nil { 43 | t.Fatal(err) 44 | } 45 | if err := os.RemoveAll(path); err != nil { 46 | t.Fatal(err) 47 | } 48 | }(tmpDir) 49 | 50 | // Create a read-only file inside the directory 51 | tmpFile, err := os.Create(filepath.Join(tmpDir, "readonlyfile.txt")) 52 | if err != nil { 53 | t.Fatalf("Failed to create temporary file: %v", err) 54 | } 55 | 56 | if err := tmpFile.Close(); err != nil { 57 | t.Fatalf("Failed to close temporary file: %v", err) 58 | } 59 | 60 | // Make the directory unwritable 61 | err = os.Chmod(tmpDir, 0400) 62 | if err != nil { 63 | t.Fatalf("Failed to change permissions: %v", err) 64 | } 65 | 66 | cacheDir = tmpDir 67 | 68 | d := DropCache(true) 69 | 70 | err = d.BeforeApply(nil) 71 | assert.Error(t, err) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/argo-compare/compare.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "path/filepath" 7 | "reflect" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/codingsince1985/checksum" 12 | "github.com/hexops/gotextdiff" 13 | "github.com/hexops/gotextdiff/myers" 14 | "github.com/hexops/gotextdiff/span" 15 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces" 16 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 17 | "github.com/shini4i/argo-compare/internal/helpers" 18 | "github.com/spf13/afero" 19 | ) 20 | 21 | type File struct { 22 | Name string 23 | Sha string 24 | } 25 | 26 | type Compare struct { 27 | CmdRunner interfaces.CmdRunner 28 | Globber utils.CustomGlobber 29 | externalDiffTool string 30 | srcFiles []File 31 | dstFiles []File 32 | diffFiles []File 33 | addedFiles []File 34 | removedFiles []File 35 | } 36 | 37 | const ( 38 | srcPathPattern = "%s/templates/src/%s" 39 | dstPathPattern = "%s/templates/dst/%s" 40 | currentFilePrintPattern = "▶ %s" 41 | ) 42 | 43 | func (c *Compare) findFiles() { 44 | // Most of the time, we want to avoid huge output containing helm labels update only, 45 | // but we still want to be able to see the diff if needed 46 | if !preserveHelmLabels { 47 | c.findAndStripHelmLabels() 48 | } 49 | 50 | wg := new(sync.WaitGroup) 51 | wg.Add(2) 52 | 53 | go func() { 54 | defer wg.Done() 55 | globPattern := filepath.Join(filepath.Join(filepath.Join(tmpDir, "templates/src"), "**", "*.yaml")) 56 | srcFiles, err := c.Globber.Glob(globPattern) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | c.srcFiles = c.processFiles(srcFiles, "src") 61 | }() 62 | 63 | go func() { 64 | defer wg.Done() 65 | globPattern := filepath.Join(filepath.Join(filepath.Join(tmpDir, "templates/dst"), "**", "*.yaml")) 66 | if dstFiles, err := c.Globber.Glob(globPattern); err != nil { 67 | // we are no longer failing here, because we need to support the case where the destination 68 | // branch does not have the Application yet 69 | log.Debugf("Error while finding files in %s: %s", filepath.Join(tmpDir, "templates/dst"), err) 70 | } else { 71 | c.dstFiles = c.processFiles(dstFiles, "dst") 72 | } 73 | }() 74 | 75 | wg.Wait() 76 | 77 | if !reflect.DeepEqual(c.srcFiles, c.dstFiles) { 78 | c.generateFilesStatus() 79 | } 80 | } 81 | 82 | func (c *Compare) processFiles(files []string, filesType string) []File { 83 | var processedFiles []File 84 | 85 | path := filepath.Join(tmpDir, "templates", filesType) 86 | 87 | for _, file := range files { 88 | if sha256sum, err := checksum.SHA256sum(file); err != nil { 89 | log.Fatal(err) 90 | } else { 91 | processedFiles = append(processedFiles, File{Name: strings.TrimPrefix(file, path), Sha: sha256sum}) 92 | } 93 | } 94 | 95 | return processedFiles 96 | } 97 | 98 | func (c *Compare) generateFilesStatus() { 99 | c.findNewOrRemovedFiles() 100 | c.compareFiles() 101 | } 102 | 103 | func (c *Compare) compareFiles() { 104 | var diffFiles []File 105 | 106 | for _, srcFile := range c.srcFiles { 107 | for _, dstFile := range c.dstFiles { 108 | if srcFile.Name == dstFile.Name && srcFile.Sha != dstFile.Sha { 109 | diffFiles = append(diffFiles, srcFile) 110 | } 111 | } 112 | } 113 | 114 | c.diffFiles = diffFiles 115 | } 116 | 117 | // findNewOrRemovedFiles scans source and destination files to identify 118 | // newly added or removed files. It populates the addedFiles and removedFiles 119 | // fields of the Compare struct with the respective files. A file is considered 120 | // added if it exists in the source but not in the destination, and removed if 121 | // it exists in the destination but not in the source. 122 | func (c *Compare) findNewOrRemovedFiles() { 123 | srcFileMap := make(map[string]File) 124 | for _, srcFile := range c.srcFiles { 125 | srcFileMap[srcFile.Name] = srcFile 126 | } 127 | 128 | dstFileMap := make(map[string]File) 129 | for _, dstFile := range c.dstFiles { 130 | dstFileMap[dstFile.Name] = dstFile 131 | } 132 | 133 | for fileName, srcFile := range srcFileMap { 134 | if _, found := dstFileMap[fileName]; !found { 135 | c.addedFiles = append(c.addedFiles, srcFile) 136 | } 137 | } 138 | 139 | for fileName, dstFile := range dstFileMap { 140 | if _, found := srcFileMap[fileName]; !found { 141 | c.removedFiles = append(c.removedFiles, dstFile) 142 | } 143 | } 144 | } 145 | 146 | // printFilesStatus logs the status of the files processed during comparison. 147 | // It determines whether files have been added, removed or have differences, 148 | // and calls printFiles for each case. 149 | func (c *Compare) printFilesStatus() { 150 | if len(c.addedFiles) == 0 && len(c.removedFiles) == 0 && len(c.diffFiles) == 0 { 151 | log.Info("No diff was found in rendered manifests!") 152 | return 153 | } 154 | 155 | c.printFiles(c.addedFiles, "added") 156 | c.printFiles(c.removedFiles, "removed") 157 | c.printFiles(c.diffFiles, "changed") 158 | } 159 | 160 | // printFiles logs the files that are subject to an operation (addition, removal, change). 161 | // It logs the number of affected files and processes each file according to the operation. 162 | func (c *Compare) printFiles(files []File, operation string) { 163 | if len(files) > 0 { 164 | fileText := "file" 165 | if len(files) > 1 { 166 | fileText = "files" 167 | } 168 | log.Infof("The following %d %s would be %s:", len(files), fileText, operation) 169 | for _, file := range files { 170 | log.Infof(currentFilePrintPattern, file.Name) 171 | c.printDiffFile(file) 172 | } 173 | } 174 | } 175 | 176 | func (c *Compare) printDiffFile(diffFile File) { 177 | dstFilePath := fmt.Sprintf(dstPathPattern, tmpDir, diffFile.Name) 178 | srcFilePath := fmt.Sprintf(srcPathPattern, tmpDir, diffFile.Name) 179 | 180 | srcFile := string(ReadFile(srcFilePath)) 181 | dstFile := string(ReadFile(dstFilePath)) 182 | 183 | edits := myers.ComputeEdits(span.URIFromPath(srcFilePath), dstFile, srcFile) 184 | 185 | output := fmt.Sprint(gotextdiff.ToUnified(srcFilePath, dstFilePath, dstFile, edits)) 186 | 187 | if c.externalDiffTool != "" { 188 | cmd := exec.Command(c.externalDiffTool) // #nosec G204 189 | 190 | // Set the external program's stdin to read from a pipe 191 | cmd.Stdin = strings.NewReader(output) 192 | 193 | // Capture the output of the external program 194 | cmdOutput, err := cmd.CombinedOutput() 195 | if err != nil { 196 | fmt.Println("Error running external program:", err) 197 | } 198 | 199 | // Print the external program's output 200 | fmt.Println(string(cmdOutput)) 201 | } else { 202 | fmt.Println(output) 203 | } 204 | } 205 | 206 | // findAndStripHelmLabels scans directory for YAML files, strips pre-defined Helm labels, 207 | // and writes modified content back. 208 | func (c *Compare) findAndStripHelmLabels() { 209 | var helmFiles []string 210 | var err error 211 | 212 | if helmFiles, err = c.Globber.Glob(filepath.Join(tmpDir, "**", "*.yaml")); err != nil { 213 | log.Fatal(err) 214 | } 215 | 216 | for _, helmFile := range helmFiles { 217 | if desiredState, err := helpers.StripHelmLabels(helmFile); err != nil { 218 | log.Fatal(err) 219 | } else { 220 | if err := helpers.WriteToFile(afero.NewOsFs(), helmFile, desiredState); err != nil { 221 | log.Fatal(err) 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /cmd/argo-compare/compare_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/op/go-logging" 9 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | const ( 14 | file1 = "file1.txt" 15 | file2 = "file2.txt" 16 | file3 = "file3.txt" 17 | file4 = "file4.txt" 18 | ) 19 | 20 | func TestGenerateFilesStatus(t *testing.T) { 21 | srcFiles := []File{ 22 | {Name: file1, Sha: "1234"}, 23 | {Name: file3, Sha: "3456"}, 24 | {Name: file4, Sha: "7890"}, 25 | } 26 | 27 | dstFiles := []File{ 28 | {Name: file1, Sha: "5678"}, 29 | {Name: file2, Sha: "9012"}, 30 | {Name: file3, Sha: "3456"}, 31 | } 32 | 33 | c := Compare{ 34 | srcFiles: srcFiles, 35 | dstFiles: dstFiles, 36 | } 37 | 38 | expectedAddedFiles := []File{{Name: file4, Sha: "7890"}} 39 | expectedRemovedFiles := []File{{Name: file2, Sha: "9012"}} 40 | expectedDiffFiles := []File{{Name: file1, Sha: "1234"}} 41 | 42 | c.generateFilesStatus() 43 | 44 | assert.Equal(t, expectedAddedFiles, c.addedFiles) 45 | assert.Equal(t, expectedRemovedFiles, c.removedFiles) 46 | assert.Equal(t, expectedDiffFiles, c.diffFiles) 47 | } 48 | 49 | func TestFindAndStripHelmLabels(t *testing.T) { 50 | // Prepare test data 51 | testFile := "../../testdata/dynamic/deployment.yaml" 52 | backupFile := testFile + ".bak" 53 | 54 | // Read the original file content 55 | originalData, err := os.ReadFile(testFile) 56 | if err != nil { 57 | t.Fatalf("Failed to read the original file: %s", err) 58 | } 59 | 60 | // Create a backup of the original file 61 | if err := os.WriteFile(backupFile, originalData, 0644); err != nil { 62 | t.Fatalf("Failed to create a backup of the test file: %s", err) 63 | } 64 | defer func() { 65 | // Restore the original file 66 | err := os.Rename(backupFile, testFile) 67 | if err != nil { 68 | t.Fatalf("Failed to restore the test file: %s", err) 69 | } 70 | }() 71 | 72 | // Change directory to the testdata directory 73 | if err := os.Chdir("../../testdata/dynamic"); err != nil { 74 | t.Fatalf("Failed to change directory: %s", err) 75 | } 76 | 77 | // Create an instance of Compare 78 | c := &Compare{} 79 | 80 | // Call the method to find and strip Helm labels 81 | c.findAndStripHelmLabels() 82 | 83 | // Return to the original directory 84 | if err := os.Chdir("../../cmd/argo-compare"); err != nil { 85 | t.Fatalf("Failed to change directory: %s", err) 86 | } 87 | 88 | // Read the modified file 89 | modifiedData, err := os.ReadFile(testFile) 90 | assert.NoError(t, err) 91 | 92 | // Define the expected modified content 93 | expectedOutput := `# for testing purpose we need only limited fields 94 | apiVersion: apps/v1 95 | kind: Deployment 96 | metadata: 97 | labels: 98 | app.kubernetes.io/instance: traefik-web 99 | app.kubernetes.io/name: traefik 100 | argocd.argoproj.io/instance: traefik 101 | name: traefik 102 | namespace: web 103 | ` 104 | 105 | // Compare the modified output with the expected output 106 | assert.Equal(t, expectedOutput, string(modifiedData)) 107 | } 108 | 109 | func TestProcessFiles(t *testing.T) { 110 | compare := &Compare{ 111 | CmdRunner: &utils.RealCmdRunner{}, 112 | } 113 | 114 | files := []string{ 115 | "../../testdata/test.yaml", 116 | "../../testdata/test-values.yaml", 117 | } 118 | 119 | expectedFiles := []File{ 120 | { 121 | Name: "../../testdata/test.yaml", 122 | Sha: "e263e4264f5570000b3666d6d07749fb67d4b82a6a1e1c1736503adcb7942e5b", 123 | }, 124 | { 125 | Name: "../../testdata/test-values.yaml", 126 | Sha: "c22e6d877e8c49693306eb2d16affaa3a318fe602f36b6e733428e9c16ebfa32", 127 | }, 128 | } 129 | 130 | foundFiles := compare.processFiles(files, "src") 131 | 132 | assert.Equal(t, expectedFiles, foundFiles) 133 | } 134 | 135 | func TestPrintFilesStatus(t *testing.T) { 136 | // Test case 1: No added, removed or changed files 137 | var buf bytes.Buffer 138 | backend := logging.NewLogBackend(&buf, "", 0) 139 | logging.SetBackend(backend) 140 | 141 | c := &Compare{ 142 | CmdRunner: &utils.RealCmdRunner{}, 143 | } 144 | c.printFilesStatus() 145 | 146 | logs := buf.String() 147 | assert.Contains(t, logs, "No diff was found in rendered manifests!") 148 | 149 | // Test case 2: Found added and removed files, but no changed files 150 | backend = logging.NewLogBackend(&buf, "", 0) 151 | 152 | logging.SetBackend(backend) 153 | 154 | c = &Compare{ 155 | addedFiles: []File{{Name: "file1", Sha: "123"}}, 156 | removedFiles: []File{{Name: "file2", Sha: "456"}, {Name: "file3", Sha: "789"}}, 157 | diffFiles: []File{}, 158 | } 159 | 160 | c.printFilesStatus() 161 | 162 | logs = buf.String() 163 | assert.Contains(t, logs, "The following 1 file would be added:") 164 | assert.Contains(t, logs, "The following 2 files would be removed:") 165 | assert.NotContains(t, logs, "The following 1 file would be changed:") 166 | } 167 | -------------------------------------------------------------------------------- /cmd/argo-compare/git.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/plumbing" 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces" 13 | "github.com/shini4i/argo-compare/internal/helpers" 14 | "github.com/spf13/afero" 15 | 16 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 17 | "github.com/shini4i/argo-compare/internal/models" 18 | ) 19 | 20 | type GitRepo struct { 21 | Repo *git.Repository 22 | CmdRunner interfaces.CmdRunner 23 | FsType afero.Fs 24 | changedFiles []string 25 | invalidFiles []string 26 | } 27 | 28 | var ( 29 | gitFileDoesNotExist = errors.New("file does not exist in target branch") 30 | ) 31 | 32 | // printChangeFile logs the names of the changed files found in the provided slice 33 | // if they are not empty strings, prefixed with a debug level message. 34 | func printChangeFile(addedFiles, Removed []string) { 35 | log.Debug("===> Found the following changed files:") 36 | for _, file := range addedFiles { 37 | if file != "" { 38 | log.Debugf("▶ %s", file) 39 | } 40 | } 41 | log.Debug("===> Found the following removed files:") 42 | for _, file := range Removed { 43 | if file != "" { 44 | log.Debugf("▶ %s", red(file)) 45 | } 46 | } 47 | } 48 | 49 | // sortChangedFiles inspects each provided .yaml file and classifies them as an application, 50 | // invalid or otherwise. Non-application and unsupported files are logged and skipped, while 51 | // valid application files are added to 'changedFiles' for further processing. 52 | func (g *GitRepo) sortChangedFiles(fileReader interfaces.FileReader, files []string) { 53 | for _, file := range files { 54 | if filepath.Ext(file) == ".yaml" { 55 | switch isApp, err := checkIfApp(g.CmdRunner, fileReader, file); { 56 | case errors.Is(err, models.NotApplicationError): 57 | log.Debugf("Skipping non-application file [%s]", file) 58 | case errors.Is(err, models.UnsupportedAppConfigurationError): 59 | log.Warningf("Skipping unsupported application configuration [%s]", file) 60 | case errors.Is(err, models.EmptyFileError): 61 | log.Debugf("Skipping empty file [%s]", file) 62 | case err != nil: 63 | log.Errorf("Error checking if [%s] is an Application: %s", file, err) 64 | g.invalidFiles = append(g.invalidFiles, file) 65 | case isApp: 66 | g.changedFiles = append(g.changedFiles, file) 67 | } 68 | } 69 | } 70 | 71 | if len(g.changedFiles) > 0 { 72 | log.Info("===> Found the following changed Application files") 73 | for _, file := range g.changedFiles { 74 | log.Infof("▶ %s", yellow(file)) 75 | } 76 | } 77 | } 78 | 79 | // getChangedFiles returns a list of files changed between the target branch and current HEAD. 80 | // It retrieves commit objects for the target and current branch, calculates the diff between trees and collects changed files. 81 | // If errors occur during these steps, they are returned. 82 | // It also triggers the logging and sorting processes for the changed files. 83 | func (g *GitRepo) getChangedFiles(fileReader interfaces.FileReader) ([]string, error) { 84 | targetRef, err := g.Repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", targetBranch)), true) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err) 87 | } 88 | 89 | // Retrieve the current branch reference. 90 | headRef, err := g.Repo.Head() 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to get HEAD: %v", err) 93 | } 94 | 95 | // Get the commit objects for the target branch and the current branch. 96 | targetCommit, err := g.Repo.CommitObject(targetRef.Hash()) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err) 99 | } 100 | 101 | headCommit, err := g.Repo.CommitObject(headRef.Hash()) 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to get commit object for current branch: %v", err) 104 | } 105 | 106 | // Get the tree objects for the target commit and the current commit. 107 | targetTree, err := targetCommit.Tree() 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to get tree for target commit: %v", err) 110 | } 111 | 112 | headTree, err := headCommit.Tree() 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to get tree for head commit: %v", err) 115 | } 116 | 117 | // Get the diff between the two trees. 118 | changes, err := object.DiffTree(targetTree, headTree) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to get diff between trees: %v", err) 121 | } 122 | 123 | // Collect all the changed files 124 | var foundFiles, removedFiles []string 125 | for _, change := range changes { 126 | if change.To.Name == "" { 127 | removedFiles = append(removedFiles, change.From.Name) 128 | continue 129 | } 130 | foundFiles = append(foundFiles, change.To.Name) 131 | } 132 | 133 | printChangeFile(foundFiles, removedFiles) 134 | g.sortChangedFiles(fileReader, foundFiles) 135 | 136 | return g.changedFiles, nil 137 | } 138 | 139 | // getChangedFileContent retrieves the content of a given targetFile in the provided targetBranch. 140 | // It retrieves the branch reference, commit and tree objects; locates the file entry and retrieves its content. 141 | // The function returns an Application model if successful. 142 | // If the file doesn't exist in the target branch, it is assumed to be a new Application. 143 | func (g *GitRepo) getChangedFileContent(targetBranch string, targetFile string) (models.Application, error) { 144 | log.Debugf("Getting content of %s from %s", targetFile, targetBranch) 145 | 146 | // Retrieve the target branch reference. 147 | targetRef, err := g.Repo.Reference(plumbing.ReferenceName("refs/remotes/origin/"+targetBranch), true) 148 | if err != nil { 149 | return models.Application{}, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err) 150 | } 151 | 152 | // Get the commit object for the target branch. 153 | targetCommit, err := g.Repo.CommitObject(targetRef.Hash()) 154 | if err != nil { 155 | return models.Application{}, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err) 156 | } 157 | 158 | // Get the tree object for the target commit. 159 | targetTree, err := targetCommit.Tree() 160 | if err != nil { 161 | return models.Application{}, fmt.Errorf("failed to get tree for target commit: %v", err) 162 | } 163 | 164 | // Find the file entry in the tree. 165 | fileEntry, err := targetTree.File(targetFile) 166 | if err != nil { 167 | if errors.Is(err, object.ErrFileNotFound) { 168 | log.Warningf("\u001B[33mThe requested file %s does not exist in target branch %s, assuming it is a new Application\u001B[0m", targetFile, targetBranch) 169 | if !printAddedManifests { 170 | return models.Application{}, gitFileDoesNotExist 171 | } 172 | } else { 173 | return models.Application{}, fmt.Errorf("failed to find file %s in target branch %s: %v", targetFile, targetBranch, err) 174 | } 175 | } 176 | 177 | var fileContent string 178 | if fileEntry == nil { 179 | fileContent = "" 180 | } else { 181 | fileContent, err = fileEntry.Contents() 182 | if err != nil { 183 | return models.Application{}, fmt.Errorf("failed to get contents of file %s: %v", targetFile, err) 184 | } 185 | } 186 | 187 | // Create a temporary file with the content. 188 | tmpFile, err := helpers.CreateTempFile(g.FsType, fileContent) 189 | if err != nil { 190 | return models.Application{}, err 191 | } 192 | 193 | defer func(file afero.File) { 194 | if err := afero.Fs.Remove(g.FsType, file.Name()); err != nil { 195 | log.Errorf("Failed to remove temporary file [%s]: %s", file.Name(), err) 196 | } 197 | }(tmpFile) 198 | 199 | // Create a Target object and parse the application. 200 | target := Target{CmdRunner: g.CmdRunner, FileReader: utils.OsFileReader{}, File: tmpFile.Name()} 201 | if err := target.parse(); err != nil { 202 | return models.Application{}, fmt.Errorf("failed to parse the application: %w", err) 203 | } 204 | 205 | return target.App, nil 206 | } 207 | 208 | // checkIfApp attempts to parse the provided file as an Application. 209 | // Returns true if the parsing is successful, indicating the file is an Application. 210 | func checkIfApp(cmdRunner interfaces.CmdRunner, fileReader interfaces.FileReader, file string) (bool, error) { 211 | log.Debugf("===> Checking if [%s] is an Application", cyan(file)) 212 | 213 | target := Target{CmdRunner: cmdRunner, FileReader: fileReader, File: file} 214 | 215 | if err := target.parse(); err != nil { 216 | return false, err 217 | } 218 | return true, nil 219 | } 220 | 221 | // GetGitRepoRoot returns the root directory of the current Git repository. 222 | // It takes a cmdRunner as input, which is an interface for executing shell commands. 223 | // The function runs the "git rev-parse --show-toplevel" command to retrieve the root directory path. 224 | // It captures the standard output and standard error streams and returns them as strings. 225 | // If the command execution is successful, it trims the leading and trailing white spaces from the output and returns it as the repository root directory path. 226 | // If there is an error executing the command, the function prints the error message to standard error and returns an empty string and the error. 227 | func GetGitRepoRoot() (string, error) { 228 | dir, err := os.Getwd() 229 | if err != nil { 230 | return "", fmt.Errorf("failed to get current working directory: %v", err) 231 | } 232 | 233 | for { 234 | _, err := git.PlainOpen(dir) 235 | if err == nil { 236 | return dir, nil 237 | } 238 | 239 | parentDir := filepath.Dir(dir) 240 | if parentDir == dir { 241 | break 242 | } 243 | 244 | dir = parentDir 245 | } 246 | 247 | return "", fmt.Errorf("no git repository found") 248 | } 249 | 250 | // ReadFile reads the contents of the specified file and returns them as a byte slice. 251 | // If the file does not exist, it prints a message indicating that the file was removed in a source branch and returns nil. 252 | // The function handles the os.ErrNotExist error to detect if the file is missing. 253 | func ReadFile(file string) []byte { 254 | if readFile, err := os.ReadFile(file); errors.Is(err, os.ErrNotExist) /* #nosec G304 */ { 255 | return nil 256 | } else { 257 | return readFile 258 | } 259 | } 260 | 261 | // NewGitRepo initializes and returns a GitRepo structure, opening a Git repository at the root location. 262 | // It takes an afero.Fs filesystem and a CmdRunner for shell commands as arguments. 263 | // In case of an error detecting the root of the Git repository or opening it, it returns an error. 264 | func NewGitRepo(fs afero.Fs, cmdRunner interfaces.CmdRunner) (*GitRepo, error) { 265 | repoRoot, err := GetGitRepoRoot() 266 | if err != nil { 267 | return nil, err 268 | } 269 | 270 | gitRepo := &GitRepo{ 271 | FsType: fs, 272 | CmdRunner: cmdRunner, 273 | } 274 | 275 | gitRepo.Repo, err = git.PlainOpen(repoRoot) 276 | if err != nil { 277 | return nil, fmt.Errorf("failed to open repository: %v", err) 278 | } 279 | 280 | return gitRepo, nil 281 | } 282 | -------------------------------------------------------------------------------- /cmd/argo-compare/git_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/shini4i/argo-compare/internal/models" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/config" 11 | "github.com/go-git/go-git/v5/plumbing" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/spf13/afero" 15 | 16 | "github.com/op/go-logging" 17 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks" 18 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 19 | "github.com/stretchr/testify/assert" 20 | "go.uber.org/mock/gomock" 21 | ) 22 | 23 | const ( 24 | appFile = "testdata/test.yaml" 25 | ) 26 | 27 | func init() { 28 | // We don't want to see any logs in tests 29 | loggingInit(logging.CRITICAL) 30 | } 31 | 32 | func TestCheckIfApp(t *testing.T) { 33 | ctrl := gomock.NewController(t) 34 | defer ctrl.Finish() 35 | 36 | // Create the mocks 37 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 38 | 39 | isApp, err := checkIfApp(mockCmdRunner, utils.OsFileReader{}, appFile) 40 | 41 | assert.True(t, isApp, "expected true, got false") 42 | assert.NoError(t, err, "expected no error, got %v", err) 43 | } 44 | 45 | func TestNewGitRepo(t *testing.T) { 46 | ctrl := gomock.NewController(t) 47 | defer ctrl.Finish() 48 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 49 | 50 | fs := afero.NewMemMapFs() 51 | 52 | repo, err := NewGitRepo(fs, mockCmdRunner) 53 | 54 | assert.NoError(t, err) 55 | assert.NotNil(t, repo.Repo) 56 | assert.IsType(t, fs, repo.FsType) 57 | assert.IsType(t, mockCmdRunner, repo.CmdRunner) 58 | } 59 | 60 | func TestGitInteraction(t *testing.T) { 61 | // Save the initial working directory 62 | originalDir, err := os.Getwd() 63 | require.NoError(t, err, "Failed to get original working directory") 64 | 65 | defer func() { 66 | // Change back to the original working directory 67 | err = os.Chdir(originalDir) 68 | require.NoError(t, err, "Failed to change back to the original working directory") 69 | }() 70 | 71 | // Create temporary directory for cloning 72 | tempDir, err := os.MkdirTemp("", "gitTest") 73 | require.NoError(t, err) 74 | 75 | defer os.RemoveAll(tempDir) // clean up 76 | 77 | // Clone the bare repo to our temporary directory 78 | repo, err := git.PlainClone(tempDir, false, &git.CloneOptions{ 79 | URL: "../../testdata/repo.git", 80 | SingleBranch: false, 81 | }) 82 | require.NoError(t, err, "Failed to clone repository") 83 | 84 | err = repo.Fetch(&git.FetchOptions{ 85 | RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}, 86 | }) 87 | require.NoError(t, err, "Failed to fetch") 88 | 89 | // Switch to the "feature" branch 90 | w, err := repo.Worktree() 91 | require.NoError(t, err, "Failed to get worktree") 92 | 93 | branchRef := plumbing.NewBranchReferenceName("feature-branch") 94 | err = w.Checkout(&git.CheckoutOptions{ 95 | Branch: branchRef, 96 | }) 97 | require.NoError(t, err, "Failed to checkout feature branch") 98 | 99 | // Initialize GitRepo with cloned repo and proceed with testing 100 | target := GitRepo{ 101 | Repo: repo, 102 | FsType: afero.NewOsFs(), 103 | CmdRunner: &utils.RealCmdRunner{}, 104 | } 105 | 106 | targetBranch = "main" 107 | 108 | // Change the working directory to the temporary directory 109 | err = os.Chdir(tempDir) 110 | require.NoError(t, err, "Failed to change working directory to tempDir") 111 | 112 | t.Run("get changed files", func(t *testing.T) { 113 | changedFiles, err := target.getChangedFiles(utils.OsFileReader{}) 114 | assert.NoError(t, err, "Failed to get changed files") 115 | assert.Equal(t, []string{"cluster-state/web/ingress-nginx.yaml"}, changedFiles) 116 | }) 117 | 118 | t.Run("get changed file content", func(t *testing.T) { 119 | fileContent, err := target.getChangedFileContent("main", "cluster-state/web/ingress-nginx.yaml") 120 | expectedApp := models.Application{ 121 | Kind: "Application", 122 | Metadata: struct { 123 | Name string `yaml:"name"` 124 | Namespace string `yaml:"namespace"` 125 | }{ 126 | Name: "ingress-nginx", 127 | Namespace: "argo-cd", 128 | }, 129 | Spec: struct { 130 | Source *models.Source `yaml:"source"` 131 | Sources []*models.Source `yaml:"sources"` 132 | MultiSource bool `yaml:"-"` 133 | }{ 134 | Source: &models.Source{ 135 | TargetRevision: "4.9.1", 136 | }, 137 | }, 138 | } 139 | assert.NoError(t, err) 140 | assert.Equal(t, expectedApp.Metadata.Name, fileContent.Metadata.Name) 141 | assert.Equal(t, expectedApp.Spec.Source.TargetRevision, fileContent.Spec.Source.TargetRevision) 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /cmd/argo-compare/interfaces/interfaces.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/shini4i/argo-compare/internal/models" 7 | ) 8 | 9 | type CmdRunner interface { 10 | Run(cmd string, args ...string) (stdout string, stderr string, err error) 11 | } 12 | 13 | type OsFs interface { 14 | CreateTemp(dir, pattern string) (f *os.File, err error) 15 | Remove(name string) error 16 | } 17 | 18 | type FileReader interface { 19 | ReadFile(file string) []byte 20 | } 21 | 22 | type Globber interface { 23 | Glob(pattern string) ([]string, error) 24 | } 25 | 26 | type HelmChartsProcessor interface { 27 | GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error 28 | DownloadHelmChart(cmdRunner CmdRunner, globber Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error 29 | ExtractHelmChart(cmdRunner CmdRunner, globber Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error 30 | RenderAppSource(cmdRunner CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType string) error 31 | } 32 | -------------------------------------------------------------------------------- /cmd/argo-compare/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/alecthomas/kong" 12 | "github.com/fatih/color" 13 | "github.com/op/go-logging" 14 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces" 15 | "github.com/spf13/afero" 16 | 17 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 18 | "github.com/shini4i/argo-compare/internal/helpers" 19 | "github.com/shini4i/argo-compare/internal/models" 20 | ) 21 | 22 | const ( 23 | loggerName = "argo-compare" 24 | repoCredsPrefix = "REPO_CREDS_" // #nosec G101 25 | ) 26 | 27 | var ( 28 | cacheDir = helpers.GetEnv("ARGO_COMPARE_CACHE_DIR", fmt.Sprintf("%s/.cache/argo-compare", os.Getenv("HOME"))) 29 | tmpDir string 30 | version = "local" 31 | repoCredentials []models.RepoCredentials 32 | externalDiffTool = os.Getenv("EXTERNAL_DIFF_TOOL") 33 | ) 34 | 35 | var ( 36 | log = logging.MustGetLogger(loggerName) 37 | // A bit weird, but it seems that it's the easiest way to implement log level support in CLI tool 38 | // without printing the log level and timestamp in the output. 39 | format = logging.MustStringFormatter( 40 | `%{message}`, 41 | ) 42 | helmChartProcessor = utils.RealHelmChartProcessor{Log: log} 43 | ) 44 | 45 | var ( 46 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc() 47 | red = color.New(color.FgRed, color.Bold).SprintFunc() 48 | yellow = color.New(color.FgYellow, color.Bold).SprintFunc() 49 | ) 50 | 51 | func loggingInit(level logging.Level) { 52 | backend := logging.NewLogBackend(os.Stdout, "", 0) 53 | backendFormatter := logging.NewBackendFormatter(backend, format) 54 | logging.SetBackend(backendFormatter) 55 | logging.SetLevel(level, "") 56 | } 57 | 58 | func processFiles(cmdRunner interfaces.CmdRunner, fileName string, fileType string, application models.Application) error { 59 | log.Debugf("Processing [%s] file: [%s]", cyan(fileType), cyan(fileName)) 60 | 61 | target := Target{CmdRunner: cmdRunner, FileReader: utils.OsFileReader{}, File: fileName, Type: fileType, App: application} 62 | if fileType == "src" { 63 | if err := target.parse(); err != nil { 64 | return err 65 | } 66 | } 67 | 68 | if err := target.generateValuesFiles(helmChartProcessor); err != nil { 69 | return err 70 | } 71 | 72 | if err := target.ensureHelmCharts(helmChartProcessor); err != nil { 73 | return err 74 | } 75 | 76 | if err := target.extractCharts(helmChartProcessor); err != nil { 77 | return err 78 | } 79 | 80 | if err := target.renderAppSources(helmChartProcessor); err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func compareFiles(fs afero.Fs, cmdRunner interfaces.CmdRunner, repo *GitRepo, changedFiles []string) { 88 | for _, file := range changedFiles { 89 | var err error 90 | 91 | log.Infof("===> Processing changed application: [%s]", cyan(file)) 92 | 93 | if tmpDir, err = afero.TempDir(fs, "/tmp", "argo-compare-"); err != nil { 94 | log.Panic(err) 95 | } 96 | 97 | if err = processFiles(cmdRunner, file, "src", models.Application{}); err != nil { 98 | log.Panicf("Could not process the source Application: %s", err) 99 | } 100 | 101 | app, err := repo.getChangedFileContent(targetBranch, file) 102 | if errors.Is(err, gitFileDoesNotExist) && !printAddedManifests { 103 | return 104 | } else if err != nil && !errors.Is(err, models.EmptyFileError) { 105 | log.Errorf("Could not get the target Application from branch [%s]: %s", targetBranch, err) 106 | } 107 | 108 | if !errors.Is(err, models.EmptyFileError) { 109 | if err = processFiles(cmdRunner, file, "dst", app); err != nil && !printAddedManifests { 110 | log.Panicf("Could not process the destination Application: %s", err) 111 | } 112 | } 113 | 114 | runComparison(cmdRunner) 115 | 116 | if err := fs.RemoveAll(tmpDir); err != nil { 117 | log.Panic(err) 118 | } 119 | } 120 | } 121 | 122 | func runComparison(cmdRunner interfaces.CmdRunner) { 123 | comparer := Compare{ 124 | CmdRunner: cmdRunner, 125 | externalDiffTool: externalDiffTool, 126 | } 127 | comparer.findFiles() 128 | comparer.printFilesStatus() 129 | } 130 | 131 | func collectRepoCredentials() error { 132 | log.Debug("===> Collecting repo credentials") 133 | for _, env := range os.Environ() { 134 | if strings.HasPrefix(env, repoCredsPrefix) { 135 | var repoCreds models.RepoCredentials 136 | if err := json.Unmarshal([]byte(strings.SplitN(env, "=", 2)[1]), &repoCreds); err != nil { 137 | return err 138 | } 139 | repoCredentials = append(repoCredentials, repoCreds) 140 | } 141 | } 142 | 143 | for _, repo := range repoCredentials { 144 | log.Debugf("▶ Found repo credentials for [%s]", cyan(repo.Url)) 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func printInvalidFilesList(repo *GitRepo) error { 151 | if len(repo.invalidFiles) > 0 { 152 | log.Info("===> The following yaml files are invalid and were skipped") 153 | for _, file := range repo.invalidFiles { 154 | log.Warningf("▶ %s", file) 155 | } 156 | return errors.New("invalid files found") 157 | } 158 | return nil 159 | } 160 | 161 | // parseCli processes command-line arguments, setting appropriate global variables based on user input. 162 | // If the user does not provide a recognized command, it returns an error. 163 | func parseCli() error { 164 | ctx := kong.Parse(&CLI, 165 | kong.Name("argo-compare"), 166 | kong.Description("Compare ArgoCD applications between git branches"), 167 | kong.UsageOnError(), 168 | kong.Vars{"version": version}) 169 | 170 | switch ctx.Command() { 171 | case "branch ": 172 | targetBranch = CLI.Branch.Name 173 | fileToCompare = CLI.Branch.File 174 | filesToIgnore = CLI.Branch.Ignore 175 | } 176 | 177 | return nil 178 | } 179 | 180 | func runCLI() error { 181 | if err := parseCli(); err != nil { 182 | return err 183 | } 184 | 185 | updateConfigurations() 186 | 187 | repo, err := NewGitRepo(afero.NewOsFs(), &utils.RealCmdRunner{}) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | log.Infof("===> Running Argo Compare version [%s]", cyan(version)) 193 | 194 | if err := collectRepoCredentials(); err != nil { 195 | return err 196 | } 197 | 198 | changedFiles, err := getChangedFiles(repo, fileToCompare, filesToIgnore) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | if len(changedFiles) == 0 { 204 | log.Info("No changed Application files found. Exiting...") 205 | } else { 206 | compareFiles(afero.NewOsFs(), &utils.RealCmdRunner{}, repo, changedFiles) 207 | } 208 | 209 | return printInvalidFilesList(repo) 210 | } 211 | 212 | func getChangedFiles(repo *GitRepo, fileToCompare string, filesToIgnore []string) ([]string, error) { 213 | var changedFiles []string 214 | var err error 215 | 216 | if fileToCompare != "" { 217 | changedFiles = []string{fileToCompare} 218 | } else { 219 | changedFiles, err = repo.getChangedFiles(utils.OsFileReader{}) 220 | if err != nil { 221 | return nil, err 222 | } 223 | } 224 | 225 | filteredChangedFiles := slices.DeleteFunc(changedFiles, func(file string) bool { 226 | return slices.Contains(filesToIgnore, file) 227 | }) 228 | 229 | return filteredChangedFiles, nil 230 | } 231 | 232 | func updateConfigurations() { 233 | if CLI.Debug { 234 | loggingInit(logging.DEBUG) 235 | } else { 236 | loggingInit(logging.INFO) 237 | } 238 | 239 | if CLI.Branch.PreserveHelmLabels { 240 | preserveHelmLabels = true 241 | } 242 | 243 | if CLI.Branch.PrintAddedManifests { 244 | printAddedManifests = true 245 | } 246 | 247 | if CLI.Branch.PrintRemovedManifests { 248 | printRemovedManifests = true 249 | } 250 | 251 | if CLI.Branch.FullOutput { 252 | printAddedManifests = true 253 | printRemovedManifests = true 254 | } 255 | } 256 | 257 | func main() { 258 | if err := runCLI(); err != nil { 259 | log.Fatal(err) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /cmd/argo-compare/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/op/go-logging" 8 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 9 | "github.com/spf13/afero" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCollectRepoCredentials(t *testing.T) { 14 | // Test case 1: repo credentials are set and valid 15 | envVars := map[string]string{ 16 | "REPO_CREDS_1": `{"url":"https://example.com","username":"user1","password":"pass1"}`, 17 | "REPO_CREDS_2": `{"url":"https://example.org","username":"user2","password":"pass2"}`, 18 | "REPO_CREDS_3": `{"url":"https://example.net","username":"user3","password":"pass3"}`, 19 | } 20 | for key, value := range envVars { 21 | err := os.Setenv(key, value) 22 | if err != nil { 23 | t.Fatalf("failed to set environment variable %q: %v", key, err) 24 | } 25 | } 26 | 27 | if err := collectRepoCredentials(); err != nil { 28 | t.Fatalf("failed to collect repo credentials: %v", err) 29 | } 30 | 31 | expectedUrls := []string{"https://example.com", "https://example.org", "https://example.net"} 32 | for _, expectedUrl := range expectedUrls { 33 | found := false 34 | for _, repo := range repoCredentials { 35 | if repo.Url == expectedUrl { 36 | found = true 37 | break 38 | } 39 | } 40 | assert.True(t, found, "expected to find repo credentials for [%s], but none were found", expectedUrl) 41 | } 42 | 43 | // Test case 2: repo credentials are set but invalid 44 | envVars = map[string]string{ 45 | "REPO_CREDS_1": `{"url":"https://example.com","username":"user1","password":"pass1"}`, 46 | "REPO_CREDS_2": `{"url":"https://example.org","username":"user2","password":"pass2"}`, 47 | "REPO_CREDS_3": `{"url":"https://example.net","username":"user3","password":"pass3"`, 48 | } 49 | 50 | for key, value := range envVars { 51 | err := os.Setenv(key, value) 52 | if err != nil { 53 | t.Fatalf("failed to set environment variable %q: %v", key, err) 54 | } 55 | } 56 | 57 | assert.Error(t, collectRepoCredentials(), "expected to get an error when repo credentials are invalid") 58 | } 59 | 60 | func TestLoggingInit(t *testing.T) { 61 | // Run the function being tested 62 | loggingInit(logging.DEBUG) 63 | 64 | // Check the result 65 | if logging.GetLevel("") != logging.DEBUG { 66 | t.Errorf("logging level not set to DEBUG") 67 | } 68 | } 69 | 70 | func TestInvalidFilesList(t *testing.T) { 71 | // Test case 1: invalid files list is not empty 72 | repo := GitRepo{FsType: afero.NewOsFs(), CmdRunner: &utils.RealCmdRunner{}} 73 | repo.invalidFiles = []string{"file1", "file2", "file3"} 74 | 75 | err := printInvalidFilesList(&repo) 76 | // We need to return the error if any of the files is invalid 77 | assert.Error(t, err) 78 | 79 | // Test case 2: invalid files list is empty 80 | repo.invalidFiles = []string{} 81 | 82 | err = printInvalidFilesList(&repo) 83 | // If the list is empty, we should not return an error 84 | assert.NoError(t, err) 85 | } 86 | 87 | func TestCliCommands(t *testing.T) { 88 | oldArgs := os.Args 89 | defer func() { os.Args = oldArgs }() 90 | 91 | testCases := []struct { 92 | name string 93 | args []string 94 | expectedBranch string 95 | expectedAdded bool 96 | expectedRemoved bool 97 | expectedPreserve bool 98 | }{ 99 | {"minimal input", []string{"cmd", "branch", "main"}, "main", false, false, false}, 100 | {"full output", []string{"cmd", "branch", "main", "--full-output"}, "main", true, true, false}, 101 | {"print added", []string{"cmd", "branch", "main", "--print-added-manifests"}, "main", true, false, false}, 102 | {"print removed", []string{"cmd", "branch", "main", "--print-removed-manifests"}, "main", false, true, false}, 103 | {"preserve helm labels", []string{"cmd", "branch", "main", "--preserve-helm-labels"}, "main", false, false, true}, 104 | } 105 | 106 | for _, tc := range testCases { 107 | t.Run(tc.name, func(t *testing.T) { 108 | os.Args = tc.args 109 | err := parseCli() 110 | assert.NoErrorf(t, err, "expected to get no error when parsing valid command") 111 | assert.Equal(t, tc.expectedBranch, targetBranch) 112 | 113 | updateConfigurations() 114 | 115 | assert.Equal(t, tc.expectedAdded, printAddedManifests) 116 | assert.Equal(t, tc.expectedRemoved, printRemovedManifests) 117 | assert.Equal(t, tc.expectedPreserve, preserveHelmLabels) 118 | 119 | // Reset global vars 120 | targetBranch = "" 121 | printAddedManifests = false 122 | printRemovedManifests = false 123 | preserveHelmLabels = false 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /cmd/argo-compare/target.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces" 8 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 9 | "gopkg.in/yaml.v3" 10 | 11 | "github.com/shini4i/argo-compare/internal/models" 12 | ) 13 | 14 | type Target struct { 15 | CmdRunner interfaces.CmdRunner 16 | FileReader interfaces.FileReader 17 | File string 18 | Type string // src or dst version 19 | App models.Application 20 | } 21 | 22 | // parse reads the YAML content from a file and unmarshals it into an Application model. 23 | // It uses the FileReader interface to support different implementations for file reading. 24 | // Returns an error in case of issues during reading, unmarshaling or validation. 25 | func (t *Target) parse() error { 26 | app := models.Application{} 27 | 28 | var file string 29 | 30 | // if we are working with a temporary file, we don't need to prepend the repo root path 31 | if !strings.Contains(t.File, "/tmp/") { 32 | if gitRepoRoot, err := GetGitRepoRoot(); err != nil { 33 | return err 34 | } else { 35 | file = fmt.Sprintf("%s/%s", gitRepoRoot, t.File) 36 | } 37 | } else { 38 | file = t.File 39 | } 40 | 41 | log.Debugf("Parsing %s...", file) 42 | 43 | yamlContent := t.FileReader.ReadFile(file) 44 | if err := yaml.Unmarshal(yamlContent, &app); err != nil { 45 | return err 46 | } 47 | 48 | if err := app.Validate(); err != nil { 49 | return err 50 | } 51 | 52 | t.App = app 53 | 54 | return nil 55 | } 56 | 57 | // generateValuesFiles generates Helm values files for the application's sources. 58 | // If the application uses multiple sources, a separate values file is created for each source. 59 | // Otherwise, a single values file is generated for the application's single source. 60 | func (t *Target) generateValuesFiles(helmChartProcessor interfaces.HelmChartsProcessor) error { 61 | if t.App.Spec.MultiSource { 62 | for _, source := range t.App.Spec.Sources { 63 | if err := helmChartProcessor.GenerateValuesFile(source.Chart, tmpDir, t.Type, source.Helm.Values, source.Helm.ValuesObject); err != nil { 64 | return err 65 | } 66 | } 67 | } else { 68 | if err := helmChartProcessor.GenerateValuesFile(t.App.Spec.Source.Chart, tmpDir, t.Type, t.App.Spec.Source.Helm.Values, t.App.Spec.Source.Helm.ValuesObject); err != nil { 69 | return err 70 | } 71 | } 72 | return nil 73 | } 74 | 75 | // ensureHelmCharts downloads Helm charts for the application's sources. 76 | // If the application uses multiple sources, each chart is downloaded separately. 77 | // If the application has a single source, only the respective chart is downloaded. 78 | // In case of any error during download, the error is returned immediately. 79 | func (t *Target) ensureHelmCharts(helmChartProcessor interfaces.HelmChartsProcessor) error { 80 | if t.App.Spec.MultiSource { 81 | for _, source := range t.App.Spec.Sources { 82 | if err := helmChartProcessor.DownloadHelmChart(t.CmdRunner, utils.CustomGlobber{}, cacheDir, source.RepoURL, source.Chart, source.TargetRevision, repoCredentials); err != nil { 83 | return err 84 | } 85 | } 86 | } else { 87 | if err := helmChartProcessor.DownloadHelmChart(t.CmdRunner, utils.CustomGlobber{}, cacheDir, t.App.Spec.Source.RepoURL, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, repoCredentials); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | // extractCharts extracts the content of the downloaded Helm charts. 96 | // For applications with multiple sources, each chart is extracted separately. 97 | // For single-source applications, only the corresponding chart is extracted. 98 | // If an error occurs during extraction, the program is terminated. 99 | func (t *Target) extractCharts(helmChartProcessor interfaces.HelmChartsProcessor) error { 100 | // We have a separate function for this and not using helm to extract the content of the chart 101 | // because we don't want to re-download the chart if the TargetRevision is the same 102 | if t.App.Spec.MultiSource { 103 | for _, source := range t.App.Spec.Sources { 104 | err := helmChartProcessor.ExtractHelmChart(t.CmdRunner, utils.CustomGlobber{}, source.Chart, source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, source.RepoURL), tmpDir, t.Type) 105 | if err != nil { 106 | return err 107 | } 108 | } 109 | } else { 110 | err := helmChartProcessor.ExtractHelmChart(t.CmdRunner, utils.CustomGlobber{}, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, t.App.Spec.Source.RepoURL), tmpDir, t.Type) 111 | if err != nil { 112 | return err 113 | } 114 | } 115 | return nil 116 | } 117 | 118 | // renderAppSources uses Helm to render chart templates for the application's sources. 119 | // If the Helm specification provides a release name, it is used; otherwise, the application's metadata name is used. 120 | // If the application has multiple sources, each source is rendered individually. 121 | // If the application has only one source, the source is rendered accordingly. 122 | // If there's any error during rendering, it will lead to a fatal error, and the program will exit. 123 | func (t *Target) renderAppSources(helmChartProcessor interfaces.HelmChartsProcessor) error { 124 | var releaseName string 125 | 126 | // We are providing release name to the helm template command to cover some corner cases 127 | // when the chart is using the release name in the templates 128 | if !t.App.Spec.MultiSource { 129 | if t.App.Spec.Source.Helm.ReleaseName != "" { 130 | releaseName = t.App.Spec.Source.Helm.ReleaseName 131 | } else { 132 | releaseName = t.App.Metadata.Name 133 | } 134 | } 135 | 136 | if t.App.Spec.MultiSource { 137 | for _, source := range t.App.Spec.Sources { 138 | if source.Helm.ReleaseName != "" { 139 | releaseName = source.Helm.ReleaseName 140 | } else { 141 | releaseName = t.App.Metadata.Name 142 | } 143 | if err := helmChartProcessor.RenderAppSource(&utils.RealCmdRunner{}, releaseName, source.Chart, source.TargetRevision, tmpDir, t.Type); err != nil { 144 | return err 145 | } 146 | } 147 | return nil 148 | } 149 | 150 | if err := helmChartProcessor.RenderAppSource(&utils.RealCmdRunner{}, releaseName, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, tmpDir, t.Type); err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /cmd/argo-compare/target_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks" 9 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 10 | "github.com/stretchr/testify/assert" 11 | "go.uber.org/mock/gomock" 12 | ) 13 | 14 | const ( 15 | testsDir = "../../testdata/disposable" 16 | ) 17 | 18 | func TestTarget_generateValuesFiles(t *testing.T) { 19 | ctrl := gomock.NewController(t) 20 | defer ctrl.Finish() 21 | 22 | // Create an instance of the mock HelmChartsProcessor 23 | mockHelmValuesGenerator := mocks.NewMockHelmChartsProcessor(ctrl) 24 | 25 | app := Target{ 26 | CmdRunner: &utils.RealCmdRunner{}, 27 | FileReader: &utils.OsFileReader{}, 28 | File: appFile, 29 | } 30 | 31 | err := app.parse() 32 | assert.NoError(t, err) 33 | 34 | // Test case 1: Successful values file generation with single source Application 35 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("ingress-nginx", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil) 36 | 37 | err = app.generateValuesFiles(mockHelmValuesGenerator) 38 | assert.NoError(t, err) 39 | 40 | // Test case 2: Successful values file generation with multiple source Applications 41 | app2 := Target{ 42 | CmdRunner: &utils.RealCmdRunner{}, 43 | FileReader: &utils.OsFileReader{}, 44 | File: "testdata/test2.yaml", 45 | } 46 | 47 | err = app2.parse() 48 | assert.NoError(t, err) 49 | 50 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("kubed", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil) 51 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("sealed-secrets", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil) 52 | 53 | err = app2.generateValuesFiles(mockHelmValuesGenerator) 54 | assert.NoError(t, err) 55 | 56 | // Test case 3: Failed values file generation with single source Application 57 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("ingress-nginx", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(errors.New("some unexpected error")) 58 | err = app.generateValuesFiles(mockHelmValuesGenerator) 59 | assert.ErrorContains(t, err, "some unexpected error") 60 | 61 | // Test case 4: Failed values file generation with multiple source Applications 62 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("kubed", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(errors.New("multiple source apps error")) 63 | err = app2.generateValuesFiles(mockHelmValuesGenerator) 64 | assert.ErrorContains(t, err, "multiple source apps error") 65 | } 66 | 67 | func TestTarget_ensureHelmCharts(t *testing.T) { 68 | ctrl := gomock.NewController(t) 69 | defer ctrl.Finish() 70 | 71 | // Create an instance of the mock HelmChartProcessor 72 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl) 73 | 74 | // Test case 1: Single source chart download success 75 | app := Target{ 76 | CmdRunner: &utils.RealCmdRunner{}, 77 | FileReader: &utils.OsFileReader{}, 78 | File: appFile, 79 | } 80 | err := app.parse() 81 | assert.NoError(t, err) 82 | 83 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app.CmdRunner, utils.CustomGlobber{}, cacheDir, app.App.Spec.Source.RepoURL, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, repoCredentials).Return(nil) 84 | 85 | err = app.ensureHelmCharts(mockHelmChartProcessor) 86 | assert.NoError(t, err) 87 | 88 | // Test case 2: Multiple source chart downloads success 89 | app2 := Target{ 90 | CmdRunner: &utils.RealCmdRunner{}, 91 | FileReader: &utils.OsFileReader{}, 92 | File: "testdata/test2.yaml", 93 | } 94 | err = app2.parse() 95 | assert.NoError(t, err) 96 | 97 | for _, source := range app2.App.Spec.Sources { 98 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app2.CmdRunner, utils.CustomGlobber{}, cacheDir, source.RepoURL, source.Chart, source.TargetRevision, repoCredentials).Return(nil) 99 | } 100 | 101 | err = app2.ensureHelmCharts(mockHelmChartProcessor) 102 | assert.NoError(t, err) 103 | 104 | // Test case 3: Single source chart download failure 105 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app.CmdRunner, utils.CustomGlobber{}, cacheDir, app.App.Spec.Source.RepoURL, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, repoCredentials).Return(errors.New("some download error")) 106 | 107 | err = app.ensureHelmCharts(mockHelmChartProcessor) 108 | assert.ErrorContains(t, err, "some download error") 109 | 110 | // Test case 4: Multiple source chart downloads failure 111 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app2.CmdRunner, utils.CustomGlobber{}, cacheDir, app2.App.Spec.Sources[0].RepoURL, app2.App.Spec.Sources[0].Chart, app2.App.Spec.Sources[0].TargetRevision, repoCredentials).Return(errors.New("multiple download error")) 112 | 113 | err = app2.ensureHelmCharts(mockHelmChartProcessor) 114 | assert.ErrorContains(t, err, "multiple download error") 115 | } 116 | 117 | func TestTarget_extractCharts(t *testing.T) { 118 | ctrl := gomock.NewController(t) 119 | defer ctrl.Finish() 120 | 121 | // Create an instance of the mock HelmChartsProcessor 122 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl) 123 | 124 | // Test case 1: Single source chart extraction success 125 | app := Target{ 126 | CmdRunner: &utils.RealCmdRunner{}, 127 | FileReader: &utils.OsFileReader{}, 128 | File: appFile, 129 | } 130 | err := app.parse() 131 | assert.NoError(t, err) 132 | 133 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app.CmdRunner, utils.CustomGlobber{}, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app.App.Spec.Source.RepoURL), tmpDir, app.Type).Return(nil) 134 | 135 | err = app.extractCharts(mockHelmChartProcessor) 136 | assert.NoError(t, err) 137 | 138 | // Test case 2: Multiple source chart extractions success 139 | app2 := Target{ 140 | CmdRunner: &utils.RealCmdRunner{}, 141 | FileReader: &utils.OsFileReader{}, 142 | File: "testdata/test2.yaml", 143 | } 144 | err = app2.parse() 145 | assert.NoError(t, err) 146 | 147 | for _, source := range app2.App.Spec.Sources { 148 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app2.CmdRunner, utils.CustomGlobber{}, source.Chart, source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, source.RepoURL), tmpDir, app2.Type).Return(nil) 149 | } 150 | 151 | err = app2.extractCharts(mockHelmChartProcessor) 152 | assert.NoError(t, err) 153 | 154 | // Test case 3: Single source chart extraction failure 155 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app.CmdRunner, utils.CustomGlobber{}, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app.App.Spec.Source.RepoURL), tmpDir, app.Type).Return(errors.New("some extraction error")) 156 | 157 | err = app.extractCharts(mockHelmChartProcessor) 158 | assert.ErrorContains(t, err, "some extraction error") 159 | 160 | // Test case 4: Multiple source chart extractions failure 161 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app2.CmdRunner, utils.CustomGlobber{}, app2.App.Spec.Sources[0].Chart, app2.App.Spec.Sources[0].TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app2.App.Spec.Sources[0].RepoURL), tmpDir, app2.Type).Return(errors.New("multiple extraction error")) 162 | 163 | err = app2.extractCharts(mockHelmChartProcessor) 164 | assert.ErrorContains(t, err, "multiple extraction error") 165 | } 166 | 167 | func TestTarget_renderAppSources(t *testing.T) { 168 | ctrl := gomock.NewController(t) 169 | defer ctrl.Finish() 170 | 171 | // Create a mock HelmChartsProcessor 172 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl) 173 | 174 | // Test case 1: Single source rendering success 175 | app := Target{ 176 | CmdRunner: &utils.RealCmdRunner{}, 177 | FileReader: &utils.OsFileReader{}, 178 | File: appFile, 179 | } 180 | err := app.parse() 181 | assert.NoError(t, err) 182 | 183 | mockHelmChartProcessor.EXPECT().RenderAppSource(app.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) 184 | 185 | err = app.renderAppSources(mockHelmChartProcessor) 186 | assert.NoError(t, err) 187 | 188 | // Test case 2: Multiple source rendering success 189 | app2 := Target{ 190 | CmdRunner: &utils.RealCmdRunner{}, 191 | FileReader: &utils.OsFileReader{}, 192 | File: "testdata/test2.yaml", 193 | } 194 | err = app2.parse() 195 | assert.NoError(t, err) 196 | 197 | for _, source := range app2.App.Spec.Sources { 198 | mockHelmChartProcessor.EXPECT().RenderAppSource(app2.CmdRunner, gomock.Any(), source.Chart, source.TargetRevision, gomock.Any(), gomock.Any()).Return(nil) 199 | } 200 | 201 | err = app2.renderAppSources(mockHelmChartProcessor) 202 | assert.NoError(t, err) 203 | 204 | // Test case 3: Single source rendering failure 205 | mockHelmChartProcessor.EXPECT().RenderAppSource(app.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("some rendering error")) 206 | 207 | err = app.renderAppSources(mockHelmChartProcessor) 208 | assert.ErrorContains(t, err, "some rendering error") 209 | 210 | // Test case 4: Multiple source rendering failure 211 | mockHelmChartProcessor.EXPECT().RenderAppSource(app2.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("multiple rendering error")) 212 | 213 | err = app2.renderAppSources(mockHelmChartProcessor) 214 | assert.ErrorContains(t, err, "multiple rendering error") 215 | } 216 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/cmdrunner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | ) 7 | 8 | type RealCmdRunner struct{} 9 | 10 | func (r *RealCmdRunner) Run(cmd string, args ...string) (string, string, error) { 11 | command := exec.Command(cmd, args...) 12 | 13 | var stdoutBuffer, stderrBuffer bytes.Buffer 14 | command.Stdout = &stdoutBuffer 15 | command.Stderr = &stderrBuffer 16 | 17 | err := command.Run() 18 | 19 | return stdoutBuffer.String(), stderrBuffer.String(), err 20 | } 21 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/cmdrunner_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRealCmdRunner_Run(t *testing.T) { 10 | runner := &RealCmdRunner{} 11 | cmd := "echo" 12 | args := []string{"hello"} 13 | 14 | stdout, stderr, err := runner.Run(cmd, args...) 15 | 16 | assert.NoError(t, err) 17 | assert.Equal(t, "hello\n", stdout) 18 | assert.Equal(t, "", stderr) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/file-reader.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | type OsFileReader struct{} 9 | 10 | func (r OsFileReader) ReadFile(file string) []byte { 11 | if readFile, err := os.ReadFile(file); errors.Is(err, os.ErrNotExist) /* #nosec G304 */ { 12 | return nil 13 | } else { 14 | return readFile 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/file-reader_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestOsFileReader_ReadFile(t *testing.T) { 11 | // Temporary file content 12 | content := "Hello, World!" 13 | 14 | // Create a temporary file and write some content to it 15 | tempFile, err := os.CreateTemp("", "testfile") 16 | if err != nil { 17 | t.Fatal("Failed to create temp file.") 18 | } 19 | defer func(name string) { 20 | err := os.Remove(name) 21 | if err != nil { 22 | t.Fatal("Failed to remove temp file.") 23 | } 24 | }(tempFile.Name()) // Clean up after test 25 | 26 | _, err = tempFile.Write([]byte(content)) 27 | if err != nil { 28 | t.Fatal("Failed to write to temp file.") 29 | } 30 | 31 | if err := tempFile.Close(); err != nil { 32 | t.Fatal("Failed to close temp file.") 33 | } 34 | 35 | // Test reading an existing file 36 | reader := OsFileReader{} 37 | readContent := reader.ReadFile(tempFile.Name()) 38 | assert.Equal(t, content, string(readContent)) 39 | 40 | // Test reading a non-existing file 41 | nonExistingFileName := "non_existing_file.txt" 42 | readContent = reader.ReadFile(nonExistingFileName) 43 | assert.Nil(t, readContent) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/globber.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/mattn/go-zglob" 4 | 5 | type CustomGlobber struct{} 6 | 7 | func (g CustomGlobber) Glob(pattern string) ([]string, error) { 8 | return zglob.Glob(pattern) 9 | } 10 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/globber_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCustomGlobber_Glob(t *testing.T) { 10 | // Create a pattern that matches some files in the filesystem 11 | pattern := "*.go" // Matches all Go source files in the current directory 12 | 13 | // Use the CustomGlobber to find matching files 14 | globber := CustomGlobber{} 15 | matches, err := globber.Glob(pattern) 16 | 17 | // Expect no error 18 | assert.NoError(t, err) 19 | 20 | // Expect at least one match (since there should be at least one Go source file) 21 | assert.True(t, len(matches) > 0) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/helm-chart-processor.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/shini4i/argo-compare/internal/helpers" 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/op/go-logging" 14 | 15 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces" 16 | "github.com/shini4i/argo-compare/internal/models" 17 | ) 18 | 19 | var ( 20 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc() 21 | FailedToDownloadChart = errors.New("failed to download chart") 22 | ) 23 | 24 | type RealHelmChartProcessor struct { 25 | Log *logging.Logger 26 | } 27 | 28 | // GenerateValuesFile creates a Helm values file for a given chart in a specified directory. 29 | // It takes a chart name, a temporary directory for storing the file, the target type categorizing the application, 30 | // and the content of the values file in string format. 31 | // The function first attempts to create the file. If an error occurs, it terminates the program. 32 | // Next, it writes the values string to the file. If an error occurs during this process, the program is also terminated. 33 | func (g RealHelmChartProcessor) GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error { 34 | yamlFile, err := os.Create(fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType)) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | defer func(yamlFile *os.File) { 40 | err := yamlFile.Close() 41 | if err != nil { 42 | g.Log.Fatal(err) 43 | } 44 | }(yamlFile) 45 | 46 | var data []byte 47 | if values != "" { 48 | // Write the 'values' field if it is provided 49 | data = []byte(values) 50 | } else if valuesObject != nil { 51 | // Serialize the 'valuesObject' if it is provided 52 | data, err = yaml.Marshal(valuesObject) 53 | if err != nil { 54 | return err 55 | } 56 | } else { 57 | return errors.New("either 'values' or 'valuesObject' must be provided") 58 | } 59 | 60 | if _, err := yamlFile.Write(data); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // DownloadHelmChart fetches a specified version of a Helm chart from a given repository URL and 68 | // stores it in a cache directory. The function leverages the provided CmdRunner to execute 69 | // the helm pull command and Globber to deal with possible non-standard chart naming. 70 | // If the chart is already present in the cache, the function just logs the information and doesn't download it again. 71 | // The function is designed to handle potential errors during directory creation, globbing, and Helm chart downloading. 72 | // Any critical error during these operations terminates the program. 73 | func (g RealHelmChartProcessor) DownloadHelmChart(cmdRunner interfaces.CmdRunner, globber interfaces.Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error { 74 | chartLocation := fmt.Sprintf("%s/%s", cacheDir, repoUrl) 75 | 76 | if err := os.MkdirAll(chartLocation, 0750); err != nil { 77 | g.Log.Fatal(err) 78 | } 79 | 80 | // A bit hacky, but we need to support cases when helm chart tgz filename does not follow the standard naming convention 81 | // For example, sonarqube-4.0.0+315.tgz 82 | chartFileName, err := globber.Glob(fmt.Sprintf("%s/%s-%s*.tgz", chartLocation, chartName, targetRevision)) 83 | if err != nil { 84 | g.Log.Fatal(err) 85 | } 86 | 87 | if len(chartFileName) == 0 { 88 | username, password := helpers.FindHelmRepoCredentials(repoUrl, repoCredentials) 89 | 90 | g.Log.Debugf("Downloading version [%s] of [%s] chart...", 91 | cyan(targetRevision), 92 | cyan(chartName)) 93 | 94 | // we assume that if repoUrl does not have protocol, it is an OCI helm registry 95 | // hence we mutate the content of chartName and remove content of repoUrl 96 | if !strings.Contains(repoUrl, "http") { 97 | chartName = fmt.Sprintf("oci://%s/%s", repoUrl, chartName) 98 | repoUrl = "" 99 | } 100 | 101 | stdout, stderr, err := cmdRunner.Run("helm", 102 | "pull", 103 | "--destination", chartLocation, 104 | "--username", username, 105 | "--password", password, 106 | "--repo", repoUrl, 107 | chartName, 108 | "--version", targetRevision) 109 | 110 | if len(stdout) > 0 { 111 | g.Log.Info(stdout) 112 | } 113 | 114 | if len(stderr) > 0 { 115 | g.Log.Error(stderr) 116 | } 117 | 118 | if err != nil { 119 | return FailedToDownloadChart 120 | } 121 | } else { 122 | g.Log.Debugf("Version [%s] of [%s] chart is present in the cache...", 123 | cyan(targetRevision), 124 | cyan(chartName)) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // ExtractHelmChart extracts a specific version of a Helm chart from a cache directory 131 | // and stores it in a temporary directory. The function uses the provided CmdRunner to 132 | // execute the tar command and Globber to match the chart file in the cache. 133 | // If multiple files matching the pattern are found, an error is returned. 134 | // The function logs any output (standard or error) from the tar command. 135 | // Any critical error during these operations, like directory creation or extraction failure, terminates the program. 136 | func (g RealHelmChartProcessor) ExtractHelmChart(cmdRunner interfaces.CmdRunner, globber interfaces.Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error { 137 | g.Log.Debugf("Extracting [%s] chart version [%s] to %s/charts/%s...", 138 | cyan(chartName), 139 | cyan(chartVersion), 140 | tmpDir, targetType) 141 | 142 | path := fmt.Sprintf("%s/charts/%s/%s", tmpDir, targetType, chartName) 143 | if err := os.MkdirAll(path, 0750); err != nil { 144 | return err 145 | } 146 | 147 | searchPattern := fmt.Sprintf("%s/%s-%s*.tgz", 148 | chartLocation, 149 | chartName, 150 | chartVersion) 151 | 152 | chartFileName, err := globber.Glob(searchPattern) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | if len(chartFileName) == 0 { 158 | return errors.New("chart file not found") 159 | } 160 | 161 | // It's highly unlikely that we will have more than one file matching the pattern 162 | // Nevertheless we need to handle this case, please submit an issue if you encounter this 163 | if len(chartFileName) > 1 { 164 | return errors.New("more than one chart file found, please check your cache directory") 165 | } 166 | 167 | stdout, stderr, err := cmdRunner.Run("tar", 168 | "xf", 169 | chartFileName[0], 170 | "-C", fmt.Sprintf("%s/charts/%s", tmpDir, targetType), 171 | ) 172 | 173 | if len(stdout) > 0 { 174 | g.Log.Info(stdout) 175 | } 176 | 177 | if len(stderr) > 0 { 178 | g.Log.Error(stderr) 179 | } 180 | 181 | if err != nil { 182 | return err 183 | } 184 | 185 | return nil 186 | } 187 | 188 | // RenderAppSource uses the Helm CLI to render the templates of a given chart. 189 | // It takes a cmdRunner to run the Helm command, a release name for the Helm release, 190 | // the chart name and version, a temporary directory for storing intermediate files, 191 | // and the target type which categorizes the application. 192 | // The function constructs the Helm command with the provided arguments, runs it, and checks for any errors. 193 | // If there are any errors, it returns them. Otherwise, it returns nil. 194 | func (g RealHelmChartProcessor) RenderAppSource(cmdRunner interfaces.CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType string) error { 195 | g.Log.Debugf("Rendering [%s] chart's version [%s] templates using release name [%s]", 196 | cyan(chartName), 197 | cyan(chartVersion), 198 | cyan(releaseName)) 199 | 200 | _, stderr, err := cmdRunner.Run( 201 | "helm", 202 | "template", 203 | "--release-name", releaseName, 204 | fmt.Sprintf("%s/charts/%s/%s", tmpDir, targetType, chartName), 205 | "--output-dir", fmt.Sprintf("%s/templates/%s", tmpDir, targetType), 206 | "--values", fmt.Sprintf("%s/charts/%s/%s/values.yaml", tmpDir, targetType, chartName), 207 | "--values", fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType), 208 | ) 209 | 210 | if err != nil { 211 | g.Log.Error(stderr) 212 | return err 213 | } 214 | 215 | return nil 216 | } 217 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/helm-chart-processor_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | 10 | "github.com/op/go-logging" 11 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks" 12 | "github.com/shini4i/argo-compare/internal/models" 13 | "github.com/stretchr/testify/assert" 14 | "go.uber.org/mock/gomock" 15 | ) 16 | 17 | const ( 18 | testsDir = "../../../testdata/disposable" 19 | ) 20 | 21 | func TestGenerateValuesFile(t *testing.T) { 22 | helmChartProcessor := RealHelmChartProcessor{} 23 | 24 | // Create a temporary directory 25 | tmpDir, err := os.MkdirTemp(testsDir, "test-") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | // Delete the directory after the test finishes 31 | defer func(path string) { 32 | err := os.RemoveAll(path) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | }(tmpDir) 37 | 38 | chartName := "ingress-nginx" 39 | targetType := "src" 40 | values := "fullnameOverride: ingress-nginx\ncontroller:\n kind: DaemonSet\n service:\n externalTrafficPolicy: Local\n annotations:\n fancyAnnotation: false\n" 41 | 42 | // Test case 1: Everything works as expected 43 | err = helmChartProcessor.GenerateValuesFile(chartName, tmpDir, targetType, values, nil) 44 | assert.NoError(t, err, "expected no error, got %v", err) 45 | 46 | // Read the generated file 47 | generatedValues, err := os.ReadFile(tmpDir + "/" + chartName + "-values-" + targetType + ".yaml") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | assert.Equal(t, values, string(generatedValues)) 53 | 54 | // Test case 2: Error when creating the file 55 | err = helmChartProcessor.GenerateValuesFile(chartName, "/non/existing/path", targetType, values, nil) 56 | assert.Error(t, err, "expected error, got nil") 57 | } 58 | 59 | func TestDownloadHelmChart(t *testing.T) { 60 | ctrl := gomock.NewController(t) 61 | defer ctrl.Finish() 62 | 63 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 64 | 65 | // Create the mocks 66 | mockGlobber := mocks.NewMockGlobber(ctrl) 67 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 68 | 69 | // Test case 1: chart exists 70 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{testsDir + "/ingress-nginx-3.34.0.tgz"}, nil) 71 | err := helmChartProcessor.DownloadHelmChart(mockCmdRunner, 72 | mockGlobber, 73 | testsDir+"/cache", 74 | "https://chart.example.com", 75 | "ingress-nginx", 76 | "3.34.0", 77 | []models.RepoCredentials{}, 78 | ) 79 | assert.NoError(t, err, "expected no error, got %v", err) 80 | 81 | // Test case 2: chart does not exist, and successfully downloaded 82 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil) 83 | mockCmdRunner.EXPECT().Run("helm", 84 | "pull", 85 | "--destination", gomock.Any(), 86 | "--username", gomock.Any(), 87 | "--password", gomock.Any(), 88 | "--repo", gomock.Any(), 89 | gomock.Any(), 90 | "--version", gomock.Any()).Return("", "", nil) 91 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner, 92 | mockGlobber, 93 | testsDir+"/cache", 94 | "https://chart.example.com", 95 | "ingress-nginx", 96 | "3.34.0", 97 | []models.RepoCredentials{}, 98 | ) 99 | assert.NoError(t, err, "expected no error, got %v", err) 100 | 101 | // Test case 3: chart does not exist, and failed to download 102 | osErr := &exec.ExitError{ 103 | ProcessState: &os.ProcessState{}, 104 | } 105 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil) 106 | mockCmdRunner.EXPECT().Run("helm", 107 | "pull", 108 | "--destination", gomock.Any(), 109 | "--username", gomock.Any(), 110 | "--password", gomock.Any(), 111 | "--repo", gomock.Any(), 112 | gomock.Any(), 113 | "--version", gomock.Any()).Return("", "dummy error message", osErr) 114 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner, 115 | mockGlobber, 116 | testsDir+"/cache", 117 | "https://chart.example.com", 118 | "ingress-nginx", 119 | "3.34.0", 120 | []models.RepoCredentials{}, 121 | ) 122 | assert.ErrorIsf(t, err, FailedToDownloadChart, "expected error %v, got %v", FailedToDownloadChart, err) 123 | } 124 | 125 | func TestExtractHelmChart(t *testing.T) { 126 | ctrl := gomock.NewController(t) 127 | defer ctrl.Finish() 128 | 129 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 130 | 131 | // Create the mocks 132 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 133 | mockGlobber := mocks.NewMockGlobber(ctrl) 134 | 135 | // Set up the expected behavior for the mocks 136 | 137 | // Test case 1: Single chart file found 138 | expectedChartFileName := testsDir + "/charts/ingress-nginx/ingress-nginx-3.34.0.tgz" 139 | expectedChartLocation := testsDir + "/cache" 140 | expectedTmpDir := testsDir + "/tmp" 141 | expectedTargetType := "target" 142 | 143 | // Mock the behavior of the globber 144 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{expectedChartFileName}, nil) 145 | 146 | // Mock the behavior of the cmdRunner 147 | mockCmdRunner.EXPECT().Run("tar", 148 | "xf", 149 | expectedChartFileName, 150 | "-C", 151 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType), 152 | ).Return("", "", nil) 153 | 154 | err := helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 155 | 156 | assert.NoError(t, err, "expected no error, got %v", err) 157 | 158 | // Test case 2: Multiple chart files found, error expected 159 | expectedChartFilesNames := []string{testsDir + "/charts/sonarqube/sonarqube-4.0.0+315.tgz", 160 | testsDir + "/charts/sonarqube/sonarqube-4.0.0+316.tgz"} 161 | 162 | mockGlobber.EXPECT().Glob(testsDir+"/cache/sonarqube-4.0.0*.tgz").Return(expectedChartFilesNames, nil) 163 | 164 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "sonarqube", "4.0.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 165 | assert.Error(t, err, "expected error, got %v", err) 166 | 167 | // Test case 3: Chart file found, but failed to extract 168 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{expectedChartFileName}, nil) 169 | mockCmdRunner.EXPECT().Run("tar", 170 | "xf", 171 | expectedChartFileName, 172 | "-C", 173 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType), 174 | ).Return("", "some unexpected error", errors.New("some unexpected error")) 175 | 176 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 177 | assert.Error(t, err, "expected error, got %v", err) 178 | 179 | // Test case 4: zglob failed to run 180 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{}, os.ErrPermission) 181 | 182 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 183 | assert.Error(t, err, "expected error, got %v", err) 184 | 185 | // Test case 5: Failed to find chart file 186 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{}, nil) 187 | 188 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 189 | assert.Error(t, err, "expected error, got %v", err) 190 | } 191 | 192 | func TestRenderAppSource(t *testing.T) { 193 | ctrl := gomock.NewController(t) 194 | defer ctrl.Finish() 195 | 196 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 197 | 198 | // Create an instance of the mock CmdRunner 199 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 200 | 201 | releaseName := "my-release" 202 | chartName := "my-chart" 203 | chartVersion := "1.2.3" 204 | tmpDir := testsDir + "/tmp" 205 | targetType := "src" 206 | 207 | // Test case 1: Successful render 208 | mockCmdRunner.EXPECT().Run("helm", 209 | "template", 210 | "--release-name", gomock.Any(), 211 | gomock.Any(), 212 | "--output-dir", gomock.Any(), 213 | "--values", gomock.Any(), 214 | "--values", gomock.Any()).Return("", "", nil) 215 | 216 | // Call the function under test 217 | err := helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType) 218 | assert.NoError(t, err, "expected no error, got %v", err) 219 | 220 | // Test case 2: Failed render 221 | osErr := &exec.ExitError{ 222 | ProcessState: &os.ProcessState{}, 223 | } 224 | mockCmdRunner.EXPECT().Run("helm", 225 | "template", 226 | "--release-name", gomock.Any(), 227 | gomock.Any(), 228 | "--output-dir", gomock.Any(), 229 | "--values", gomock.Any(), 230 | "--values", gomock.Any()).Return("", "", osErr) 231 | 232 | err = helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType) 233 | assert.Errorf(t, err, "expected error, got %v", err) 234 | } 235 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/osfs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | type RealOsFs struct{} 6 | 7 | func (r *RealOsFs) CreateTemp(dir, pattern string) (f *os.File, err error) { 8 | return os.CreateTemp(dir, pattern) 9 | } 10 | 11 | func (r *RealOsFs) Remove(name string) error { 12 | return os.Remove(name) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/osfs_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRealOsFs_CreateTemp(t *testing.T) { 11 | r := RealOsFs{} 12 | dir := "" // Use the default temporary directory 13 | pattern := "testfile*" 14 | 15 | // Create a temporary file 16 | file, err := r.CreateTemp(dir, pattern) 17 | assert.NoError(t, err) 18 | defer func(file *os.File) { 19 | err := file.Close() 20 | if err != nil { 21 | t.Fatal("Failed to close temp file.") 22 | } 23 | }(file) 24 | 25 | // Check that the file exists 26 | _, err = os.Stat(file.Name()) 27 | assert.NoError(t, err) 28 | 29 | // Clean up 30 | err = r.Remove(file.Name()) 31 | assert.NoError(t, err) 32 | } 33 | 34 | func TestRealOsFs_Remove(t *testing.T) { 35 | r := RealOsFs{} 36 | dir := "" // Use the default temporary directory 37 | pattern := "testfile*" 38 | 39 | // Create a temporary file 40 | file, err := r.CreateTemp(dir, pattern) 41 | assert.NoError(t, err) 42 | defer func(file *os.File) { 43 | err := file.Close() 44 | if err != nil { 45 | t.Fatal("Failed to close temp file.") 46 | } 47 | }(file) 48 | 49 | // Remove the file 50 | err = r.Remove(file.Name()) 51 | assert.NoError(t, err) 52 | 53 | // Check that the file no longer exists 54 | _, err = os.Stat(file.Name()) 55 | assert.True(t, os.IsNotExist(err)) 56 | } 57 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/*_test.go" 3 | - "cmd/argo-compare/mocks/**" 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shini4i/argo-compare 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/alecthomas/kong v1.10.0 9 | github.com/codingsince1985/checksum v1.3.0 10 | github.com/fatih/color v1.18.0 11 | github.com/go-git/go-git/v5 v5.15.0 12 | github.com/hexops/gotextdiff v1.0.3 13 | github.com/mattn/go-zglob v0.0.6 14 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 15 | github.com/spf13/afero v1.14.0 16 | github.com/stretchr/testify v1.10.0 17 | go.uber.org/mock v0.5.1 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | dario.cat/mergo v1.0.0 // indirect 23 | github.com/Microsoft/go-winio v0.6.2 // indirect 24 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 25 | github.com/cloudflare/circl v1.6.1 // indirect 26 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/emirpasic/gods v1.18.1 // indirect 29 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 30 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 31 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 32 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 33 | github.com/kevinburke/ssh_config v1.2.0 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/pjbgf/sha1cd v0.3.2 // indirect 37 | github.com/pmezard/go-difflib v1.0.0 // indirect 38 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 39 | github.com/skeema/knownhosts v1.3.1 // indirect 40 | github.com/xanzy/ssh-agent v0.3.3 // indirect 41 | golang.org/x/crypto v0.37.0 // indirect 42 | golang.org/x/net v0.39.0 // indirect 43 | golang.org/x/sys v0.32.0 // indirect 44 | golang.org/x/text v0.24.0 // indirect 45 | gopkg.in/warnings.v0 v0.1.2 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 7 | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 9 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 10 | github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw= 11 | github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 12 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 13 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 15 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 16 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 17 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 18 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 19 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 20 | github.com/codingsince1985/checksum v1.3.0 h1:kqqIqWBwjidGmt/pO4yXCEX+np7HACGx72EB+MkKcVY= 21 | github.com/codingsince1985/checksum v1.3.0/go.mod h1:QfRskdtdWap+gJil8e5obw6I8/cWJ0SwMUACruWDSU8= 22 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 23 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 28 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 29 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 30 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 31 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 32 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 33 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 34 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 35 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 37 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 38 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 39 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 40 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 41 | github.com/go-git/go-git/v5 v5.15.0 h1:f5Qn0W0F7ry1iN0ZwIU5m/n7/BKB4hiZfc+zlZx7ly0= 42 | github.com/go-git/go-git/v5 v5.15.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 43 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 44 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 46 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 47 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 48 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 49 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 50 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 51 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 52 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 53 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 59 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 60 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 61 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 62 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 63 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 64 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 65 | github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= 66 | github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= 67 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 68 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 69 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 70 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 71 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 72 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 73 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 74 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 75 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 76 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 77 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 78 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 79 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 80 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 81 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 82 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 83 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 84 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 85 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 86 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 87 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 88 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 89 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 90 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 91 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 92 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 93 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 94 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 95 | go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= 96 | go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 97 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 98 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 99 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 100 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 101 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 102 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 103 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 104 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 105 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 114 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 115 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 116 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 117 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 118 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 119 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 120 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 121 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 122 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 123 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 124 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 125 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 126 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 127 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 128 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 129 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 130 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 131 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 132 | -------------------------------------------------------------------------------- /internal/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/shini4i/argo-compare/internal/models" 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | // GetEnv retrieves the value of an environment variable specified by the given key. 14 | // If the environment variable is set, its value is returned. 15 | // If the environment variable is not set, the provided fallback value is returned. 16 | func GetEnv(key, fallback string) string { 17 | if value, ok := os.LookupEnv(key); ok { 18 | return value 19 | } 20 | return fallback 21 | } 22 | 23 | // Contains checks if a string `s` is present in the given string slice `slice`. 24 | // It iterates over each item in the slice and returns true if a match is found. 25 | // If no match is found, it returns false. 26 | func Contains(slice []string, s string) bool { 27 | for _, item := range slice { 28 | if item == s { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | 35 | // StripHelmLabels removes the specified Helm labels from the content of a file. 36 | // The function takes a file path as input and returns the stripped file content as a byte slice. 37 | // It removes the labels listed in the `labels` slice using regular expressions. 38 | // The function returns an error if there is an issue reading the file. 39 | func StripHelmLabels(file string) ([]byte, error) { 40 | // list of labels to remove 41 | labels := []string{ 42 | "app.kubernetes.io/managed-by", 43 | "helm.sh/chart", 44 | "chart", 45 | "app.kubernetes.io/version", 46 | } 47 | 48 | regex := strings.Join(labels, "|") 49 | 50 | // remove helm labels as they are not needed for comparison 51 | // it might be error-prone, as those labels are not always the same 52 | re := regexp.MustCompile("(?m)[\r\n]+^.*(" + regex + "):.*$") 53 | 54 | var fileData []byte 55 | var err error 56 | 57 | if fileData, err = os.ReadFile(file); err != nil /* #nosec G304 */ { 58 | return nil, err 59 | } 60 | 61 | strippedFileData := re.ReplaceAll(fileData, []byte("")) 62 | 63 | return strippedFileData, nil 64 | } 65 | 66 | // WriteToFile writes the provided data to a file specified by the file path. 67 | // It takes a file path and a byte slice of data as input. 68 | // The function writes the data to the file with the specified file permissions (0644). 69 | // It returns an error if there is an issue writing to the file. 70 | func WriteToFile(fs afero.Fs, file string, data []byte) error { 71 | if err := afero.WriteFile(fs, file, data, 0644); err != nil { 72 | return err 73 | } 74 | return nil 75 | } 76 | 77 | // CreateTempFile creates a temporary file in the "/tmp" directory with a unique name 78 | // that has the prefix "compare-" and suffix ".yaml". It then writes the provided content 79 | // to this temporary file. The function returns a pointer to the created os.File if it 80 | // succeeds. If the function fails at any step, it returns an error wrapped with context 81 | // about what step of the process it failed at. 82 | func CreateTempFile(fs afero.Fs, content string) (afero.File, error) { 83 | tmpFile, err := afero.TempFile(fs, "/tmp", "compare-*.yaml") 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to create temporary file: %w", err) 86 | } 87 | 88 | if err = WriteToFile(fs, tmpFile.Name(), []byte(content)); err != nil { 89 | return nil, fmt.Errorf("failed to write to temporary file: %w", err) 90 | } 91 | 92 | return tmpFile, nil 93 | } 94 | 95 | // FindHelmRepoCredentials scans the provided array of RepoCredentials for a match to the 96 | // provided repository URL, and returns the associated username and password. 97 | // If no matching credentials are found, it returns two empty strings. 98 | func FindHelmRepoCredentials(url string, credentials []models.RepoCredentials) (string, string) { 99 | for _, repoCred := range credentials { 100 | if repoCred.Url == url { 101 | return repoCred.Username, repoCred.Password 102 | } 103 | } 104 | return "", "" 105 | } 106 | -------------------------------------------------------------------------------- /internal/helpers/helpers_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shini4i/argo-compare/internal/models" 7 | "github.com/spf13/afero" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | const ( 12 | expectedStrippedOutput = `# for testing purpose we need only limited fields 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | metadata: 16 | labels: 17 | app.kubernetes.io/instance: traefik-web 18 | app.kubernetes.io/name: traefik 19 | argocd.argoproj.io/instance: traefik 20 | name: traefik 21 | namespace: web 22 | ` 23 | ) 24 | 25 | func TestGetEnv(t *testing.T) { 26 | // Test case 1: Check if an existing environment variable is retrieved 27 | expectedValue := "test value" 28 | t.Setenv("TEST_KEY", expectedValue) 29 | 30 | actualValue := GetEnv("TEST_KEY", "fallback") 31 | assert.Equal(t, expectedValue, actualValue) 32 | 33 | // Test case 2: Check if a missing environment variable falls back to the default value 34 | expectedValue = "fallback" 35 | actualValue = GetEnv("MISSING_KEY", expectedValue) 36 | assert.Equal(t, expectedValue, actualValue) 37 | } 38 | 39 | func TestContains(t *testing.T) { 40 | // Test case 1: Check if an item is in a slice 41 | slice1 := []string{"apple", "banana", "cherry"} 42 | assert.True(t, Contains(slice1, "apple")) 43 | 44 | // Test case 2: Check if an item is not in a slice 45 | slice2 := []string{"apple", "banana", "cherry"} 46 | assert.False(t, Contains(slice2, "orange")) 47 | } 48 | 49 | func TestStripHelmLabels(t *testing.T) { 50 | // Call the function to strip Helm labels 51 | fileContent, err := StripHelmLabels("../../testdata/dynamic/deployment.yaml") 52 | 53 | assert.NoError(t, err) 54 | assert.Equal(t, expectedStrippedOutput, string(fileContent)) 55 | 56 | // We want to be sure that the function returns an error if the file cannot be read 57 | _, err = StripHelmLabels("../../testdata/invalid.yaml") 58 | assert.Error(t, err) 59 | } 60 | 61 | func TestWriteToFile(t *testing.T) { 62 | fs := afero.NewMemMapFs() 63 | 64 | // Test case 1: Check the successful case 65 | filePath := "../../testdata/dynamic/output.txt" 66 | 67 | // Call the function to write data to file 68 | err := WriteToFile(fs, filePath, []byte(expectedStrippedOutput)) 69 | assert.NoError(t, err) 70 | 71 | // Read the written file 72 | writtenData, err := afero.ReadFile(fs, filePath) 73 | assert.NoError(t, err) 74 | 75 | // Compare the written data with the test data 76 | assert.Equal(t, expectedStrippedOutput, string(writtenData)) 77 | 78 | // Cleanup: Remove the written file 79 | err = fs.Remove(filePath) 80 | assert.NoError(t, err) 81 | 82 | // Test case 2: Check the error case (we should get an error if the file cannot be written) 83 | fs = afero.NewReadOnlyFs(fs) 84 | 85 | filePath = "../../testdata/invalid/output.txt" 86 | err = WriteToFile(fs, filePath, []byte(expectedStrippedOutput)) 87 | assert.Error(t, err) 88 | } 89 | 90 | func TestCreateTempFile(t *testing.T) { 91 | t.Run("create and write successful", func(t *testing.T) { 92 | // Create a new in-memory filesystem 93 | fs := afero.NewMemMapFs() 94 | 95 | // Run the function to test 96 | file, err := CreateTempFile(fs, "test content") 97 | assert.NoError(t, err) 98 | 99 | // Check that the file contains the expected content 100 | content, err := afero.ReadFile(fs, file.Name()) 101 | assert.NoError(t, err) 102 | assert.Equal(t, "test content", string(content)) 103 | }) 104 | 105 | t.Run("failed to create file", func(t *testing.T) { 106 | // Create a read-only in-memory filesystem 107 | fs := afero.NewReadOnlyFs(afero.NewMemMapFs()) 108 | 109 | // Run the function to test 110 | _, err := CreateTempFile(fs, "test content") 111 | 112 | // assert error to contain the expected message 113 | assert.Contains(t, err.Error(), "failed to create temporary file") 114 | }) 115 | } 116 | 117 | func TestFindHelmRepoCredentials(t *testing.T) { 118 | repoCreds := []models.RepoCredentials{ 119 | { 120 | Url: "https://charts.example.com", 121 | Username: "user", 122 | Password: "pass", 123 | }, 124 | { 125 | Url: "https://charts.test.com", 126 | Username: "testuser", 127 | Password: "testpass", 128 | }, 129 | } 130 | 131 | tests := []struct { 132 | name string 133 | url string 134 | expectedUser string 135 | expectedPass string 136 | }{ 137 | { 138 | name: "Credentials Found", 139 | url: "https://charts.example.com", 140 | expectedUser: "user", 141 | expectedPass: "pass", 142 | }, 143 | { 144 | name: "Credentials Not Found", 145 | url: "https://charts.notfound.com", 146 | expectedUser: "", 147 | expectedPass: "", 148 | }, 149 | } 150 | 151 | for _, tt := range tests { 152 | t.Run(tt.name, func(t *testing.T) { 153 | username, password := FindHelmRepoCredentials(tt.url, repoCreds) 154 | assert.Equal(t, tt.expectedUser, username) 155 | assert.Equal(t, tt.expectedPass, password) 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /internal/models/application.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | NotApplicationError = errors.New("file is not an Application") 10 | UnsupportedAppConfigurationError = errors.New("unsupported Application configuration") 11 | EmptyFileError = errors.New("file is empty") 12 | ) 13 | 14 | type Application struct { 15 | Kind string `yaml:"kind"` 16 | Metadata struct { 17 | Name string `yaml:"name"` 18 | Namespace string `yaml:"namespace"` 19 | } `yaml:"metadata"` 20 | Spec struct { 21 | Source *Source `yaml:"source"` 22 | Sources []*Source `yaml:"sources"` 23 | MultiSource bool `yaml:"-"` 24 | } `yaml:"spec"` 25 | } 26 | 27 | type Source struct { 28 | RepoURL string `yaml:"repoURL"` 29 | Chart string `yaml:"chart,omitempty"` 30 | TargetRevision string `yaml:"targetRevision"` 31 | Path string `yaml:"path,omitempty"` 32 | Helm struct { 33 | ReleaseName string `yaml:"releaseName,omitempty"` 34 | Values string `yaml:"values,omitempty"` 35 | ValueFiles []string `yaml:"valueFiles,omitempty"` 36 | ValuesObject map[string]interface{} `yaml:"valuesObject,omitempty"` 37 | } `yaml:"helm"` 38 | } 39 | 40 | // Validate performs validation checks on the Application struct. 41 | // It checks for the following: 42 | // - If the Application struct is empty, returns EmptyFileError. 43 | // - If both the 'source' and 'sources' fields are set at the same time, returns an error. 44 | // - If the kind of the application is not "Application", returns NotApplicationError. 45 | // - If the application specifies sources, ensures that each source has a non-empty 'chart' field. 46 | // - Sets the 'MultiSource' field to true if sources are specified. 47 | // - Returns nil if all validation checks pass. 48 | func (app *Application) Validate() error { 49 | // Check if the required fields 'Kind', 'Metadata.Name', and 'Metadata.Namespace' are set. 50 | if app.Kind == "" && app.Metadata.Name == "" && app.Metadata.Namespace == "" { 51 | return EmptyFileError 52 | } 53 | 54 | if app.Spec.Source != nil && len(app.Spec.Sources) > 0 { 55 | return fmt.Errorf("both 'source' and 'sources' fields cannot be set at the same time") 56 | } 57 | 58 | if app.Kind != "Application" { 59 | return NotApplicationError 60 | } 61 | 62 | // currently we support only helm repository based charts as a source 63 | if len(app.Spec.Sources) != 0 { 64 | for _, source := range app.Spec.Sources { 65 | if len(source.Chart) == 0 { 66 | return UnsupportedAppConfigurationError 67 | } 68 | } 69 | } else { 70 | if len(app.Spec.Source.Chart) == 0 { 71 | return UnsupportedAppConfigurationError 72 | } 73 | } 74 | 75 | if app.Spec.Sources != nil { 76 | app.Spec.MultiSource = true 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/models/application_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestApplication_Validate(t *testing.T) { 11 | // Test case 1: Empty application 12 | app := &Application{} 13 | err := app.Validate() 14 | assert.True(t, errors.Is(err, EmptyFileError), "Expected validation error: %v, but got: %v", EmptyFileError, err) 15 | 16 | // Test case 2: Application with invalid kind 17 | app = &Application{ 18 | Kind: "InvalidKind", 19 | } 20 | err = app.Validate() 21 | assert.True(t, errors.Is(err, NotApplicationError), "Expected validation error: %v, but got: %v", NotApplicationError, err) 22 | 23 | // Test case 3: Unsupported app configuration - empty chart name 24 | appWithEmptyChart := &Application{ 25 | Kind: "Application", 26 | Spec: struct { 27 | Source *Source `yaml:"source"` 28 | Sources []*Source `yaml:"sources"` 29 | MultiSource bool `yaml:"-"` 30 | }{ 31 | Source: &Source{ 32 | Chart: "", // Empty chart name 33 | }, 34 | Sources: nil, 35 | MultiSource: false, 36 | }, 37 | } 38 | err = appWithEmptyChart.Validate() 39 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError") 40 | 41 | // Test case 4: Valid application with multiple sources 42 | appWithMultipleSources := &Application{ 43 | Kind: "Application", 44 | Spec: struct { 45 | Source *Source `yaml:"source"` 46 | Sources []*Source `yaml:"sources"` 47 | MultiSource bool `yaml:"-"` 48 | }{ 49 | Source: nil, 50 | Sources: []*Source{ 51 | { 52 | RepoURL: "https://chart.example.com", 53 | Chart: "chart-1", 54 | TargetRevision: "1.0.0", 55 | }, 56 | { 57 | RepoURL: "https://chart.example.com", 58 | Chart: "chart-2", 59 | TargetRevision: "2.0.0", 60 | }, 61 | }, 62 | MultiSource: false, 63 | }, 64 | } 65 | err = appWithMultipleSources.Validate() 66 | assert.NoError(t, err, "expected no error") 67 | 68 | // Test case 5: Both 'source' and 'sources' fields are set 69 | appWithBothFields := &Application{ 70 | Kind: "Application", 71 | Spec: struct { 72 | Source *Source `yaml:"source"` 73 | Sources []*Source `yaml:"sources"` 74 | MultiSource bool `yaml:"-"` 75 | }{ 76 | Source: &Source{ 77 | RepoURL: "https://chart.example.com", 78 | Chart: "ingress-nginx", 79 | TargetRevision: "3.34.0", 80 | }, 81 | Sources: []*Source{ 82 | { 83 | RepoURL: "https://chart.example.com", 84 | Chart: "chart-1", 85 | TargetRevision: "1.0.0", 86 | }, 87 | }, 88 | MultiSource: false, 89 | }, 90 | } 91 | err = appWithBothFields.Validate() 92 | assert.EqualError(t, err, "both 'source' and 'sources' fields cannot be set at the same time", "expected error message") 93 | 94 | // Test case 6: Unsupported app configuration - empty chart name in multiple sources 95 | appWithMultipleSourcesUnsupported := &Application{ 96 | Kind: "Application", 97 | Spec: struct { 98 | Source *Source `yaml:"source"` 99 | Sources []*Source `yaml:"sources"` 100 | MultiSource bool `yaml:"-"` 101 | }{ 102 | Source: nil, 103 | Sources: []*Source{ 104 | { 105 | Chart: "", 106 | }, 107 | { 108 | Chart: "chart-2", 109 | }, 110 | }, 111 | MultiSource: false, 112 | }, 113 | } 114 | err = appWithMultipleSourcesUnsupported.Validate() 115 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError") 116 | } 117 | -------------------------------------------------------------------------------- /internal/models/repo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type RepoCredentials struct { 4 | Url string `json:"url"` 5 | Username string `json:"username"` 6 | Password string `json:"password"` 7 | } 8 | -------------------------------------------------------------------------------- /patch/diff-so-fancy.patch: -------------------------------------------------------------------------------- 1 | --- /usr/local/bin/diff-so-fancy 2021-07-27 23:23:49 2 | +++ diff-so-fancy-patched.pl 2023-01-19 01:21:19 3 | @@ -663,13 +663,18 @@ 4 | } elsif ($file_2 eq "/dev/null") { 5 | my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; 6 | return "deleted: $del_color$file_1$reset_color"; 7 | - # If the files aren't the same it's a rename 8 | + # If the files aren't the same it's a rename, but in case of argo-compare it is not 9 | + # hence we are making changes here 10 | } elsif ($file_1 ne $file_2) { 11 | my ($old, $new) = DiffHighlight::highlight_pair($file_1,$file_2,{only_diff => 1}); 12 | # highlight_pair already includes reset_color, but adds newline characters that need to be trimmed off 13 | $old = trim($old); 14 | $new = trim($new); 15 | - return "renamed: $old$meta_color to $new" 16 | + 17 | + my @fullFilePath = split("/", $new); 18 | + my $filePath = join("/", @fullFilePath[9..$#fullFilePath]); 19 | + 20 | + return "Found changes in: $filePath" 21 | # Something we haven't thought of yet 22 | } else { 23 | return "$file_1 -> $file_2"; 24 | -------------------------------------------------------------------------------- /testdata/disposable/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/disposable/.gitkeep -------------------------------------------------------------------------------- /testdata/dynamic/deployment.yaml: -------------------------------------------------------------------------------- 1 | # for testing purpose we need only limited fields 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | labels: 6 | app.kubernetes.io/instance: traefik-web 7 | app.kubernetes.io/managed-by: Helm 8 | app.kubernetes.io/name: traefik 9 | argocd.argoproj.io/instance: traefik 10 | helm.sh/chart: traefik-23.0.1 11 | name: traefik 12 | namespace: web 13 | -------------------------------------------------------------------------------- /testdata/repo.git/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/main 2 | -------------------------------------------------------------------------------- /testdata/repo.git/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = true 5 | ignorecase = true 6 | precomposeunicode = true 7 | [remote "origin"] 8 | url = /Users/ci/git/github.com/shini4i/argo-compare/testdata/repo 9 | -------------------------------------------------------------------------------- /testdata/repo.git/description: -------------------------------------------------------------------------------- 1 | Unnamed repository; edit this file 'description' to name the repository. 2 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/applypatch-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message taken by 4 | # applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. The hook is 8 | # allowed to edit the commit message file. 9 | # 10 | # To enable this hook, rename this file to "applypatch-msg". 11 | 12 | . git-sh-setup 13 | commitmsg="$(git rev-parse --git-path hooks/commit-msg)" 14 | test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} 15 | : 16 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | test "" = "$(grep '^Signed-off-by: ' "$1" | 21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { 22 | echo >&2 Duplicate Signed-off-by lines. 23 | exit 1 24 | } 25 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/fsmonitor-watchman.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use IPC::Open2; 6 | 7 | # An example hook script to integrate Watchman 8 | # (https://facebook.github.io/watchman/) with git to speed up detecting 9 | # new and modified files. 10 | # 11 | # The hook is passed a version (currently 2) and last update token 12 | # formatted as a string and outputs to stdout a new update token and 13 | # all files that have been modified since the update token. Paths must 14 | # be relative to the root of the working tree and separated by a single NUL. 15 | # 16 | # To enable this hook, rename this file to "query-watchman" and set 17 | # 'git config core.fsmonitor .git/hooks/query-watchman' 18 | # 19 | my ($version, $last_update_token) = @ARGV; 20 | 21 | # Uncomment for debugging 22 | # print STDERR "$0 $version $last_update_token\n"; 23 | 24 | # Check the hook interface version 25 | if ($version ne 2) { 26 | die "Unsupported query-fsmonitor hook version '$version'.\n" . 27 | "Falling back to scanning...\n"; 28 | } 29 | 30 | my $git_work_tree = get_working_dir(); 31 | 32 | my $retry = 1; 33 | 34 | my $json_pkg; 35 | eval { 36 | require JSON::XS; 37 | $json_pkg = "JSON::XS"; 38 | 1; 39 | } or do { 40 | require JSON::PP; 41 | $json_pkg = "JSON::PP"; 42 | }; 43 | 44 | launch_watchman(); 45 | 46 | sub launch_watchman { 47 | my $o = watchman_query(); 48 | if (is_work_tree_watched($o)) { 49 | output_result($o->{clock}, @{$o->{files}}); 50 | } 51 | } 52 | 53 | sub output_result { 54 | my ($clockid, @files) = @_; 55 | 56 | # Uncomment for debugging watchman output 57 | # open (my $fh, ">", ".git/watchman-output.out"); 58 | # binmode $fh, ":utf8"; 59 | # print $fh "$clockid\n@files\n"; 60 | # close $fh; 61 | 62 | binmode STDOUT, ":utf8"; 63 | print $clockid; 64 | print "\0"; 65 | local $, = "\0"; 66 | print @files; 67 | } 68 | 69 | sub watchman_clock { 70 | my $response = qx/watchman clock "$git_work_tree"/; 71 | die "Failed to get clock id on '$git_work_tree'.\n" . 72 | "Falling back to scanning...\n" if $? != 0; 73 | 74 | return $json_pkg->new->utf8->decode($response); 75 | } 76 | 77 | sub watchman_query { 78 | my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') 79 | or die "open2() failed: $!\n" . 80 | "Falling back to scanning...\n"; 81 | 82 | # In the query expression below we're asking for names of files that 83 | # changed since $last_update_token but not from the .git folder. 84 | # 85 | # To accomplish this, we're using the "since" generator to use the 86 | # recency index to select candidate nodes and "fields" to limit the 87 | # output to file names only. Then we're using the "expression" term to 88 | # further constrain the results. 89 | my $last_update_line = ""; 90 | if (substr($last_update_token, 0, 1) eq "c") { 91 | $last_update_token = "\"$last_update_token\""; 92 | $last_update_line = qq[\n"since": $last_update_token,]; 93 | } 94 | my $query = <<" END"; 95 | ["query", "$git_work_tree", {$last_update_line 96 | "fields": ["name"], 97 | "expression": ["not", ["dirname", ".git"]] 98 | }] 99 | END 100 | 101 | # Uncomment for debugging the watchman query 102 | # open (my $fh, ">", ".git/watchman-query.json"); 103 | # print $fh $query; 104 | # close $fh; 105 | 106 | print CHLD_IN $query; 107 | close CHLD_IN; 108 | my $response = do {local $/; }; 109 | 110 | # Uncomment for debugging the watch response 111 | # open ($fh, ">", ".git/watchman-response.json"); 112 | # print $fh $response; 113 | # close $fh; 114 | 115 | die "Watchman: command returned no output.\n" . 116 | "Falling back to scanning...\n" if $response eq ""; 117 | die "Watchman: command returned invalid output: $response\n" . 118 | "Falling back to scanning...\n" unless $response =~ /^\{/; 119 | 120 | return $json_pkg->new->utf8->decode($response); 121 | } 122 | 123 | sub is_work_tree_watched { 124 | my ($output) = @_; 125 | my $error = $output->{error}; 126 | if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { 127 | $retry--; 128 | my $response = qx/watchman watch "$git_work_tree"/; 129 | die "Failed to make watchman watch '$git_work_tree'.\n" . 130 | "Falling back to scanning...\n" if $? != 0; 131 | $output = $json_pkg->new->utf8->decode($response); 132 | $error = $output->{error}; 133 | die "Watchman: $error.\n" . 134 | "Falling back to scanning...\n" if $error; 135 | 136 | # Uncomment for debugging watchman output 137 | # open (my $fh, ">", ".git/watchman-output.out"); 138 | # close $fh; 139 | 140 | # Watchman will always return all files on the first query so 141 | # return the fast "everything is dirty" flag to git and do the 142 | # Watchman query just to get it over with now so we won't pay 143 | # the cost in git to look up each individual file. 144 | my $o = watchman_clock(); 145 | $error = $output->{error}; 146 | 147 | die "Watchman: $error.\n" . 148 | "Falling back to scanning...\n" if $error; 149 | 150 | output_result($o->{clock}, ("/")); 151 | $last_update_token = $o->{clock}; 152 | 153 | eval { launch_watchman() }; 154 | return 0; 155 | } 156 | 157 | die "Watchman: $error.\n" . 158 | "Falling back to scanning...\n" if $error; 159 | 160 | return 1; 161 | } 162 | 163 | sub get_working_dir { 164 | my $working_dir; 165 | if ($^O =~ 'msys' || $^O =~ 'cygwin') { 166 | $working_dir = Win32::GetCwd(); 167 | $working_dir =~ tr/\\/\//; 168 | } else { 169 | require Cwd; 170 | $working_dir = Cwd::cwd(); 171 | } 172 | 173 | return $working_dir; 174 | } 175 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/post-update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare a packed repository for use over 4 | # dumb transports. 5 | # 6 | # To enable this hook, rename this file to "post-update". 7 | 8 | exec git update-server-info 9 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-applypatch.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed 4 | # by applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. 8 | # 9 | # To enable this hook, rename this file to "pre-applypatch". 10 | 11 | . git-sh-setup 12 | precommit="$(git rev-parse --git-path hooks/pre-commit)" 13 | test -x "$precommit" && exec "$precommit" ${1+"$@"} 14 | : 15 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 11 | then 12 | against=HEAD 13 | else 14 | # Initial commit: diff against an empty tree object 15 | against=$(git hash-object -t tree /dev/null) 16 | fi 17 | 18 | # If you want to allow non-ASCII filenames set this variable to true. 19 | allownonascii=$(git config --type=bool hooks.allownonascii) 20 | 21 | # Redirect output to stderr. 22 | exec 1>&2 23 | 24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent 25 | # them from being added to the repository. We exploit the fact that the 26 | # printable range starts at the space character and ends with tilde. 27 | if [ "$allownonascii" != "true" ] && 28 | # Note that the use of brackets around a tr range is ok here, (it's 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since 30 | # the square bracket bytes happen to fall in the designated range. 31 | test $(git diff --cached --name-only --diff-filter=A -z $against | 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 33 | then 34 | cat <<\EOF 35 | Error: Attempt to add a non-ASCII file name. 36 | 37 | This can cause problems if you want to work with people on other platforms. 38 | 39 | To be portable it is advisable to rename the file. 40 | 41 | If you know what you are doing you can disable this check using: 42 | 43 | git config hooks.allownonascii true 44 | EOF 45 | exit 1 46 | fi 47 | 48 | # If there are whitespace errors, print the offending file names and fail. 49 | exec git diff-index --check --cached $against -- 50 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-merge-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git merge" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message to 6 | # stderr if it wants to stop the merge commit. 7 | # 8 | # To enable this hook, rename this file to "pre-merge-commit". 9 | 10 | . git-sh-setup 11 | test -x "$GIT_DIR/hooks/pre-commit" && 12 | exec "$GIT_DIR/hooks/pre-commit" 13 | : 14 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-push.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # An example hook script to verify what is about to be pushed. Called by "git 4 | # push" after it has checked the remote status, but before anything has been 5 | # pushed. If this script exits with a non-zero status nothing will be pushed. 6 | # 7 | # This hook is called with the following parameters: 8 | # 9 | # $1 -- Name of the remote to which the push is being done 10 | # $2 -- URL to which the push is being done 11 | # 12 | # If pushing without using a named remote those arguments will be equal. 13 | # 14 | # Information about the commits which are being pushed is supplied as lines to 15 | # the standard input in the form: 16 | # 17 | # 18 | # 19 | # This sample shows how to prevent push of commits where the log message starts 20 | # with "WIP" (work in progress). 21 | 22 | remote="$1" 23 | url="$2" 24 | 25 | zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" 48 | exit 1 49 | fi 50 | fi 51 | done 52 | 53 | exit 0 54 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-rebase.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) 2006, 2008 Junio C Hamano 4 | # 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing 6 | # its job, and can prevent the command from running by exiting with 7 | # non-zero status. 8 | # 9 | # The hook is called with the following parameters: 10 | # 11 | # $1 -- the upstream the series was forked from. 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). 13 | # 14 | # This sample shows how to prevent topic branches that are already 15 | # merged to 'next' branch from getting rebased, because allowing it 16 | # would result in rebasing already published history. 17 | 18 | publish=next 19 | basebranch="$1" 20 | if test "$#" = 2 21 | then 22 | topic="refs/heads/$2" 23 | else 24 | topic=`git symbolic-ref HEAD` || 25 | exit 0 ;# we do not interrupt rebasing detached HEAD 26 | fi 27 | 28 | case "$topic" in 29 | refs/heads/??/*) 30 | ;; 31 | *) 32 | exit 0 ;# we do not interrupt others. 33 | ;; 34 | esac 35 | 36 | # Now we are dealing with a topic branch being rebased 37 | # on top of master. Is it OK to rebase it? 38 | 39 | # Does the topic really exist? 40 | git show-ref -q "$topic" || { 41 | echo >&2 "No such branch $topic" 42 | exit 1 43 | } 44 | 45 | # Is topic fully merged to master? 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` 47 | if test -z "$not_in_master" 48 | then 49 | echo >&2 "$topic is fully merged to master; better remove it." 50 | exit 1 ;# we could allow it, but there is no point. 51 | fi 52 | 53 | # Is topic ever merged to next? If so you should not be rebasing it. 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` 55 | only_next_2=`git rev-list ^master ${publish} | sort` 56 | if test "$only_next_1" = "$only_next_2" 57 | then 58 | not_in_topic=`git rev-list "^$topic" master` 59 | if test -z "$not_in_topic" 60 | then 61 | echo >&2 "$topic is already up to date with master" 62 | exit 1 ;# we could allow it, but there is no point. 63 | else 64 | exit 0 65 | fi 66 | else 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` 68 | /usr/bin/perl -e ' 69 | my $topic = $ARGV[0]; 70 | my $msg = "* $topic has commits already merged to public branch:\n"; 71 | my (%not_in_next) = map { 72 | /^([0-9a-f]+) /; 73 | ($1 => 1); 74 | } split(/\n/, $ARGV[1]); 75 | for my $elem (map { 76 | /^([0-9a-f]+) (.*)$/; 77 | [$1 => $2]; 78 | } split(/\n/, $ARGV[2])) { 79 | if (!exists $not_in_next{$elem->[0]}) { 80 | if ($msg) { 81 | print STDERR $msg; 82 | undef $msg; 83 | } 84 | print STDERR " $elem->[1]\n"; 85 | } 86 | } 87 | ' "$topic" "$not_in_next" "$not_in_master" 88 | exit 1 89 | fi 90 | 91 | <<\DOC_END 92 | 93 | This sample hook safeguards topic branches that have been 94 | published from being rewound. 95 | 96 | The workflow assumed here is: 97 | 98 | * Once a topic branch forks from "master", "master" is never 99 | merged into it again (either directly or indirectly). 100 | 101 | * Once a topic branch is fully cooked and merged into "master", 102 | it is deleted. If you need to build on top of it to correct 103 | earlier mistakes, a new topic branch is created by forking at 104 | the tip of the "master". This is not strictly necessary, but 105 | it makes it easier to keep your history simple. 106 | 107 | * Whenever you need to test or publish your changes to topic 108 | branches, merge them into "next" branch. 109 | 110 | The script, being an example, hardcodes the publish branch name 111 | to be "next", but it is trivial to make it configurable via 112 | $GIT_DIR/config mechanism. 113 | 114 | With this workflow, you would want to know: 115 | 116 | (1) ... if a topic branch has ever been merged to "next". Young 117 | topic branches can have stupid mistakes you would rather 118 | clean up before publishing, and things that have not been 119 | merged into other branches can be easily rebased without 120 | affecting other people. But once it is published, you would 121 | not want to rewind it. 122 | 123 | (2) ... if a topic branch has been fully merged to "master". 124 | Then you can delete it. More importantly, you should not 125 | build on top of it -- other people may already want to 126 | change things related to the topic as patches against your 127 | "master", so if you need further changes, it is better to 128 | fork the topic (perhaps with the same name) afresh from the 129 | tip of "master". 130 | 131 | Let's look at this example: 132 | 133 | o---o---o---o---o---o---o---o---o---o "next" 134 | / / / / 135 | / a---a---b A / / 136 | / / / / 137 | / / c---c---c---c B / 138 | / / / \ / 139 | / / / b---b C \ / 140 | / / / / \ / 141 | ---o---o---o---o---o---o---o---o---o---o---o "master" 142 | 143 | 144 | A, B and C are topic branches. 145 | 146 | * A has one fix since it was merged up to "next". 147 | 148 | * B has finished. It has been fully merged up to "master" and "next", 149 | and is ready to be deleted. 150 | 151 | * C has not merged to "next" at all. 152 | 153 | We would want to allow C to be rebased, refuse A, and encourage 154 | B to be deleted. 155 | 156 | To compute (1): 157 | 158 | git rev-list ^master ^topic next 159 | git rev-list ^master next 160 | 161 | if these match, topic has not merged in next at all. 162 | 163 | To compute (2): 164 | 165 | git rev-list master..topic 166 | 167 | if this is empty, it is fully merged to "master". 168 | 169 | DOC_END 170 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/pre-receive.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to make use of push options. 4 | # The example simply echoes all push options that start with 'echoback=' 5 | # and rejects all pushes when the "reject" push option is used. 6 | # 7 | # To enable this hook, rename this file to "pre-receive". 8 | 9 | if test -n "$GIT_PUSH_OPTION_COUNT" 10 | then 11 | i=0 12 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" 13 | do 14 | eval "value=\$GIT_PUSH_OPTION_$i" 15 | case "$value" in 16 | echoback=*) 17 | echo "echo from the pre-receive-hook: ${value#*=}" >&2 18 | ;; 19 | reject) 20 | exit 1 21 | esac 22 | i=$((i + 1)) 23 | done 24 | fi 25 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/prepare-commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare the commit log message. 4 | # Called by "git commit" with the name of the file that has the 5 | # commit message, followed by the description of the commit 6 | # message's source. The hook's purpose is to edit the commit 7 | # message file. If the hook fails with a non-zero status, 8 | # the commit is aborted. 9 | # 10 | # To enable this hook, rename this file to "prepare-commit-msg". 11 | 12 | # This hook includes three examples. The first one removes the 13 | # "# Please enter the commit message..." help message. 14 | # 15 | # The second includes the output of "git diff --name-status -r" 16 | # into the message, just before the "git status" output. It is 17 | # commented because it doesn't cope with --amend or with squashed 18 | # commits. 19 | # 20 | # The third example adds a Signed-off-by line to the message, that can 21 | # still be edited. This is rarely a good idea. 22 | 23 | COMMIT_MSG_FILE=$1 24 | COMMIT_SOURCE=$2 25 | SHA1=$3 26 | 27 | /usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" 28 | 29 | # case "$COMMIT_SOURCE,$SHA1" in 30 | # ,|template,) 31 | # /usr/bin/perl -i.bak -pe ' 32 | # print "\n" . `git diff --cached --name-status -r` 33 | # if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; 34 | # *) ;; 35 | # esac 36 | 37 | # SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 38 | # git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" 39 | # if test -z "$COMMIT_SOURCE" 40 | # then 41 | # /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" 42 | # fi 43 | -------------------------------------------------------------------------------- /testdata/repo.git/hooks/push-to-checkout.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # An example hook script to update a checked-out tree on a git push. 4 | # 5 | # This hook is invoked by git-receive-pack(1) when it reacts to git 6 | # push and updates reference(s) in its repository, and when the push 7 | # tries to update the branch that is currently checked out and the 8 | # receive.denyCurrentBranch configuration variable is set to 9 | # updateInstead. 10 | # 11 | # By default, such a push is refused if the working tree and the index 12 | # of the remote repository has any difference from the currently 13 | # checked out commit; when both the working tree and the index match 14 | # the current commit, they are updated to match the newly pushed tip 15 | # of the branch. This hook is to be used to override the default 16 | # behaviour; however the code below reimplements the default behaviour 17 | # as a starting point for convenient modification. 18 | # 19 | # The hook receives the commit with which the tip of the current 20 | # branch is going to be updated: 21 | commit=$1 22 | 23 | # It can exit with a non-zero status to refuse the push (when it does 24 | # so, it must not modify the index or the working tree). 25 | die () { 26 | echo >&2 "$*" 27 | exit 1 28 | } 29 | 30 | # Or it can make any necessary changes to the working tree and to the 31 | # index to bring them to the desired state when the tip of the current 32 | # branch is updated to the new commit, and exit with a zero status. 33 | # 34 | # For example, the hook can simply run git read-tree -u -m HEAD "$1" 35 | # in order to emulate git fetch that is run in the reverse direction 36 | # with git push, as the two-tree form of git read-tree -u -m is 37 | # essentially the same as git switch or git checkout that switches 38 | # branches while keeping the local changes in the working tree that do 39 | # not interfere with the difference between the branches. 40 | 41 | # The below is a more-or-less exact translation to shell of the C code 42 | # for the default behaviour for git's push-to-checkout hook defined in 43 | # the push_to_deploy() function in builtin/receive-pack.c. 44 | # 45 | # Note that the hook will be executed from the repository directory, 46 | # not from the working tree, so if you want to perform operations on 47 | # the working tree, you will have to adapt your code accordingly, e.g. 48 | # by adding "cd .." or using relative paths. 49 | 50 | if ! git update-index -q --ignore-submodules --refresh 51 | then 52 | die "Up-to-date check failed" 53 | fi 54 | 55 | if ! git diff-files --quiet --ignore-submodules -- 56 | then 57 | die "Working directory has unstaged changes" 58 | fi 59 | 60 | # This is a rough translation of: 61 | # 62 | # head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX 63 | if git cat-file -e HEAD 2>/dev/null 64 | then 65 | head=HEAD 66 | else 67 | head=$(git hash-object -t tree --stdin &2 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 36 | echo " $0 )" >&2 37 | exit 1 38 | fi 39 | 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 41 | echo "usage: $0 " >&2 42 | exit 1 43 | fi 44 | 45 | # --- Config 46 | allowunannotated=$(git config --type=bool hooks.allowunannotated) 47 | allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) 48 | denycreatebranch=$(git config --type=bool hooks.denycreatebranch) 49 | allowdeletetag=$(git config --type=bool hooks.allowdeletetag) 50 | allowmodifytag=$(git config --type=bool hooks.allowmodifytag) 51 | 52 | # check for no description 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 54 | case "$projectdesc" in 55 | "Unnamed repository"* | "") 56 | echo "*** Project description file hasn't been set" >&2 57 | exit 1 58 | ;; 59 | esac 60 | 61 | # --- Check types 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. 63 | zero=$(git hash-object --stdin &2 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 77 | exit 1 78 | fi 79 | ;; 80 | refs/tags/*,delete) 81 | # delete tag 82 | if [ "$allowdeletetag" != "true" ]; then 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 84 | exit 1 85 | fi 86 | ;; 87 | refs/tags/*,tag) 88 | # annotated tag 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 90 | then 91 | echo "*** Tag '$refname' already exists." >&2 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 93 | exit 1 94 | fi 95 | ;; 96 | refs/heads/*,commit) 97 | # branch 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 99 | echo "*** Creating a branch is not allowed in this repository" >&2 100 | exit 1 101 | fi 102 | ;; 103 | refs/heads/*,delete) 104 | # delete branch 105 | if [ "$allowdeletebranch" != "true" ]; then 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 107 | exit 1 108 | fi 109 | ;; 110 | refs/remotes/*,commit) 111 | # tracking branch 112 | ;; 113 | refs/remotes/*,delete) 114 | # delete tracking branch 115 | if [ "$allowdeletebranch" != "true" ]; then 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 117 | exit 1 118 | fi 119 | ;; 120 | *) 121 | # Anything else (is there anything else?) 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 123 | exit 1 124 | ;; 125 | esac 126 | 127 | # --- Finished 128 | exit 0 129 | -------------------------------------------------------------------------------- /testdata/repo.git/info/exclude: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | -------------------------------------------------------------------------------- /testdata/repo.git/objects/14/4ee8cf8a2bc327c05e5478f46c8b4d867c2d53: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/14/4ee8cf8a2bc327c05e5478f46c8b4d867c2d53 -------------------------------------------------------------------------------- /testdata/repo.git/objects/41/334bbafb63ec09623eb5e970ef98d39bb3840f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/41/334bbafb63ec09623eb5e970ef98d39bb3840f -------------------------------------------------------------------------------- /testdata/repo.git/objects/42/fa0288a96d4c0c0ed530f982f13b31fde03da6: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/42/fa0288a96d4c0c0ed530f982f13b31fde03da6 -------------------------------------------------------------------------------- /testdata/repo.git/objects/5e/c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/5e/c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0 -------------------------------------------------------------------------------- /testdata/repo.git/objects/61/95e9b8bde1e012ccf756216fa225d35fa33690: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/61/95e9b8bde1e012ccf756216fa225d35fa33690 -------------------------------------------------------------------------------- /testdata/repo.git/objects/63/988c89a49d0b3a2afa0764ef4ea8e76d501b46: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/63/988c89a49d0b3a2afa0764ef4ea8e76d501b46 -------------------------------------------------------------------------------- /testdata/repo.git/objects/8f/c4774c9313d27406df5e3ff71fa4ec68c0deb7: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/8f/c4774c9313d27406df5e3ff71fa4ec68c0deb7 -------------------------------------------------------------------------------- /testdata/repo.git/objects/93/9b376ec8b6af2ba467bd0ca7d57499159b3ba3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/93/9b376ec8b6af2ba467bd0ca7d57499159b3ba3 -------------------------------------------------------------------------------- /testdata/repo.git/objects/b8/5e290803976b2a52702a0a699706f6e4c4ed86: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/b8/5e290803976b2a52702a0a699706f6e4c4ed86 -------------------------------------------------------------------------------- /testdata/repo.git/objects/bd/a469dd11cdfa8cc28bc7e044c360737327e3ce: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/bd/a469dd11cdfa8cc28bc7e044c360737327e3ce -------------------------------------------------------------------------------- /testdata/repo.git/objects/fc/a984329cd120e7b7fe648cb534a2add7291d41: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/fc/a984329cd120e7b7fe648cb534a2add7291d41 -------------------------------------------------------------------------------- /testdata/repo.git/packed-refs: -------------------------------------------------------------------------------- 1 | # pack-refs with: peeled fully-peeled sorted 2 | 144ee8cf8a2bc327c05e5478f46c8b4d867c2d53 refs/heads/feature-branch 3 | 5ec56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0 refs/heads/main 4 | -------------------------------------------------------------------------------- /testdata/test-values.yaml: -------------------------------------------------------------------------------- 1 | fullnameOverride: ingress-nginx 2 | controller: 3 | kind: DaemonSet 4 | service: 5 | externalTrafficPolicy: Local 6 | annotations: 7 | fancyAnnotation: false 8 | -------------------------------------------------------------------------------- /testdata/test.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: ingress-nginx 5 | namespace: argo-cd 6 | finalizers: 7 | - resources-finalizer.argocd.argoproj.io 8 | spec: 9 | project: default 10 | source: 11 | repoURL: https://kubernetes.github.io/ingress-nginx 12 | chart: ingress-nginx 13 | targetRevision: "4.2.3" 14 | helm: 15 | values: | 16 | fullnameOverride: ingress-nginx 17 | controller: 18 | kind: DaemonSet 19 | service: 20 | externalTrafficPolicy: Local 21 | annotations: 22 | fancyAnnotation: false 23 | 24 | destination: 25 | server: https://kubernetes.default.svc 26 | namespace: web 27 | 28 | syncPolicy: 29 | automated: 30 | prune: true 31 | selfHeal: true 32 | syncOptions: 33 | - CreateNamespace=true 34 | -------------------------------------------------------------------------------- /testdata/test2.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: ingress-nginx 5 | namespace: argo-cd 6 | finalizers: 7 | - resources-finalizer.argocd.argoproj.io 8 | spec: 9 | project: default 10 | sources: 11 | - repoURL: https://charts.appscode.com/stable 12 | chart: kubed 13 | targetRevision: "v0.13.2" 14 | helm: 15 | values: | 16 | fullnameOverride: kubed 17 | enableAnalytics: false 18 | config: 19 | clusterName: example 20 | - repoURL: https://bitnami-labs.github.io/sealed-secrets 21 | chart: sealed-secrets 22 | targetRevision: "2.10.0" 23 | helm: 24 | values: | 25 | fullnameOverride: sealed-secrets 26 | args: ["--key-renew-period=0", "--update-status"] 27 | 28 | destination: 29 | server: https://kubernetes.default.svc 30 | namespace: web 31 | 32 | syncPolicy: 33 | automated: 34 | prune: true 35 | selfHeal: true 36 | syncOptions: 37 | - CreateNamespace=true 38 | --------------------------------------------------------------------------------