pull/4916/merge
Florian Forster 9 months ago committed by GitHub
commit f455ff0b1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -17,24 +17,48 @@ limitations under the License.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"bitbucket.org/creachadair/stringset"
oci "github.com/fluxcd/pkg/oci/client"
"github.com/fluxcd/pkg/tar"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gonvenience/ytbx"
"github.com/google/shlex"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/homeport/dyff/pkg/dyff"
"github.com/spf13/cobra"
"golang.org/x/exp/maps"
"github.com/fluxcd/flux2/v2/internal/flags"
"github.com/fluxcd/flux2/v2/pkg/printers"
)
var ErrDiffArtifactChanged = errors.New("the artifact contents differ")
var diffArtifactCmd = &cobra.Command{
Use: "artifact",
Use: "artifact <from> <to>",
Short: "Diff Artifact",
Long: withPreviewNote(`The diff artifact command computes the diff between the remote OCI artifact and a local directory or file`),
Long: withPreviewNote(fmt.Sprintf(
"The diff artifact command prints the diff between the remote OCI artifact and a local directory or file.\n\n"+
"You can overwrite the command used for diffing by setting the %q environment variable.", externalDiffVar)),
Example: `# Check if local files differ from remote
flux diff artifact oci://ghcr.io/stefanprodan/manifests:podinfo:6.2.0 --path=./kustomize`,
flux diff artifact oci://ghcr.io/stefanprodan/manifests:podinfo:6.2.0 ./kustomize`,
RunE: diffArtifactCmdRun,
Args: cobra.RangeArgs(1, 2),
}
type diffArtifactFlags struct {
@ -42,6 +66,8 @@ type diffArtifactFlags struct {
creds string
provider flags.SourceOCIProvider
ignorePaths []string
brief bool
differ *differFlag
}
var diffArtifactArgs = newDiffArtifactArgs()
@ -49,34 +75,58 @@ var diffArtifactArgs = newDiffArtifactArgs()
func newDiffArtifactArgs() diffArtifactFlags {
return diffArtifactFlags{
provider: flags.SourceOCIProvider(sourcev1.GenericOCIProvider),
differ: &differFlag{
options: map[string]differ{
"dyff": dyffBuiltin{
opts: []dyff.CompareOption{
dyff.IgnoreOrderChanges(false),
dyff.KubernetesEntityDetection(true),
},
},
"external": externalDiff{},
"unified": unifiedDiff{},
},
description: map[string]string{
"dyff": `semantic diff for YAML inputs`,
"external": `execute the command in the "` + externalDiffVar + `" environment variable`,
"unified": "generic unified diff for arbitrary text inputs",
},
value: "unified",
differ: unifiedDiff{},
},
}
}
func init() {
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.path, "path", "", "path to the directory where the Kubernetes manifests are located")
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.path, "path", "", "path to the directory or file containing the Kubernetes manifests (deprecated, use a second positional argument instead)")
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.creds, "creds", "", "credentials for OCI registry in the format <username>[:<password>] if --provider is generic")
diffArtifactCmd.Flags().Var(&diffArtifactArgs.provider, "provider", sourceOCIRepositoryArgs.provider.Description())
diffArtifactCmd.Flags().StringSliceVar(&diffArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
diffArtifactCmd.Flags().BoolVarP(&diffArtifactArgs.brief, "brief", "q", false, "just print a line when the resources differ; does not output a list of changes")
diffArtifactCmd.Flags().Var(diffArtifactArgs.differ, "differ", diffArtifactArgs.differ.usage())
diffCmd.AddCommand(diffArtifactCmd)
}
func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
var from, to string
if len(args) < 1 {
return fmt.Errorf("artifact URL is required")
}
ociURL := args[0]
from = args[0]
if diffArtifactArgs.path == "" {
return fmt.Errorf("invalid path %q", diffArtifactArgs.path)
}
switch {
case len(args) >= 2:
to = args[1]
url, err := oci.ParseArtifactURL(ociURL)
if err != nil {
return err
}
case diffArtifactArgs.path != "":
// for backwards compatibility
to = diffArtifactArgs.path
if _, err := os.Stat(diffArtifactArgs.path); err != nil {
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", diffArtifactArgs.path)
default:
return errors.New("a second artifact is required")
}
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
@ -98,15 +148,402 @@ func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
return fmt.Errorf("provider not supported: %w", err)
}
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
return fmt.Errorf("error during login with provider: %w", err)
if url, err := oci.ParseArtifactURL(from); err == nil {
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
return fmt.Errorf("error during login with provider: %w", err)
}
}
if url, err := oci.ParseArtifactURL(to); err == nil {
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
return fmt.Errorf("error during login with provider: %w", err)
}
}
}
diff, err := diffArtifact(ctx, ociClient, from, to, diffArtifactArgs)
if err != nil {
return err
}
if diff == "" {
logger.Successf("no changes detected")
return nil
}
if !diffArtifactArgs.brief {
cmd.Print(diff)
}
return fmt.Errorf("%q and %q: %w", from, to, ErrDiffArtifactChanged)
}
func newMatcher(ignorePaths []string) gitignore.Matcher {
var patterns []gitignore.Pattern
for _, path := range ignorePaths {
patterns = append(patterns, gitignore.ParsePattern(path, nil))
}
return gitignore.NewMatcher(patterns)
}
func diffArtifact(ctx context.Context, client *oci.Client, from, to string, flags diffArtifactFlags) (string, error) {
fromDir, fromCleanup, err := loadArtifact(ctx, client, from)
if err != nil {
return "", err
}
defer fromCleanup()
toDir, toCleanup, err := loadArtifact(ctx, client, to)
if err != nil {
return "", err
}
defer toCleanup()
return flags.differ.Diff(ctx, fromDir, toDir, flags.ignorePaths)
}
// loadArtifact ensures that the artifact is in a local directory that can be
// recursively diffed. If necessary, files are downloaded, extracted, and/or
// copied into temporary directories for this purpose.
func loadArtifact(ctx context.Context, client *oci.Client, path string) (dir string, cleanup func(), err error) {
fi, err := os.Stat(path)
if err == nil && fi.IsDir() {
return path, func() {}, nil
}
if err == nil && fi.Mode().IsRegular() {
return loadArtifactFile(path)
}
url, err := oci.ParseArtifactURL(path)
if err == nil {
return loadArtifactOCI(ctx, client, url)
}
return "", nil, fmt.Errorf("%q: %w", path, os.ErrNotExist)
}
// loadArtifactOCI pulls the remove artifact into a temporary directory.
func loadArtifactOCI(ctx context.Context, client *oci.Client, url string) (dir string, cleanup func(), err error) {
tmpDir, err := os.MkdirTemp("", "flux-diff-artifact")
if err != nil {
return "", nil, fmt.Errorf("could not create temporary directory: %w", err)
}
cleanup = func() {
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Fprintf(os.Stderr, "os.RemoveAll(%q): %v\n", tmpDir, err)
}
}
if _, err := client.Pull(ctx, url, tmpDir); err != nil {
cleanup()
return "", nil, fmt.Errorf("Pull(%q): %w", url, err)
}
return tmpDir, cleanup, nil
}
// loadArtifactFile copies a file into a temporary directory to allow for recursive diffing.
// If path is a .tar.gz or .tgz file, the archive is extracted into a temporary directory.
// Otherwise the file is copied verbatim.
func loadArtifactFile(path string) (dir string, cleanup func(), err error) {
tmpDir, err := os.MkdirTemp("", "flux-diff-artifact")
if err != nil {
return "", nil, fmt.Errorf("could not create temporary directory: %w", err)
}
cleanup = func() {
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Fprintf(os.Stderr, "os.RemoveAll(%q): %v\n", tmpDir, err)
}
}
if err := ociClient.Diff(ctx, url, diffArtifactArgs.path, diffArtifactArgs.ignorePaths); err != nil {
if strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") {
if err := extractTo(path, tmpDir); err != nil {
cleanup()
return "", nil, err
}
} else {
fh, err := os.Open(path)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("os.Open(%q): %w", path, err)
}
defer fh.Close()
name := filepath.Join(tmpDir, filepath.Base(path))
if err := copyFile(fh, name); err != nil {
cleanup()
return "", nil, fmt.Errorf("os.Open(%q): %w", path, err)
}
}
return tmpDir, cleanup, nil
}
// extractTo extracts the .tar.gz / .tgz archive at archivePath into the destDir directory.
func extractTo(archivePath, destDir string) error {
archiveFH, err := os.Open(archivePath)
if err != nil {
return err
}
defer archiveFH.Close()
if err := tar.Untar(archiveFH, destDir); err != nil {
return fmt.Errorf("Untar(%q, %q): %w", archivePath, destDir, err)
}
logger.Successf("no changes detected")
return nil
}
func copyFile(from io.Reader, to string) error {
fh, err := os.Create(to)
if err != nil {
return fmt.Errorf("os.Create(%q): %w", to, err)
}
defer fh.Close()
if _, err := io.Copy(fh, from); err != nil {
return fmt.Errorf("io.Copy(%q): %w", to, err)
}
return nil
}
type differ interface {
// Diff compares the two local directories "to" and "from" and returns their differences, or an empty string if they are equal.
Diff(ctx context.Context, from, to string, ignorePaths []string) (string, error)
}
type unifiedDiff struct{}
func (d unifiedDiff) Diff(_ context.Context, fromDir, toDir string, ignorePaths []string) (string, error) {
matcher := newMatcher(ignorePaths)
fromFiles, err := filesInDir(fromDir, matcher)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "fromFiles = %v\n", fromFiles)
toFiles, err := filesInDir(toDir, matcher)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "toFiles = %v\n", toFiles)
allFiles := fromFiles.Union(toFiles)
var sb strings.Builder
for _, relPath := range allFiles.Elements() {
diff, err := d.diffFiles(fromDir, toDir, relPath)
if err != nil {
return "", err
}
fmt.Fprint(&sb, diff)
}
return sb.String(), nil
}
func (d unifiedDiff) diffFiles(fromDir, toDir, relPath string) (string, error) {
fromPath := filepath.Join(fromDir, relPath)
fromData, err := d.readFile(fromPath)
switch {
case errors.Is(err, fs.ErrNotExist):
return fmt.Sprintf("Only in %s: %s\n", toDir, relPath), nil
case err != nil:
return "", fmt.Errorf("readFile(%q): %w", fromPath, err)
}
toPath := filepath.Join(toDir, relPath)
toData, err := d.readFile(toPath)
switch {
case errors.Is(err, fs.ErrNotExist):
return fmt.Sprintf("Only in %s: %s\n", fromDir, relPath), nil
case err != nil:
return "", fmt.Errorf("readFile(%q): %w", toPath, err)
}
edits := myers.ComputeEdits(span.URIFromPath(fromPath), string(fromData), string(toData))
return fmt.Sprint(gotextdiff.ToUnified(fromPath, toPath, string(fromData), edits)), nil
}
func (d unifiedDiff) readFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(file)
}
func splitPath(path string) []string {
return strings.Split(path, string([]rune{filepath.Separator}))
}
func filesInDir(root string, matcher gitignore.Matcher) (stringset.Set, error) {
var files stringset.Set
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("filepath.Rel(%q, %q): %w", root, path, err)
}
if matcher.Match(splitPath(relPath), d.IsDir()) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if !d.Type().IsRegular() {
return nil
}
files.Add(relPath)
return nil
})
if err != nil {
return nil, err
}
return files, err
}
// externalDiff implements the differ interface using an external diff command.
type externalDiff struct{}
// externalDiffVar is the environment variable users can use to overwrite the external diff command.
const externalDiffVar = "FLUX_EXTERNAL_DIFF"
func (externalDiff) Diff(ctx context.Context, fromDir, toDir string, ignorePaths []string) (string, error) {
cmdline := os.Getenv(externalDiffVar)
if cmdline == "" {
return "", fmt.Errorf("the required %q environment variable is unset", externalDiffVar)
}
args, err := shlex.Split(cmdline)
if err != nil {
return "", fmt.Errorf("shlex.Split(%q): %w", cmdline, err)
}
var executable string
executable, args = args[0], args[1:]
for _, path := range ignorePaths {
args = append(args, "--exclude", path)
}
args = append(args, fromDir, toDir)
cmd := exec.CommandContext(ctx, executable, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
// exit code 1 only means there was a difference => ignore
} else if err != nil {
return "", fmt.Errorf("executing %q: %w", executable, err)
}
return stdout.String(), nil
}
// dyffBuiltin implements the differ interface using `dyff`, a semantic diff for YAML documents.
type dyffBuiltin struct {
opts []dyff.CompareOption
}
func (d dyffBuiltin) Diff(ctx context.Context, fromDir, toDir string, _ []string) (string, error) {
fromFile, err := ytbx.LoadDirectory(fromDir)
if err != nil {
return "", fmt.Errorf("ytbx.LoadDirectory(%q): %w", fromDir, err)
}
toFile, err := ytbx.LoadDirectory(toDir)
if err != nil {
return "", fmt.Errorf("ytbx.LoadDirectory(%q): %w", toDir, err)
}
report, err := dyff.CompareInputFiles(fromFile, toFile, d.opts...)
if err != nil {
return "", fmt.Errorf("dyff.CompareInputFiles(): %w", err)
}
if len(report.Diffs) == 0 {
return "", nil
}
var buf bytes.Buffer
if err := printers.NewDyffPrinter().Print(&buf, report); err != nil {
return "", fmt.Errorf("formatting dyff report: %w", err)
}
return buf.String(), nil
}
// differFlag implements pflag.Value for choosing a diffing implementation.
type differFlag struct {
options map[string]differ
description map[string]string
value string
differ
}
func (f *differFlag) Set(s string) error {
d, ok := f.options[s]
if !ok {
return fmt.Errorf("invalid value: %q", s)
}
f.value = s
f.differ = d
return nil
}
func (f *differFlag) String() string {
return f.value
}
func (f *differFlag) Type() string {
keys := maps.Keys(f.options)
sort.Strings(keys)
return strings.Join(keys, "|")
}
func (f *differFlag) usage() string {
var b strings.Builder
fmt.Fprint(&b, "how the diff is generated:")
keys := maps.Keys(f.options)
sort.Strings(keys)
for _, key := range keys {
fmt.Fprintf(&b, "\n %q: %s", key, f.description[key])
}
return b.String()
}

@ -22,6 +22,9 @@ package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"
@ -65,6 +68,7 @@ func TestDiffArtifact(t *testing.T) {
argsTpl string
pushFile string
diffFile string
diffName string
assert assertFunc
}{
{
@ -75,14 +79,50 @@ func TestDiffArtifact(t *testing.T) {
diffFile: "./testdata/diff-artifact/deployment.yaml",
assert: assertGoldenFile("testdata/diff-artifact/success.golden"),
},
{
name: "create unified diff output by default",
url: "oci://%s/podinfo:2.0.0",
argsTpl: "diff artifact %s --path=%s",
pushFile: "./testdata/diff-artifact/deployment.yaml",
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
diffName: "deployment.yaml",
assert: assert(
assertErrorIs(ErrDiffArtifactChanged),
assertRegexp(`(?m)^- cpu: 1000m$`),
assertRegexp(`(?m)^\+ cpu: 2000m$`),
),
},
{
name: "should fail if there is a diff",
url: "oci://%s/podinfo:2.0.0",
argsTpl: "diff artifact %s --path=%s",
pushFile: "./testdata/diff-artifact/deployment.yaml",
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
assert: assertError("the remote artifact contents differs from the local one"),
diffName: "only-local.yaml",
assert: assert(
assertErrorIs(ErrDiffArtifactChanged),
assertRegexp(`(?m)^Only in [^:]+: deployment.yaml$`),
assertRegexp(`(?m)^Only in [^:]+: only-local.yaml$`),
),
},
{
name: "semantic diff using dyff",
url: "oci://%s/podinfo:2.0.0",
argsTpl: "diff artifact %s --path=%s --differ=dyff",
pushFile: "./testdata/diff-artifact/deployment.yaml",
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
diffName: "deployment.yaml",
assert: assert(
assertErrorIs(ErrDiffArtifactChanged),
assertRegexp(`(?m)^spec.template.spec.containers.podinfod.resources.limits.cpu$`),
assertRegexp(`(?m)^ ± value change$`),
assertRegexp(`(?m)^ - 1000m$`),
assertRegexp(`(?m)^ \+ 2000m$`),
),
},
// Attention: tests do not spawn a new process when executing commands.
// That means that the --differ flag remains set to "dyff" for
// subsequent tests.
}
ctx := ctrl.SetupSignalHandler()
@ -99,11 +139,38 @@ func TestDiffArtifact(t *testing.T) {
t.Fatalf(fmt.Errorf("failed to push image: %w", err).Error())
}
diffFile := tt.diffFile
if tt.diffName != "" {
diffFile = makeTempFile(t, tt.diffFile, tt.diffName)
}
cmd := cmdTestCase{
args: fmt.Sprintf(tt.argsTpl, tt.url, tt.diffFile),
args: fmt.Sprintf(tt.argsTpl, tt.url, diffFile),
assert: tt.assert,
}
cmd.runTestCmd(t)
})
}
}
func makeTempFile(t *testing.T, source, basename string) string {
path := filepath.Join(t.TempDir(), basename)
out, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer out.Close()
in, err := os.Open(source)
if err != nil {
t.Fatal(err)
}
defer in.Close()
_, err = io.Copy(out, in)
if err != nil {
t.Fatal(err)
}
return path
}

@ -20,11 +20,13 @@ import (
"bufio"
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
@ -33,7 +35,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/mattn/go-shellwords"
"k8s.io/apimachinery/pkg/api/errors"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/tools/clientcmd"
@ -115,7 +117,7 @@ func (m *testEnvKubeManager) CreateObjects(clientObjects []*unstructured.Unstruc
obj.SetResourceVersion(createObj.GetResourceVersion())
err = m.client.Status().Update(context.Background(), obj)
// Updating status of static objects results in not found error.
if err != nil && !errors.IsNotFound(err) {
if err != nil && !k8serrors.IsNotFound(err) {
return err
}
}
@ -272,6 +274,15 @@ func assertError(expected string) assertFunc {
}
}
func assertErrorIs(want error) assertFunc {
return func(_ string, got error) error {
if errors.Is(got, want) {
return nil
}
return fmt.Errorf("Expected error '%v' but got '%v'", want, got)
}
}
// Expect the command to succeed with the expected test output.
func assertGoldenValue(expected string) assertFunc {
return assert(
@ -328,6 +339,17 @@ func assertGoldenTemplateFile(goldenFile string, templateValues map[string]strin
})
}
func assertRegexp(expected string) assertFunc {
re := regexp.MustCompile(expected)
return func(output string, _ error) error {
if !re.MatchString(output) {
return fmt.Errorf("Output does not match regular expression:\nOutput:\n%s\n\nRegular expression:\n%s", output, expected)
}
return nil
}
}
type TestClusterMode int
const (

@ -4,7 +4,7 @@ metadata:
labels:
kustomize.toolkit.fluxcd.io/name: podinfo
kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }}
name: podinfo-diff
name: podinfo
namespace: default
spec:
minReadySeconds: 3

@ -71,7 +71,7 @@ spec:
timeoutSeconds: 5
resources:
limits:
cpu: 2000m
cpu: 1000m
memory: 512Mi
requests:
cpu: 100m

@ -6,6 +6,7 @@ go 1.22.4
replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
require (
bitbucket.org/creachadair/stringset v0.0.14
github.com/Masterminds/semver/v3 v3.2.1
github.com/ProtonMail/go-crypto v1.0.0
github.com/cyphar/filepath-securejoin v0.3.1
@ -37,6 +38,7 @@ require (
github.com/gonvenience/ytbx v1.4.4
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.20.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-cleanhttp v0.5.2
github.com/homeport/dyff v1.7.1
github.com/lucasb-eyer/go-colorful v1.2.0
@ -50,6 +52,7 @@ require (
github.com/spf13/pflag v1.0.5
github.com/theckman/yacspin v0.13.12
golang.org/x/crypto v0.26.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/term v0.23.0
golang.org/x/text v0.17.0
k8s.io/api v0.31.0
@ -144,7 +147,6 @@ require (
github.com/google/go-github/v64 v64.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
@ -157,6 +159,7 @@ require (
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
github.com/hexops/gotextdiff v1.0.3
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@ -232,7 +235,6 @@ require (
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/oauth2 v0.22.0 // indirect
golang.org/x/sync v0.8.0 // indirect

@ -1,3 +1,5 @@
bitbucket.org/creachadair/stringset v0.0.14 h1:t1ejQyf8utS4GZV/4fM+1gvYucggZkfhb+tMobDxYOE=
bitbucket.org/creachadair/stringset v0.0.14/go.mod h1:Ej8fsr6rQvmeMDf6CCWMWGb14H9mz8kmDgPPTdiVT0w=
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
@ -323,6 +325,8 @@ github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGN
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/homeport/dyff v1.7.1 h1:B3KJUtnU53H2UryxGcfYKQPrde8VjjbwlHZbczH3giQ=
github.com/homeport/dyff v1.7.1/go.mod h1:iLe5b3ymc9xmHZNuJlNVKERE8L2isQMBLxFiTXcwZY0=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=

Loading…
Cancel
Save