diff --git a/cmd/casdiff/casdiff.go b/cmd/casdiff/casdiff.go new file mode 100644 index 00000000..60d83a62 --- /dev/null +++ b/cmd/casdiff/casdiff.go @@ -0,0 +1,227 @@ +// Copyright 2021-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strconv" + + "github.com/bufbuild/buf/private/bufpkg/bufcas" + "github.com/bufbuild/buf/private/pkg/app/appcmd" + "github.com/bufbuild/buf/private/pkg/app/appext" + "github.com/bufbuild/buf/private/pkg/slicesext" + "github.com/bufbuild/buf/private/pkg/slogapp" + "github.com/bufbuild/buf/private/pkg/storage" + "github.com/bufbuild/buf/private/pkg/storage/storageos" + "github.com/bufbuild/modules/private/bufpkg/bufstate" + "github.com/spf13/pflag" +) + +// format is a format to print the casdiff. +type format int + +const ( + formatFlagName = "format" + formatFlagShortName = "f" +) + +const ( + formatText format = iota + 1 + formatMarkdown +) + +//nolint:gochecknoglobals // treated as consts +var ( + formatsValuesToNames = map[format]string{ + formatText: "text", + formatMarkdown: "markdown", + } + formatsNamesToValues, _ = slicesext.ToUniqueValuesMap( + slicesext.MapKeysToSlice(formatsValuesToNames), + func(f format) string { return formatsValuesToNames[f] }, + ) + allFormatsString = slicesext.MapKeysToSortedSlice(formatsNamesToValues) +) + +func (f format) String() string { + if n, ok := formatsValuesToNames[f]; ok { + return n + } + return strconv.Itoa(int(f)) +} + +func newCommand(name string) *appcmd.Command { + builder := appext.NewBuilder( + name, + appext.BuilderWithLoggerProvider(slogapp.LoggerProvider), + ) + flags := newFlags() + return &appcmd.Command{ + Use: name + " ", + Short: "Run a CAS diff.", + Args: appcmd.ExactArgs(2), + BindFlags: flags.bind, + Run: builder.NewRunFunc( + func(ctx context.Context, container appext.Container) error { + return run(ctx, container, flags) + }, + ), + } +} + +type flags struct { + format string +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) bind(flagSet *pflag.FlagSet) { + flagSet.StringVarP( + &f.format, + formatFlagName, + formatFlagShortName, + formatText.String(), + fmt.Sprintf(`The out format to use. Must be one of %s`, allFormatsString), + ) +} + +func run( + ctx context.Context, + container appext.Container, + flags *flags, +) error { + format, ok := formatsNamesToValues[flags.format] + if !ok { + return fmt.Errorf("unsupported format %s", flags.format) + } + from, to := container.Arg(0), container.Arg(1) //nolint:varnamelen // from/to used symmetrically + if from == to { + return printDiff(newManifestDiff(), format) + } + // first, attempt to match from/to as module references in a state file in the same directory + // where the command is run + bucket, err := storageos.NewProvider().NewReadWriteBucket(".") + if err != nil { + return fmt.Errorf("new rw bucket: %w", err) + } + moduleStateReader, err := bucket.Get(ctx, bufstate.ModStateFileName) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read module state file: %w", err) + } + // if the state file does not exist, we assume we are in the cas directory, and that from/to are + // the manifest paths + mdiff, err := calculateDiffFromCASDirectory(ctx, bucket, from, to) + if err != nil { + return fmt.Errorf("calculate cas diff: %w", err) + } + return printDiff(mdiff, format) + } + // state file was found, attempt to parse it and match from/to with its references + stateRW, err := bufstate.NewReadWriter() + if err != nil { + return fmt.Errorf("new state rw: %w", err) + } + moduleState, err := stateRW.ReadModStateFile(moduleStateReader) + if err != nil { + return fmt.Errorf("read module state: %w", err) + } + var ( + fromManifestPath string + toManifestPath string + ) + for _, ref := range moduleState.GetReferences() { + if ref.GetName() == from { + fromManifestPath = ref.GetDigest() + if toManifestPath != "" { + break + } + } else if ref.GetName() == to { + toManifestPath = ref.GetDigest() + if fromManifestPath != "" { + break + } + } + } + if fromManifestPath == "" { + return fmt.Errorf("from reference %s not found in the module state file", from) + } + if toManifestPath == "" { + return fmt.Errorf("to reference %s not found in the module state file", to) + } + if fromManifestPath == toManifestPath { + return printDiff(newManifestDiff(), format) + } + casBucket, err := storageos.NewProvider().NewReadWriteBucket("cas") + if err != nil { + return fmt.Errorf("new rw cas bucket: %w", err) + } + mdiff, err := calculateDiffFromCASDirectory(ctx, casBucket, fromManifestPath, toManifestPath) + if err != nil { + return fmt.Errorf("calculate cas diff from state references: %w", err) + } + return printDiff(mdiff, format) +} + +// calculateDiffFromCASDirectory takes the cas bucket, and the from/to manifest paths to calculate a +// diff. +func calculateDiffFromCASDirectory( + ctx context.Context, + casBucket storage.ReadBucket, + fromManifestPath string, + toManifestPath string, +) (*manifestDiff, error) { + if fromManifestPath == toManifestPath { + return newManifestDiff(), nil + } + fromManifest, err := readManifest(ctx, casBucket, fromManifestPath) + if err != nil { + return nil, fmt.Errorf("read manifest from: %w", err) + } + toManifest, err := readManifest(ctx, casBucket, toManifestPath) + if err != nil { + return nil, fmt.Errorf("read manifest to: %w", err) + } + return buildManifestDiff(ctx, fromManifest, toManifest, casBucket) +} + +func readManifest(ctx context.Context, bucket storage.ReadBucket, manifestPath string) (bufcas.Manifest, error) { + data, err := storage.ReadPath(ctx, bucket, manifestPath) + if err != nil { + return nil, fmt.Errorf("read path: %w", err) + } + m, err := bufcas.ParseManifest(string(data)) + if err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + return m, nil +} + +func printDiff(mdiff *manifestDiff, format format) error { + switch format { + case formatText: + mdiff.printText() + case formatMarkdown: + mdiff.printMarkdown() + default: + return fmt.Errorf("format %s not supported", format.String()) + } + return nil +} diff --git a/cmd/casdiff/main.go b/cmd/casdiff/main.go new file mode 100644 index 00000000..5e75f9b7 --- /dev/null +++ b/cmd/casdiff/main.go @@ -0,0 +1,29 @@ +// Copyright 2021-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/bufbuild/buf/private/pkg/app/appcmd" +) + +const ( + rootCmdName = "casdiff" +) + +func main() { + appcmd.Main(context.Background(), newCommand(rootCmdName)) +} diff --git a/cmd/casdiff/manifest_diff.go b/cmd/casdiff/manifest_diff.go new file mode 100644 index 00000000..50b44a25 --- /dev/null +++ b/cmd/casdiff/manifest_diff.go @@ -0,0 +1,275 @@ +// Copyright 2021-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/hex" + "fmt" + "os" + + "github.com/bufbuild/buf/private/bufpkg/bufcas" + "github.com/bufbuild/buf/private/pkg/diff" + "github.com/bufbuild/buf/private/pkg/slicesext" + "github.com/bufbuild/buf/private/pkg/storage" +) + +type manifestDiff struct { + pathsAdded map[string]bufcas.FileNode + pathsRenamed map[string]fileDiff + pathsRemoved map[string]bufcas.FileNode + pathsChangedContent map[string]fileDiff +} + +type fileDiff struct { + from bufcas.FileNode + to bufcas.FileNode + diff string +} + +func newManifestDiff() *manifestDiff { + return &manifestDiff{ + pathsAdded: make(map[string]bufcas.FileNode), + pathsRenamed: make(map[string]fileDiff), + pathsRemoved: make(map[string]bufcas.FileNode), + pathsChangedContent: make(map[string]fileDiff), + } +} + +func buildManifestDiff( + ctx context.Context, + from bufcas.Manifest, + to bufcas.Manifest, //nolint:varnamelen // from/to used symmetrically + bucket storage.ReadBucket, +) (*manifestDiff, error) { + var ( + diff = newManifestDiff() + digestToAddedPaths = make(map[string][]string) + digestToRemovedPaths = make(map[string][]string) + ) + // removed and changed + for _, fromNode := range from.FileNodes() { + path := fromNode.Path() + toNode := to.GetFileNode(path) + if toNode == nil { + diff.pathsRemoved[path] = fromNode + digestToRemovedPaths[fromNode.Digest().String()] = append(digestToRemovedPaths[fromNode.Digest().String()], path) + continue + } + if bufcas.DigestEqual(fromNode.Digest(), toNode.Digest()) { + continue // no changes + } + diffString, err := calculateFileNodeDiff(ctx, fromNode, toNode, bucket) + if err != nil { + return nil, fmt.Errorf("calculate file node diff: %w", err) + } + diff.pathsChangedContent[path] = fileDiff{ + from: fromNode, + to: toNode, + diff: diffString, + } + } + // added + for _, toNode := range to.FileNodes() { + path := toNode.Path() + if from.GetFileNode(path) == nil { + diff.pathsAdded[path] = toNode + digestToAddedPaths[toNode.Digest().String()] = append(digestToAddedPaths[toNode.Digest().String()], path) + } + } + // renamed: defined as digests present both in pathsRemoved and pathsAdded but under different + // paths + // + // We'll keep track of the paths that we matched as renames to later remove them from the + // added/removed maps + var ( + matchedRemovedPaths []string + matchedAddedPaths []string + ) + for digest, removedPaths := range digestToRemovedPaths { + // removedPaths and addedPaths should be lists with no items in common since they're recorded + // only for added and removed nodes exclusively + addedPaths, digestHasAddedPaths := digestToAddedPaths[digest] + if !digestHasAddedPaths { + continue + } + for _, removedPath := range removedPaths { + if len(addedPaths) == 0 { + continue + } + // both lists are sorted by path, we can always take out the first one to match + var addedPath string + addedPaths, addedPath = removeSliceItem(addedPaths, 0) + matchedRemovedPaths = append(matchedRemovedPaths, removedPath) + matchedAddedPaths = append(matchedAddedPaths, addedPath) + diff.pathsRenamed[removedPath] = fileDiff{ + from: from.GetFileNode(removedPath), + to: to.GetFileNode(addedPath), + } + } + } + // delete the matches + for _, matchedRemovedPath := range matchedRemovedPaths { + delete(diff.pathsRemoved, matchedRemovedPath) + } + for _, matchedAddedPath := range matchedAddedPaths { + delete(diff.pathsAdded, matchedAddedPath) + } + return diff, nil +} + +func (d *manifestDiff) printText() { + os.Stdout.WriteString(fmt.Sprintf( + "%d files changed: %d removed, %d renamed, %d added, %d changed content\n", + len(d.pathsRemoved)+len(d.pathsRenamed)+len(d.pathsAdded)+len(d.pathsChangedContent), + len(d.pathsRemoved), + len(d.pathsRenamed), + len(d.pathsAdded), + len(d.pathsChangedContent), + )) + if len(d.pathsRemoved) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("Files removed:\n\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsRemoved) + for _, path := range sortedPaths { + os.Stdout.WriteString("- " + d.pathsRemoved[path].String() + "\n") + } + } + if len(d.pathsRenamed) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("Files renamed:\n\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsRenamed) + for _, path := range sortedPaths { + os.Stdout.WriteString("- " + d.pathsRenamed[path].from.String() + "\n") + os.Stdout.WriteString("+ " + d.pathsRenamed[path].to.String() + "\n") + } + } + if len(d.pathsAdded) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("Files added:\n\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsAdded) + for _, path := range sortedPaths { + os.Stdout.WriteString("+ " + d.pathsAdded[path].String() + "\n") + } + } + if len(d.pathsChangedContent) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("Files changed content:\n\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsChangedContent) + for _, path := range sortedPaths { + fnDiff := d.pathsChangedContent[path] + os.Stdout.WriteString(fnDiff.diff + "\n") + } + } +} + +func (d *manifestDiff) printMarkdown() { + os.Stdout.WriteString(fmt.Sprintf( + "> _%d files changed: %d removed, %d renamed, %d added, %d changed content_\n", + len(d.pathsRemoved)+len(d.pathsRenamed)+len(d.pathsAdded)+len(d.pathsChangedContent), + len(d.pathsRemoved), + len(d.pathsRenamed), + len(d.pathsAdded), + len(d.pathsChangedContent), + )) + if len(d.pathsRemoved) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("# Files removed:\n\n") + os.Stdout.WriteString("```diff\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsRemoved) + for _, path := range sortedPaths { + os.Stdout.WriteString("- " + d.pathsRemoved[path].String() + "\n") + } + os.Stdout.WriteString("```\n") + } + if len(d.pathsRenamed) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("# Files renamed:\n\n") + os.Stdout.WriteString("```diff\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsRenamed) + for _, path := range sortedPaths { + os.Stdout.WriteString("- " + d.pathsRenamed[path].from.String() + "\n") + os.Stdout.WriteString("+ " + d.pathsRenamed[path].to.String() + "\n") + } + os.Stdout.WriteString("```\n") + } + if len(d.pathsAdded) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("# Files added:\n\n") + os.Stdout.WriteString("```diff\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsAdded) + for _, path := range sortedPaths { + os.Stdout.WriteString("+ " + d.pathsAdded[path].String() + "\n") + } + os.Stdout.WriteString("```\n") + } + if len(d.pathsChangedContent) > 0 { + os.Stdout.WriteString("\n") + os.Stdout.WriteString("# Files changed content:\n\n") + sortedPaths := slicesext.MapKeysToSortedSlice(d.pathsChangedContent) + for _, path := range sortedPaths { + fdiff := d.pathsChangedContent[path] + // the path we use here can be from/to, is the same, what changed was the content. + os.Stdout.WriteString("## `" + fdiff.from.Path() + "`:\n") + os.Stdout.WriteString( + "```diff\n" + + fdiff.diff + "\n" + + "```\n", + ) + } + } +} + +func calculateFileNodeDiff( + ctx context.Context, + from bufcas.FileNode, + to bufcas.FileNode, //nolint:varnamelen // from/to used symmetrically + bucket storage.ReadBucket, +) (string, error) { + if from.Path() == to.Path() && bufcas.DigestEqual(from.Digest(), to.Digest()) { + return "", nil + } + var ( + fromFilePath = hex.EncodeToString(from.Digest().Value()) + toFilePath = hex.EncodeToString(to.Digest().Value()) + ) + fromData, err := storage.ReadPath(ctx, bucket, fromFilePath) + if err != nil { + return "", fmt.Errorf("read path from: %w", err) + } + toData, err := storage.ReadPath(ctx, bucket, toFilePath) + if err != nil { + return "", fmt.Errorf("read path to: %w", err) + } + diffData, err := diff.Diff( + ctx, + fromData, + toData, + from.String(), + to.String(), + diff.DiffWithSuppressCommands(), + diff.DiffWithSuppressTimestamps(), + ) + if err != nil { + return "", fmt.Errorf("diff: %w", err) + } + return string(diffData), nil +} + +// removeSliceItem returns the slice with the item in index i removed, and the removed item. +func removeSliceItem[T any](s []T, i int) ([]T, T) { + item := s[i] + return append(s[:i], s[i+1:]...), item +} diff --git a/cmd/casdiff/manifest_diff_test.go b/cmd/casdiff/manifest_diff_test.go new file mode 100644 index 00000000..73f0b73c --- /dev/null +++ b/cmd/casdiff/manifest_diff_test.go @@ -0,0 +1,136 @@ +// Copyright 2021-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/bufbuild/buf/private/bufpkg/bufcas" + "github.com/bufbuild/buf/private/pkg/storage" + "github.com/bufbuild/buf/private/pkg/storage/storagemem" + "github.com/bufbuild/buf/private/pkg/storage/storageos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestDiff(t *testing.T) { + t.Parallel() + ctx := context.Background() + casBucket, mFrom, mTo := prepareDiffCASBucket(ctx, t) + mdiff, err := buildManifestDiff(ctx, mFrom, mTo, casBucket) + require.NoError(t, err) + require.NotNil(t, mdiff) + t.Run("removed", func(t *testing.T) { + t.Parallel() + expectedRemovedPaths := map[string]struct{}{ + "to_remove.txt": {}, + } + assert.Len(t, mdiff.pathsRemoved, len(expectedRemovedPaths)) + for expectedRemovedPath := range expectedRemovedPaths { + expected := mFrom.GetFileNode(expectedRemovedPath) + require.NotNil(t, expected) + actual, present := mdiff.pathsRemoved[expectedRemovedPath] + require.True(t, present) + assert.Equal(t, expectedRemovedPath, actual.Path()) + assert.True(t, bufcas.DigestEqual(expected.Digest(), actual.Digest())) + } + }) + t.Run("renamed", func(t *testing.T) { + t.Parallel() + // two directories in testdata are renamed, and the content in all files of each group is the + // same, so this test actually makes sure that all of them are matched (even with the same + // digest), and that the order is trying to be kept. + expectedRenamedPaths := map[string]string{ + "to_rename_bar/1.txt": "renamed_bar/1.txt", + "to_rename_bar/2.txt": "renamed_bar/2.txt", + "to_rename_bar/3.txt": "renamed_bar/3.txt", + "to_rename_foo/1.txt": "renamed_foo/1.txt", + "to_rename_foo/2.txt": "renamed_foo/2.txt", + "to_rename_foo/3.txt": "renamed_foo/3.txt", + } + assert.Len(t, mdiff.pathsRenamed, len(expectedRenamedPaths)) + for fromPath, toPath := range expectedRenamedPaths { + expected := mFrom.GetFileNode(fromPath) + require.NotNil(t, expected) + actual, present := mdiff.pathsRenamed[fromPath] + require.True(t, present) + assert.Equal(t, fromPath, actual.from.Path()) + assert.Equal(t, toPath, actual.to.Path()) + assert.True(t, bufcas.DigestEqual(expected.Digest(), actual.from.Digest())) + assert.True(t, bufcas.DigestEqual(actual.from.Digest(), actual.to.Digest())) + assert.Empty(t, actual.diff) + } + }) + t.Run("added", func(t *testing.T) { + t.Parallel() + expectedAddedPaths := map[string]struct{}{ + "added.txt": {}, + } + assert.Len(t, mdiff.pathsAdded, len(expectedAddedPaths)) + for expectedAddedPath := range expectedAddedPaths { + expected := mTo.GetFileNode(expectedAddedPath) + require.NotNil(t, expected) + actual, present := mdiff.pathsAdded[expectedAddedPath] + require.True(t, present) + assert.Equal(t, expectedAddedPath, actual.Path()) + assert.True(t, bufcas.DigestEqual(expected.Digest(), actual.Digest())) + } + }) + t.Run("changed_content", func(t *testing.T) { + t.Parallel() + expectedChangedContentPaths := map[string]struct{}{ + "changes.txt": {}, + } + assert.Len(t, mdiff.pathsChangedContent, len(expectedChangedContentPaths)) + for expectedChangedContentPath := range expectedChangedContentPaths { + expected := mTo.GetFileNode(expectedChangedContentPath) + require.NotNil(t, expected) + actual, present := mdiff.pathsChangedContent[expectedChangedContentPath] + require.True(t, present) + assert.Equal(t, actual.from.Path(), actual.to.Path()) + assert.False(t, bufcas.DigestEqual(actual.from.Digest(), actual.to.Digest())) + assert.NotEmpty(t, actual.diff) + } + }) +} + +func prepareDiffCASBucket(ctx context.Context, t *testing.T) ( + storage.ReadBucket, + bufcas.Manifest, + bufcas.Manifest, +) { + t.Helper() + casBucket := storagemem.NewReadWriteBucket() + casWrite := func(dirpath string) bufcas.Manifest { + testFiles, err := storageos.NewProvider().NewReadWriteBucket("testdata/manifest_diff/" + dirpath) + require.NoError(t, err) + fileSet, err := bufcas.NewFileSetForBucket(ctx, testFiles) + require.NoError(t, err) + mBlob, err := bufcas.ManifestToBlob(fileSet.Manifest()) + require.NoError(t, err) + blobsToPut := append(fileSet.BlobSet().Blobs(), mBlob) + for _, blob := range blobsToPut { + require.NoError(t, storage.PutPath(ctx, casBucket, hex.EncodeToString(blob.Digest().Value()), blob.Content())) + } + return fileSet.Manifest() + } + var ( + mFrom = casWrite("from") + mTo = casWrite("to") + ) + return casBucket, mFrom, mTo +} diff --git a/cmd/casdiff/testdata/manifest_diff/README.md b/cmd/casdiff/testdata/manifest_diff/README.md new file mode 100644 index 00000000..015cb607 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/README.md @@ -0,0 +1,12 @@ +## Manifest Diff test structure + +This directory is parsed to a single CAS bucket everytime the test runs, as long as it follows the +following structure: + +``` +. +├── from +│ └── +└── to + └── +``` diff --git a/cmd/casdiff/testdata/manifest_diff/from/changes.txt b/cmd/casdiff/testdata/manifest_diff/from/changes.txt new file mode 100644 index 00000000..b4374a2d --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/changes.txt @@ -0,0 +1 @@ +content to change diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_remove.txt b/cmd/casdiff/testdata/manifest_diff/from/to_remove.txt new file mode 100644 index 00000000..227ed66d --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_remove.txt @@ -0,0 +1 @@ +content to remove diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/1.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/1.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/1.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/2.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/2.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/2.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/3.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/3.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_bar/3.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/1.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/1.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/1.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/2.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/2.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/2.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/3.txt b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/3.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/from/to_rename_foo/3.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/added.txt b/cmd/casdiff/testdata/manifest_diff/to/added.txt new file mode 100644 index 00000000..f50e4e9c --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/added.txt @@ -0,0 +1 @@ +content added diff --git a/cmd/casdiff/testdata/manifest_diff/to/changes.txt b/cmd/casdiff/testdata/manifest_diff/to/changes.txt new file mode 100644 index 00000000..f7f6607f --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/changes.txt @@ -0,0 +1 @@ +content changed diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/1.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/1.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/1.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/2.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/2.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/2.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/3.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/3.txt new file mode 100644 index 00000000..ada65e61 --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_bar/3.txt @@ -0,0 +1 @@ +content bar to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/1.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/1.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/1.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/2.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/2.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/2.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/3.txt b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/3.txt new file mode 100644 index 00000000..479d73fd --- /dev/null +++ b/cmd/casdiff/testdata/manifest_diff/to/renamed_foo/3.txt @@ -0,0 +1 @@ +content foo to rename diff --git a/go.mod b/go.mod index 87c758a1..b3fe2eec 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/go-github/v64 v64.0.0 github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 golang.org/x/mod v0.22.0 @@ -21,12 +22,21 @@ require ( cel.dev/expr v0.19.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/bufbuild/protocompile v0.14.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/felixge/fgprof v0.9.5 // indirect github.com/google/cel-go v0.23.2 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/spf13/cobra v1.8.1 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/sync v0.10.0 // indirect diff --git a/go.sum b/go.sum index 98ad82aa..44c6d678 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,18 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/bufbuild/protovalidate-go v0.9.1 h1:cdrIA33994yCcJyEIZRL36ZGTe9UDM/WHs5MBHEimiE= github.com/bufbuild/protovalidate-go v0.9.1/go.mod h1:5jptBxfvlY51RhX32zR6875JfPBRXUsQjyZjm/NqkLQ= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -19,6 +31,12 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= +github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -28,6 +46,10 @@ github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKby github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -36,18 +58,34 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -58,8 +96,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= @@ -70,6 +114,9 @@ golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=