Skip to content

Commit 5c67c49

Browse files
Copilotddstreet
authored andcommitted
feat(component): add refresh-upstream-commit for lock-file-free mode
Register the component commands that maintain resolved state according to the selected mode. The default mode keeps history, query, and update; the lock-file-free mode instead gets refresh-upstream-commit, which resolves each selected upstream component at the distro snapshot and records the result as generated component TOML, plus hidden no-op stand-ins for the lock-file commands so existing invocations report that they do nothing. Because a stale generated pin can make strict configuration loading fail, the refresh command is annotated to load configuration permissively, and commands can now be excluded from generated Markdown docs so the no-op stand-ins stay out of the reference documentation. Component commands take construction options so that lock-file-only flags, such as '--skip-lock-validation', are simply not registered in lock-file-free mode instead of being accepted and ignored. Both modes are exercised by unit tests, including a lock-file-free test environment. Refs: microsoft#323
1 parent 60a7b8b commit 5c67c49

25 files changed

Lines changed: 1707 additions & 64 deletions

internal/app/azldev/app.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type App struct {
5050
reportFormat ReportFormat
5151
disableDefaultConfig bool
5252
permissiveConfigParsing bool
53+
commandPermissiveConfig bool
5354
configFiles []string
5455
colorMode ColorMode
5556
withoutLockfile bool
@@ -137,7 +138,7 @@ lives), or use -C to point to one.`,
137138
env.SetAcceptAllPrompts(app.acceptAllPrompts)
138139
env.SetColorMode(app.colorMode)
139140
env.SetNetworkRetries(app.networkRetries)
140-
env.SetPermissiveConfigParsing(app.permissiveConfigParsing)
141+
env.SetPermissiveConfigParsing(app.permissiveConfigEnabled())
141142

142143
return nil
143144
},
@@ -262,6 +263,7 @@ func (a *App) Execute(args []string) int {
262263
// the "right thing" to happen.
263264
//
264265
a.PreParseGlobalFlags(args)
266+
a.commandPermissiveConfig = a.commandRequestsPermissiveConfig(args)
265267

266268
envOptions := a.initializeEnvOptions()
267269

@@ -365,6 +367,25 @@ func (a *App) Execute(args []string) int {
365367
return a.dispatchToCommand(env, args)
366368
}
367369

370+
// commandRequestsPermissiveConfig reports whether the command selected by args asked
371+
// for permissive configuration loading via [CommandAnnotationPermissiveConfig].
372+
func (a *App) commandRequestsPermissiveConfig(args []string) bool {
373+
cmd, _, err := a.cmd.Find(args)
374+
if err != nil {
375+
return false
376+
}
377+
378+
_, permissive := cmd.Annotations[CommandAnnotationPermissiveConfig]
379+
380+
return permissive
381+
}
382+
383+
// permissiveConfigEnabled reports whether configuration should be loaded permissively,
384+
// either because the user asked for it or because the selected command requires it.
385+
func (a *App) permissiveConfigEnabled() bool {
386+
return a.permissiveConfigParsing || a.commandPermissiveConfig
387+
}
388+
368389
func (*App) setCmdFactory(envOptions *EnvOptions) error {
369390
cmdFactory, err := DefaultCmdFactory(envOptions.DryRunnable, envOptions.EventListener)
370391
if err != nil {
@@ -603,7 +624,7 @@ func (a *App) findAndLoadConfig(tempDirPath string, extraConfigFiles []string) (
603624
a.disableDefaultConfig,
604625
tempDirPath,
605626
extraConfigFiles,
606-
a.permissiveConfigParsing,
627+
a.permissiveConfigEnabled(),
607628
a.withoutLockfile,
608629
)
609630
if err != nil {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,11 @@ type ComponentBuildResults struct {
7878
RPMs []RPMResult `json:"rpms" table:"-"`
7979
}
8080

81-
func buildOnAppInit(_ *azldev.App, parent *cobra.Command) {
82-
parent.AddCommand(NewBuildCmd())
81+
func buildOnAppInit(app *azldev.App, parent *cobra.Command) {
82+
parent.AddCommand(NewBuildCmd(cmdOptionsForApp(app)...))
8383
}
8484

85-
func NewBuildCmd() *cobra.Command {
85+
func NewBuildCmd(opts ...CmdOption) *cobra.Command {
8686
// Fill out options defaults.
8787
options := &ComponentBuildOptions{
8888
BuildEnvPolicy: BuildEnvPreserveOnFailure,
@@ -127,7 +127,7 @@ builds can consume.`,
127127
ValidArgsFunction: components.GenerateComponentNameCompletions,
128128
}
129129

130-
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
130+
addComponentFilterOptions(cmd, &options.ComponentFilter, newCmdOptions(opts...))
131131
cmd.Flags().BoolVarP(&options.ContinueOnError, "continue-on-error", "k", false,
132132
"Continue building when some components fail")
133133
cmd.Flags().BoolVar(&options.NoCheck, "no-check", false, "Skip package %check tests")

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ type ChangedComponentOptions struct {
3232
IncludeUnchanged bool
3333
}
3434

35-
func changedOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
36-
parentCmd.AddCommand(NewChangedCmd())
35+
func changedOnAppInit(app *azldev.App, parentCmd *cobra.Command) {
36+
parentCmd.AddCommand(NewChangedCmd(cmdOptionsForApp(app)...))
3737
}
3838

3939
// NewChangedCmd constructs a [cobra.Command] for the "component changed" CLI subcommand.
40-
func NewChangedCmd() *cobra.Command {
40+
func NewChangedCmd(opts ...CmdOption) *cobra.Command {
4141
options := &ChangedComponentOptions{}
4242

4343
cmd := &cobra.Command{
@@ -81,7 +81,8 @@ detected via lock file presence in the compared refs when using -a.`,
8181
ValidArgsFunction: components.GenerateComponentNameCompletions,
8282
}
8383

84-
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
84+
cmdOptions := newCmdOptions(opts...)
85+
addComponentFilterOptions(cmd, &options.ComponentFilter, cmdOptions)
8586

8687
cmd.Flags().StringVar(&options.From, "from", "", "Git ref to compare from (required)")
8788
cmd.Flags().StringVar(&options.To, "to", "HEAD", "Git ref to compare to")
@@ -92,7 +93,9 @@ detected via lock file presence in the compared refs when using -a.`,
9293

9394
// Hide inherited flag -- this command always skips lock validation since
9495
// it inspects historical locks at arbitrary refs.
95-
_ = cmd.Flags().MarkHidden("skip-lock-validation")
96+
if !cmdOptions.withoutLockfile {
97+
_ = cmd.Flags().MarkHidden("skip-lock-validation")
98+
}
9699

97100
azldev.ExportAsReadOnlyMCPTool(cmd)
98101

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
8+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// cmdOptions holds the mode-sensitive choices made when a component command is
13+
// constructed. The zero value describes azldev's default (lock file) mode.
14+
type cmdOptions struct {
15+
// withoutLockfile omits lock-file-specific flags, matching the command surface
16+
// exposed by the global '--without-lockfile' flag.
17+
withoutLockfile bool
18+
}
19+
20+
// CmdOption customizes how a component command is constructed.
21+
type CmdOption func(*cmdOptions)
22+
23+
// WithoutLockfileFlags omits the lock-file-specific flags from a component command.
24+
// Commands are registered with this option when the global '--without-lockfile'
25+
// flag selects lock-file-free mode, so the flags that only lock files can honor are
26+
// never offered.
27+
func WithoutLockfileFlags() CmdOption {
28+
return func(options *cmdOptions) {
29+
options.withoutLockfile = true
30+
}
31+
}
32+
33+
// newCmdOptions resolves the supplied command options.
34+
func newCmdOptions(opts ...CmdOption) cmdOptions {
35+
options := cmdOptions{}
36+
for _, opt := range opts {
37+
opt(&options)
38+
}
39+
40+
return options
41+
}
42+
43+
// addComponentFilterOptions registers the component selection flags, including the
44+
// lock-file flags that only apply in azldev's default mode.
45+
func addComponentFilterOptions(
46+
cmd *cobra.Command, filter *components.ComponentFilter, options cmdOptions,
47+
) {
48+
components.AddComponentFilterOptionsToCommand(cmd, filter)
49+
50+
if !options.withoutLockfile {
51+
components.AddLockValidationFlagToCommand(cmd, filter)
52+
}
53+
}
54+
55+
// cmdOptionsForApp returns the command options matching the app's selected mode.
56+
func cmdOptionsForApp(app *azldev.App) []CmdOption {
57+
if app.WithoutLockfile() {
58+
return []CmdOption{WithoutLockfileFlags()}
59+
}
60+
61+
return nil
62+
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,20 @@ components defined in the project configuration.`,
2727
buildOnAppInit(app, cmd)
2828
changedOnAppInit(app, cmd)
2929
diffSourcesOnAppInit(app, cmd)
30-
historyOnAppInit(app, cmd)
3130
listOnAppInit(app, cmd)
3231
prepareOnAppInit(app, cmd)
33-
queryOnAppInit(app, cmd)
3432
renderOnAppInit(app, cmd)
35-
updateOnAppInit(app, cmd)
33+
34+
// The commands that maintain resolved component state differ by mode: the
35+
// default mode maintains lock files, while lock-file-free mode maintains
36+
// generated upstream-commit config. Registering only the commands that
37+
// belong to the active mode keeps help, docs, and MCP tools honest.
38+
if app.WithoutLockfile() {
39+
legacyOnAppInit(app, cmd)
40+
refreshUpstreamCommitOnAppInit(app, cmd)
41+
} else {
42+
historyOnAppInit(app, cmd)
43+
queryOnAppInit(app, cmd)
44+
updateOnAppInit(app, cmd)
45+
}
3646
}

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package component_test
55

66
import (
7+
"slices"
78
"testing"
89

910
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
@@ -26,3 +27,52 @@ func TestOnAppInit(t *testing.T) {
2627

2728
assert.Contains(t, topLevelCommandNames, "component")
2829
}
30+
31+
func TestOnAppInit_CommandsByMode(t *testing.T) {
32+
testCases := []struct {
33+
name string
34+
args []string
35+
refreshExpected bool
36+
}{
37+
{name: "default mode", args: nil, refreshExpected: false},
38+
{name: "lock-file-free mode", args: []string{"--without-lockfile"}, refreshExpected: true},
39+
}
40+
41+
for _, testCase := range testCases {
42+
t.Run(testCase.name, func(t *testing.T) {
43+
ctrl := gomock.NewController(t)
44+
app := azldev.NewApp(opctx_test.NewMockFileSystemFactory(ctrl), opctx_test.NewMockOSEnvFactory(ctrl))
45+
46+
app.PreParseGlobalFlags(testCase.args)
47+
component.OnAppInit(app)
48+
49+
commandNames, err := app.CommandNames("component")
50+
require.NoError(t, err)
51+
52+
assert.Equal(t, testCase.refreshExpected,
53+
slices.Contains(commandNames, "refresh-upstream-commit"))
54+
55+
// The lock-file command names stay reachable in lock-file-free mode,
56+
// where they are registered as hidden no-ops for compatibility.
57+
for _, name := range []string{"history", "query", "update"} {
58+
assert.Contains(t, commandNames, name)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestNewComponentCommands_LockValidationFlagByMode(t *testing.T) {
65+
// The lock-file consistency flags only mean something when lock files are in
66+
// play, so lock-file-free mode leaves them unregistered.
67+
assert.NotNil(t, component.NewBuildCmd().Flags().Lookup("skip-lock-validation"))
68+
assert.NotNil(t, component.NewRenderCmd().Flags().Lookup("skip-lock-validation"))
69+
assert.NotNil(t, component.NewComponentListCommand().Flags().Lookup("skip-lock-validation"))
70+
assert.NotNil(t, component.NewHistoryCmd().Flags().Lookup("skip-lock-validation"))
71+
assert.NotNil(t, component.NewComponentQueryCommand().Flags().Lookup("skip-lock-validation"))
72+
assert.NotNil(t, component.NewUpdateCmd().Flags().Lookup("skip-lock-validation"))
73+
74+
withoutLockfile := component.WithoutLockfileFlags()
75+
assert.Nil(t, component.NewBuildCmd(withoutLockfile).Flags().Lookup("skip-lock-validation"))
76+
assert.Nil(t, component.NewRenderCmd(withoutLockfile).Flags().Lookup("skip-lock-validation"))
77+
assert.Nil(t, component.NewComponentListCommand(withoutLockfile).Flags().Lookup("skip-lock-validation"))
78+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ type DiffSourcesOptions struct {
2828
OutputFile string
2929
}
3030

31-
func diffSourcesOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
32-
parentCmd.AddCommand(NewDiffSourcesCmd())
31+
func diffSourcesOnAppInit(app *azldev.App, parentCmd *cobra.Command) {
32+
parentCmd.AddCommand(NewDiffSourcesCmd(cmdOptionsForApp(app)...))
3333
}
3434

3535
// NewDiffSourcesCmd constructs a [cobra.Command] for the "component diff-sources" CLI subcommand.
36-
func NewDiffSourcesCmd() *cobra.Command {
36+
func NewDiffSourcesCmd(opts ...CmdOption) *cobra.Command {
3737
var options DiffSourcesOptions
3838

3939
cmd := &cobra.Command{
@@ -50,7 +50,7 @@ overlays to the copy and displays the resulting diff between the two trees.`,
5050
ValidArgsFunction: components.GenerateComponentNameCompletions,
5151
}
5252

53-
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
53+
addComponentFilterOptions(cmd, &options.ComponentFilter, newCmdOptions(opts...))
5454

5555
cmd.Flags().StringVar(&options.OutputFile, "output-file", "",
5656
"write the diff output to a file instead of stdout")

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ hand-picking entries to document.`,
108108
ValidArgsFunction: components.GenerateComponentNameCompletions,
109109
}
110110

111-
components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)
111+
addComponentFilterOptions(cmd, &options.ComponentFilter, cmdOptions{})
112112

113113
cmd.Flags().StringVar(&options.SharedTomlMode, "shared", sharedTomlModeShow,
114114
"How to report rows for components that share a TOML file with others: "+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package component
5+
6+
import (
7+
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
const (
12+
legacyUpdateMessage = "azldev component update no longer does anything and should no longer be used."
13+
legacyHistoryMessage = "azldev component history no longer does anything and should no longer be used."
14+
legacyQueryMessage = "azldev component query no longer does anything and should no longer be used."
15+
)
16+
17+
func legacyOnAppInit(_ *azldev.App, parentCmd *cobra.Command) {
18+
parentCmd.AddCommand(
19+
newLegacyNoOpCmd("update", nil, legacyUpdateMessage),
20+
newLegacyNoOpCmd("history", []string{"hist"}, legacyHistoryMessage),
21+
newLegacyNoOpCmd("query", nil, legacyQueryMessage),
22+
)
23+
}
24+
25+
func newLegacyNoOpCmd(name string, aliases []string, message string) *cobra.Command {
26+
cmd := &cobra.Command{
27+
Use: name,
28+
Aliases: aliases,
29+
Short: message,
30+
Hidden: true,
31+
DisableFlagParsing: true,
32+
Args: cobra.ArbitraryArgs,
33+
Run: func(cmd *cobra.Command, _ []string) {
34+
cmd.Println(message)
35+
},
36+
}
37+
38+
azldev.ExcludeFromMarkdownDocs(cmd)
39+
40+
return cmd
41+
}

0 commit comments

Comments
 (0)