Skip to content

Commit 0fb1fab

Browse files
committed
flux diff artifact: Compute a unified diff internally by default.
Signed-off-by: Florian Forster <[email protected]>
1 parent b5f77cd commit 0fb1fab

File tree

3 files changed

+163
-44
lines changed

3 files changed

+163
-44
lines changed

cmd/flux/diff_artifact.go

+133-36
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,22 @@ import (
2222
"errors"
2323
"fmt"
2424
"io"
25+
"io/fs"
2526
"os"
2627
"os/exec"
2728
"path/filepath"
2829
"sort"
2930
"strings"
3031

32+
"bitbucket.org/creachadair/stringset"
3133
oci "github.com/fluxcd/pkg/oci/client"
3234
"github.com/fluxcd/pkg/tar"
3335
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
3436
"github.com/gonvenience/ytbx"
3537
"github.com/google/shlex"
38+
"github.com/hexops/gotextdiff"
39+
"github.com/hexops/gotextdiff/myers"
40+
"github.com/hexops/gotextdiff/span"
3641
"github.com/homeport/dyff/pkg/dyff"
3742
"github.com/spf13/cobra"
3843
"golang.org/x/exp/maps"
@@ -61,29 +66,33 @@ type diffArtifactFlags struct {
6166
provider flags.SourceOCIProvider
6267
ignorePaths []string
6368
brief bool
64-
differ *semanticDiffFlag
69+
differ *differFlag
6570
}
6671

6772
var diffArtifactArgs = newDiffArtifactArgs()
6873

6974
func newDiffArtifactArgs() diffArtifactFlags {
70-
defaultDiffer := mustExternalDiff()
71-
7275
return diffArtifactFlags{
7376
provider: flags.SourceOCIProvider(sourcev1.GenericOCIProvider),
7477

75-
differ: &semanticDiffFlag{
78+
differ: &differFlag{
7679
options: map[string]differ{
77-
"yaml": dyffBuiltin{
80+
"dyff": dyffBuiltin{
7881
opts: []dyff.CompareOption{
7982
dyff.IgnoreOrderChanges(false),
8083
dyff.KubernetesEntityDetection(true),
8184
},
8285
},
83-
"false": defaultDiffer,
86+
"external": externalDiff{},
87+
"unified": unifiedDiff{},
88+
},
89+
description: map[string]string{
90+
"dyff": `semantic diff for YAML inputs`,
91+
"external": `execute the command in the "` + externalDiffVar + `" environment variable`,
92+
"unified": "generic unified diff for arbitrary text inputs",
8493
},
85-
value: "false",
86-
differ: defaultDiffer,
94+
value: "unified",
95+
differ: unifiedDiff{},
8796
},
8897
}
8998
}
@@ -94,7 +103,7 @@ func init() {
94103
diffArtifactCmd.Flags().Var(&diffArtifactArgs.provider, "provider", sourceOCIRepositoryArgs.provider.Description())
95104
diffArtifactCmd.Flags().StringSliceVar(&diffArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
96105
diffArtifactCmd.Flags().BoolVarP(&diffArtifactArgs.brief, "brief", "q", false, "just print a line when the resources differ; does not output a list of changes")
97-
diffArtifactCmd.Flags().Var(diffArtifactArgs.differ, "semantic-diff", "use a semantic diffing algorithm")
106+
diffArtifactCmd.Flags().Var(diffArtifactArgs.differ, "differ", diffArtifactArgs.differ.usage())
98107

99108
diffCmd.AddCommand(diffArtifactCmd)
100109
}
@@ -297,53 +306,125 @@ type differ interface {
297306
Diff(ctx context.Context, from, to string) (string, error)
298307
}
299308

300-
// externalDiffCommand implements the differ interface using an external diff command.
301-
type externalDiffCommand struct {
302-
name string
303-
flags []string
309+
type unifiedDiff struct{}
310+
311+
func (d unifiedDiff) Diff(_ context.Context, fromDir, toDir string) (string, error) {
312+
fromFiles, err := filesInDir(fromDir)
313+
if err != nil {
314+
return "", err
315+
}
316+
317+
toFiles, err := filesInDir(toDir)
318+
if err != nil {
319+
return "", err
320+
}
321+
322+
allFiles := fromFiles.Union(toFiles)
323+
324+
var sb strings.Builder
325+
326+
for _, relPath := range allFiles.Elements() {
327+
diff, err := d.diffFiles(fromDir, toDir, relPath)
328+
if err != nil {
329+
return "", err
330+
}
331+
332+
fmt.Fprint(&sb, diff)
333+
}
334+
335+
return sb.String(), nil
336+
}
337+
338+
func (d unifiedDiff) diffFiles(fromDir, toDir, relPath string) (string, error) {
339+
fromPath := filepath.Join(fromDir, relPath)
340+
fromData, err := d.readFile(fromPath)
341+
if err != nil {
342+
return "", fmt.Errorf("readFile(%q): %w", fromPath, err)
343+
}
344+
345+
toPath := filepath.Join(toDir, relPath)
346+
toData, err := d.readFile(toPath)
347+
if err != nil {
348+
return "", fmt.Errorf("readFile(%q): %w", toPath, err)
349+
}
350+
351+
edits := myers.ComputeEdits(span.URIFromPath(fromPath), string(fromData), string(toData))
352+
return fmt.Sprint(gotextdiff.ToUnified(fromPath, toPath, string(fromData), edits)), nil
304353
}
305354

355+
func (d unifiedDiff) readFile(path string) ([]byte, error) {
356+
file, err := os.Open(path)
357+
if err != nil {
358+
return nil, fmt.Errorf("os.Open(%q): %w", path, err)
359+
}
360+
defer file.Close()
361+
362+
return io.ReadAll(file)
363+
}
364+
365+
func filesInDir(root string) (stringset.Set, error) {
366+
var files stringset.Set
367+
368+
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
369+
if err != nil {
370+
return err
371+
}
372+
373+
if !d.Type().IsRegular() {
374+
return nil
375+
}
376+
377+
relPath, err := filepath.Rel(root, path)
378+
if err != nil {
379+
return fmt.Errorf("filepath.Rel(%q, %q): %w", root, path, err)
380+
}
381+
382+
files.Add(relPath)
383+
return nil
384+
})
385+
if err != nil {
386+
return nil, err
387+
}
388+
389+
return files, err
390+
}
391+
392+
// externalDiff implements the differ interface using an external diff command.
393+
type externalDiff struct{}
394+
306395
// externalDiffVar is the environment variable users can use to overwrite the external diff command.
307396
const externalDiffVar = "FLUX_EXTERNAL_DIFF"
308397

309-
// mustExternalDiff initializes an externalDiffCommand using the externalDiffVar environment variable.
310-
func mustExternalDiff() externalDiffCommand {
398+
func (externalDiff) Diff(ctx context.Context, fromDir, toDir string) (string, error) {
311399
cmdline := os.Getenv(externalDiffVar)
312400
if cmdline == "" {
313-
cmdline = "diff -ur"
401+
return "", fmt.Errorf("the required %q environment variable is unset", externalDiffVar)
314402
}
315403

316404
args, err := shlex.Split(cmdline)
317405
if err != nil {
318-
panic(fmt.Sprintf("shlex.Split(%q): %v", cmdline, err))
406+
return "", fmt.Errorf("shlex.Split(%q): %w", cmdline, err)
319407
}
320408

321-
return externalDiffCommand{
322-
name: args[0],
323-
flags: args[1:],
324-
}
325-
}
409+
var executable string
410+
executable, args = args[0], args[1:]
326411

327-
func (c externalDiffCommand) Diff(ctx context.Context, fromDir, toDir string) (string, error) {
328-
var args []string
329-
330-
args = append(args, c.flags...)
331412
args = append(args, fromDir, toDir)
332413

333-
cmd := exec.CommandContext(ctx, c.name, args...)
414+
cmd := exec.CommandContext(ctx, executable, args...)
334415

335416
var stdout bytes.Buffer
336417

337418
cmd.Stdout = &stdout
338419
cmd.Stderr = os.Stderr
339420

340-
err := cmd.Run()
421+
err = cmd.Run()
341422

342423
var exitErr *exec.ExitError
343424
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
344425
// exit code 1 only means there was a difference => ignore
345426
} else if err != nil {
346-
return "", fmt.Errorf("executing %q: %w", c.name, err)
427+
return "", fmt.Errorf("executing %q: %w", executable, err)
347428
}
348429

349430
return stdout.String(), nil
@@ -383,14 +464,15 @@ func (d dyffBuiltin) Diff(ctx context.Context, fromDir, toDir string) (string, e
383464
return buf.String(), nil
384465
}
385466

386-
// semanticDiffFlag implements pflag.Value for choosing a semantic diffing algorithm.
387-
type semanticDiffFlag struct {
388-
options map[string]differ
389-
value string
467+
// differFlag implements pflag.Value for choosing a diffing implementation.
468+
type differFlag struct {
469+
options map[string]differ
470+
description map[string]string
471+
value string
390472
differ
391473
}
392474

393-
func (f *semanticDiffFlag) Set(s string) error {
475+
func (f *differFlag) Set(s string) error {
394476
d, ok := f.options[s]
395477
if !ok {
396478
return fmt.Errorf("invalid value: %q", s)
@@ -402,14 +484,29 @@ func (f *semanticDiffFlag) Set(s string) error {
402484
return nil
403485
}
404486

405-
func (f *semanticDiffFlag) String() string {
487+
func (f *differFlag) String() string {
406488
return f.value
407489
}
408490

409-
func (f *semanticDiffFlag) Type() string {
491+
func (f *differFlag) Type() string {
410492
keys := maps.Keys(f.options)
411493

412494
sort.Strings(keys)
413495

414496
return strings.Join(keys, "|")
415497
}
498+
499+
func (f *differFlag) usage() string {
500+
var b strings.Builder
501+
fmt.Fprint(&b, "how the diff is generated:")
502+
503+
keys := maps.Keys(f.options)
504+
505+
sort.Strings(keys)
506+
507+
for _, key := range keys {
508+
fmt.Fprintf(&b, "\n %q: %s", key, f.description[key])
509+
}
510+
511+
return b.String()
512+
}

go.mod

+10-8
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ require (
5050
github.com/spf13/cobra v1.8.0
5151
github.com/spf13/pflag v1.0.5
5252
github.com/theckman/yacspin v0.13.12
53-
golang.org/x/crypto v0.22.0
53+
golang.org/x/crypto v0.26.0
5454
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f
55-
golang.org/x/term v0.19.0
56-
golang.org/x/text v0.14.0
55+
golang.org/x/term v0.23.0
56+
golang.org/x/text v0.17.0
5757
k8s.io/api v0.30.0
5858
k8s.io/apiextensions-apiserver v0.30.0
5959
k8s.io/apimachinery v0.30.0
@@ -67,6 +67,7 @@ require (
6767
)
6868

6969
require (
70+
bitbucket.org/creachadair/stringset v0.0.14
7071
code.gitea.io/sdk/gitea v0.17.1 // indirect
7172
dario.cat/mergo v1.0.0 // indirect
7273
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 // indirect
@@ -158,6 +159,7 @@ require (
158159
github.com/hashicorp/go-version v1.6.0 // indirect
159160
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
160161
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
162+
github.com/hexops/gotextdiff v1.0.3
161163
github.com/imdario/mergo v0.3.16 // indirect
162164
github.com/inconshreveable/mousetrap v1.1.0 // indirect
163165
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -227,13 +229,13 @@ require (
227229
go.opentelemetry.io/otel/trace v1.21.0 // indirect
228230
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
229231
go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect
230-
golang.org/x/mod v0.17.0 // indirect
231-
golang.org/x/net v0.24.0 // indirect
232+
golang.org/x/mod v0.20.0 // indirect
233+
golang.org/x/net v0.28.0 // indirect
232234
golang.org/x/oauth2 v0.19.0 // indirect
233-
golang.org/x/sync v0.7.0 // indirect
234-
golang.org/x/sys v0.19.0 // indirect
235+
golang.org/x/sync v0.8.0 // indirect
236+
golang.org/x/sys v0.23.0 // indirect
235237
golang.org/x/time v0.5.0 // indirect
236-
golang.org/x/tools v0.20.0 // indirect
238+
golang.org/x/tools v0.24.0 // indirect
237239
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
238240
google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect
239241
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect

0 commit comments

Comments
 (0)