Skip to content

Commit c43f48c

Browse files
liunan-msreubeno
andcommitted
feat: add azldev comp diff-sources (#508)
Co-authored-by: reuben olinsky <reubeno@users.noreply.github.com>
1 parent 57fa462 commit c43f48c

File tree

16 files changed

+2221
-1
lines changed

16 files changed

+2221
-1
lines changed

docs/user/reference/cli/azldev_component.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user/reference/cli/azldev_component_diff-sources.md

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
github.com/nxadm/tail v1.4.11
3939
github.com/opencontainers/selinux v1.13.1
4040
github.com/pelletier/go-toml/v2 v2.2.4
41+
github.com/pmezard/go-difflib v1.0.0
4142
github.com/samber/lo v1.52.0
4243
github.com/samber/slog-multi v1.7.1
4344
github.com/shirou/gopsutil v3.21.11+incompatible
@@ -118,7 +119,6 @@ require (
118119
github.com/opencontainers/go-digest v1.0.0 // indirect
119120
github.com/opencontainers/image-spec v1.1.1 // indirect
120121
github.com/pkg/errors v0.9.1 // indirect
121-
github.com/pmezard/go-difflib v1.0.0 // indirect
122122
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
123123
github.com/rivo/uniseg v0.4.7 // indirect
124124
github.com/rogpeppe/go-internal v1.14.1 // indirect

internal/app/azldev/cmds/component/component.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ components defined in the project configuration.`,
2525
app.AddTopLevelCommand(cmd)
2626
addOnAppInit(app, cmd)
2727
buildOnAppInit(app, cmd)
28+
diffSourcesOnAppInit(app, cmd)
2829
listOnAppInit(app, cmd)
2930
prepareOnAppInit(app, cmd)
3031
queryOnAppInit(app, cmd)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"os"
11+
12+
"github.com/mattn/go-isatty"
13+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
15+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources"
16+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/workdir"
17+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders"
18+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/dirdiff"
19+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
20+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
21+
"github.com/spf13/cobra"
22+
)
23+
24+
// DiffSourcesOptions holds the options for the diff-sources command.
25+
type DiffSourcesOptions struct {
26+
ComponentFilter components.ComponentFilter
27+
28+
OutputFile string
29+
}
30+
31+
func diffSourcesOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
32+
parentCmd.AddCommand(NewDiffSourcesCmd())
33+
}
34+
35+
// NewDiffSourcesCmd constructs a [cobra.Command] for the "component diff-sources" CLI subcommand.
36+
func NewDiffSourcesCmd() *cobra.Command {
37+
var options DiffSourcesOptions
38+
39+
cmd := &cobra.Command{
40+
Use: "diff-sources",
41+
Short: "Show the diff that overlays apply to a component's sources",
42+
Long: `Computes a unified diff showing the changes that overlays apply to a
43+
component's sources. Fetches the sources once, copies them, then applies
44+
overlays to the copy and displays the resulting diff between the two trees.`,
45+
RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) {
46+
options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...)
47+
48+
return DiffComponentSources(env, &options)
49+
}),
50+
ValidArgsFunction: components.GenerateComponentNameCompletions,
51+
}
52+
53+
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
54+
55+
cmd.Flags().StringVar(&options.OutputFile, "output-file", "",
56+
"write the diff output to a file instead of stdout")
57+
58+
azldev.ExportAsMCPTool(cmd)
59+
60+
return cmd
61+
}
62+
63+
// DiffComponentSources computes the diff between original and overlaid sources for a single component.
64+
// When color is enabled and the output format is not JSON, the returned value is a pre-colorized
65+
// string. Otherwise it is [*dirdiff.DiffResult] for structured output.
66+
func DiffComponentSources(env *azldev.Env, options *DiffSourcesOptions) (interface{}, error) {
67+
resolver := components.NewResolver(env)
68+
69+
comps, err := resolver.FindComponents(&options.ComponentFilter)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to resolve components:\n%w", err)
72+
}
73+
74+
if comps.Len() == 0 {
75+
return nil, errors.New("no components were selected; " +
76+
"please use command-line options to indicate which components you would like to diff",
77+
)
78+
}
79+
80+
if comps.Len() != 1 {
81+
return nil, fmt.Errorf("expected exactly one component, got %d", comps.Len())
82+
}
83+
84+
component := comps.Components()[0]
85+
86+
event := env.StartEvent("Diffing sources", "component", component.GetName())
87+
defer event.End()
88+
89+
sourceManager, err := sourceproviders.NewSourceManager(env)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to create source manager:\n%w", err)
92+
}
93+
94+
preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env)
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to create source preparer:\n%w", err)
97+
}
98+
99+
// Create a per-component work directory for the diff operation's temp files.
100+
workDirFactory, err := workdir.NewFactory(env.FS(), env.WorkDir(), env.ConstructionTime())
101+
if err != nil {
102+
return nil, fmt.Errorf("failed to create work dir factory:\n%w", err)
103+
}
104+
105+
baseDir, err := workDirFactory.Create(component.GetName(), "diff-sources")
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to create work dir for component %#q:\n%w", component.GetName(), err)
108+
}
109+
110+
result, err := preparer.DiffSources(env, component, baseDir)
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to diff sources for component %#q:\n%w", component.GetName(), err)
113+
}
114+
115+
// If an output file was specified, write the diff there (JSON or plain text depending on format).
116+
if options.OutputFile != "" {
117+
if err := writeDiffOutput(env, options.OutputFile, result); err != nil {
118+
return nil, err
119+
}
120+
121+
env.Event("Diff written to file", "file", options.OutputFile)
122+
123+
return "", nil
124+
}
125+
126+
// For JSON output, return the structured form. [FileDiff.MarshalJSON] parses
127+
// the raw unified diff into per-line change records automatically.
128+
if env.DefaultReportFormat() == azldev.ReportFormatJSON {
129+
return result, nil
130+
}
131+
// For non-JSON text formats, colorize the diff output when color is enabled.
132+
if shouldColorize(env) {
133+
return result.ColorString(), nil
134+
}
135+
136+
return result.String(), nil
137+
}
138+
139+
// writeDiffOutput writes the diff result to the specified output file in the appropriate format.
140+
func writeDiffOutput(env *azldev.Env, outputFile string, result *dirdiff.DiffResult) error {
141+
var fileContent []byte
142+
143+
if env.DefaultReportFormat() == azldev.ReportFormatJSON {
144+
jsonBytes, jsonErr := json.MarshalIndent(result, "", " ")
145+
if jsonErr != nil {
146+
return fmt.Errorf("failed to marshal diff to JSON:\n%w", jsonErr)
147+
}
148+
149+
jsonBytes = append(jsonBytes, '\n')
150+
fileContent = jsonBytes
151+
} else {
152+
fileContent = []byte(result.String())
153+
}
154+
155+
if writeErr := fileutils.WriteFile(env.FS(), outputFile, fileContent, fileperms.PublicFile); writeErr != nil {
156+
return fmt.Errorf("failed to write diff to %#q:\n%w", outputFile, writeErr)
157+
}
158+
159+
return nil
160+
}
161+
162+
// shouldColorize determines whether the current session should produce colorized output,
163+
// based on the environment's [azldev.ColorMode] and whether stdout is a terminal.
164+
func shouldColorize(env *azldev.Env) bool {
165+
switch env.ColorMode() {
166+
case azldev.ColorModeAlways:
167+
return true
168+
case azldev.ColorModeNever:
169+
return false
170+
case azldev.ColorModeAuto:
171+
return isatty.IsTerminal(os.Stdout.Fd())
172+
}
173+
174+
return false
175+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component_test
5+
6+
import (
7+
"testing"
8+
9+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component"
10+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestNewDiffSourcesCmd(t *testing.T) {
16+
cmd := component.NewDiffSourcesCmd()
17+
require.NotNil(t, cmd)
18+
assert.Equal(t, "diff-sources", cmd.Use)
19+
20+
outputFileFlag := cmd.Flags().Lookup("output-file")
21+
require.NotNil(t, outputFileFlag, "--output-file flag should be registered")
22+
assert.Empty(t, outputFileFlag.DefValue)
23+
}
24+
25+
func TestDiffSourcesCmd_NoMatch(t *testing.T) {
26+
testEnv := testutils.NewTestEnv(t)
27+
28+
cmd := component.NewDiffSourcesCmd()
29+
cmd.SetArgs([]string{"nonexistent-component"})
30+
31+
err := cmd.ExecuteContext(testEnv.Env)
32+
33+
// We expect an error because we haven't set up any components.
34+
require.Error(t, err)
35+
}

internal/app/azldev/core/sources/sourceprep.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
1717
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
1818
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders"
19+
"github.com/microsoft/azure-linux-dev-tools/internal/utils/dirdiff"
1920
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
2021
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
2122
"github.com/samber/lo"
@@ -41,6 +42,11 @@ type SourcePreparer interface {
4142
// relied on to be present implicitly within the build root, or expressed via BuildRequires or DynamicBuildRequires
4243
// in the component's spec file and any defaults from the macros used to interpret the spec file.
4344
PrepareSources(ctx context.Context, component components.Component, outputDir string, applyOverlays bool) error
45+
46+
// DiffSources computes a unified diff showing the changes that overlays apply to a component's sources.
47+
// The component's sources are fetched once into a subdirectory of baseDir, then copied to a second
48+
// subdirectory where overlays are applied in-place. The diff between the two subdirectories is returned.
49+
DiffSources(ctx context.Context, component components.Component, baseDir string) (*dirdiff.DiffResult, error)
4450
}
4551

4652
// Standard implementation of the [SourcePreparer] interface.
@@ -127,6 +133,70 @@ func (p *sourcePreparerImpl) PrepareSources(
127133
return nil
128134
}
129135

136+
// DiffSources implements the [SourcePreparer] interface.
137+
// It fetches the component's sources once, copies them to a second directory, applies overlays
138+
// to the copy, then diffs the two trees. This avoids fetching the sources twice.
139+
func (p *sourcePreparerImpl) DiffSources(
140+
ctx context.Context, component components.Component, baseDir string,
141+
) (result *dirdiff.DiffResult, err error) {
142+
event := p.eventListener.StartEvent("Computing overlay diff", "component", component.GetName())
143+
defer event.End()
144+
145+
// Create temp dirs for sources prepared without and with overlays.
146+
originalDir, err := fileutils.MkdirTemp(p.fs, baseDir, "original-")
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to create temp dir for original sources:\n%w", err)
149+
}
150+
151+
defer fileutils.RemoveAllAndUpdateErrorIfNil(p.fs, originalDir, &err)
152+
153+
// Prepare sources without applying overlays, to get the original tree.
154+
if err := p.PrepareSources(ctx, component, originalDir, false /* applyOverlays */); err != nil {
155+
return nil, err
156+
}
157+
158+
// Copy the fetched sources to a separate directory for in-place overlay application.
159+
// This avoids a second network fetch.
160+
overlaidDir, err := fileutils.MkdirTemp(p.fs, baseDir, "overlaid-")
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to create temp dir for overlaid sources:\n%w", err)
163+
}
164+
165+
defer fileutils.RemoveAllAndUpdateErrorIfNil(p.fs, overlaidDir, &err)
166+
167+
if err := fileutils.CopyDirRecursive(
168+
p.dryRunnable, p.fs, originalDir, overlaidDir,
169+
fileutils.CopyDirOptions{CopyFileOptions: fileutils.CopyFileOptions{PreserveFileMode: true}},
170+
); err != nil {
171+
return nil, fmt.Errorf("failed to copy sources for component %#q:\n%w", component.GetName(), err)
172+
}
173+
174+
// Apply overlays in-place to the copied directory only.
175+
var macrosFileName string
176+
177+
macrosFilePath, err := p.writeMacrosFile(component, overlaidDir)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to write macros file for component %#q:\n%w", component.GetName(), err)
180+
}
181+
182+
if macrosFilePath != "" {
183+
macrosFileName = filepath.Base(macrosFilePath)
184+
}
185+
186+
if err := p.postProcessSources(component, overlaidDir, macrosFileName); err != nil {
187+
return nil, fmt.Errorf("failed to post-process sources for component %#q:\n%w", component.GetName(), err)
188+
}
189+
190+
// Diff the original tree against the overlaid tree.
191+
result, err = dirdiff.DiffDirs(p.fs, originalDir, overlaidDir)
192+
if err != nil {
193+
return nil, fmt.Errorf("failed to diff source trees for component %#q:\n%w",
194+
component.GetName(), err)
195+
}
196+
197+
return result, nil
198+
}
199+
130200
// writeMacrosFile writes a macros file containing the resolved macros for a component.
131201
// This includes with/without flags converted to macro format, and any explicit defines.
132202
// If the build configuration produces no macros, no file is written and an empty path is

0 commit comments

Comments
 (0)