Skip to content

Commit 78c489f

Browse files
ddstreetCopilot
andcommitted
feat(cli): add global --without-lockfile preview flag
Add a global, persistent '--without-lockfile' boolean flag that selects a preview mode in which azldev tracks resolved upstream commits in generated component configuration instead of per-component lock files. The flag defaults to false, which keeps azldev's existing lock-file behavior unchanged. The registered command set and configuration loading both depend on the mode, and both happen before cobra parses the command line, so the flag is hand-parsed up front by App.PreParseGlobalFlags. Only exact '--without-lockfile' and '--without-lockfile=<value>' tokens are recognized, and scanning stops at the '--' terminator so positional text is never mistaken for the flag. The CLI entry point pre-parses the process arguments before registering commands; Execute pre-parses again so an App executed directly behaves identically. The mode is carried on Env via EnvOptions.WithoutLockfile, and the lock store is not created when the mode is active, so every lock-file consumer degrades to a no-op without needing mode-specific code. The flag is part of the CLI surface, so the help, MCP tool-schema, and usage-error snapshots pick up the new global option. Refs: microsoft#323 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58ef091 commit 78c489f

12 files changed

Lines changed: 267 additions & 13 deletions

internal/app/azldev/app.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/signal"
1313
"path/filepath"
14+
"strconv"
1415
"strings"
1516
"time"
1617

@@ -51,6 +52,7 @@ type App struct {
5152
permissiveConfigParsing bool
5253
configFiles []string
5354
colorMode ColorMode
55+
withoutLockfile bool
5456

5557
// Root command for the CLI.
5658
cmd cobra.Command
@@ -184,6 +186,8 @@ func (app *App) registerGlobalFlags() {
184186
"output colorization mode {always, auto, never}")
185187
app.cmd.PersistentFlags().BoolVar(&app.permissiveConfigParsing, "permissive-config",
186188
false, "do not fail on unknown fields in TOML config files")
189+
app.cmd.PersistentFlags().BoolVar(&app.withoutLockfile, "without-lockfile", false,
190+
"preview: track resolved upstream commits in generated config instead of lock files")
187191
}
188192

189193
// addAdvancedCommandHint embeds a hint about the hidden "advanced" command group
@@ -257,7 +261,7 @@ func (a *App) Execute(args []string) int {
257261
// We tried to do this with cobra first, but it was too difficult to get
258262
// the "right thing" to happen.
259263
//
260-
a.handParseConfigFlags(args)
264+
a.PreParseGlobalFlags(args)
261265

262266
envOptions := a.initializeEnvOptions()
263267

@@ -397,6 +401,7 @@ func (a *App) initializeEnvOptions() *EnvOptions {
397401
envOptions.Interfaces.FileSystemFactory = a.fsFactory
398402
envOptions.Interfaces.OSEnvFactory = a.osEnvFactory
399403
envOptions.DryRunnable = NewAppDryRunnable(a.dryRun)
404+
envOptions.WithoutLockfile = a.withoutLockfile
400405

401406
return &envOptions
402407
}
@@ -458,6 +463,68 @@ func setEventListener(stdioLogger *slog.Logger, quiet, verbose bool, envOptions
458463
return nil
459464
}
460465

466+
// PreParseGlobalFlags hand-parses the global flags that must be known before commands
467+
// are registered and configuration is loaded. It is safe to call more than once with the
468+
// same arguments; each call fully recomputes the pre-parsed state.
469+
//
470+
// Command registration is mode-sensitive, so the CLI entry point calls this before it
471+
// registers commands; [App.Execute] calls it again so that an App executed directly
472+
// (e.g. from a test) behaves identically.
473+
func (a *App) PreParseGlobalFlags(args []string) {
474+
// Reset accumulating state so repeated calls are idempotent.
475+
a.configFiles = nil
476+
477+
a.withoutLockfile = parseWithoutLockfileFlag(args)
478+
a.handParseConfigFlags(args)
479+
}
480+
481+
// WithoutLockfile reports whether the preview lock-file-free mode was requested via the
482+
// global '--without-lockfile' flag. Valid only after [App.PreParseGlobalFlags] has run.
483+
func (a *App) WithoutLockfile() bool {
484+
return a.withoutLockfile
485+
}
486+
487+
// withoutLockfileFlagName is the global flag that selects lock-file-free mode.
488+
const withoutLockfileFlagName = "--without-lockfile"
489+
490+
// parseWithoutLockfileFlag hand-parses the global '--without-lockfile' boolean flag.
491+
//
492+
// Only exact '--without-lockfile' and '--without-lockfile=<value>' tokens are
493+
// recognized, and scanning stops at the '--' terminator so that positional text
494+
// (for example a mock command line) is never mistaken for the flag. An invalid or
495+
// missing value is treated the same way cobra treats it: '--without-lockfile' alone
496+
// enables the mode, and an unparseable '=<value>' leaves the mode disabled so the
497+
// final cobra parse reports the error.
498+
func parseWithoutLockfileFlag(args []string) bool {
499+
withoutLockfile := false
500+
501+
for _, arg := range args {
502+
if arg == "--" {
503+
break
504+
}
505+
506+
if arg == withoutLockfileFlagName {
507+
withoutLockfile = true
508+
509+
continue
510+
}
511+
512+
value, found := strings.CutPrefix(arg, withoutLockfileFlagName+"=")
513+
if !found {
514+
continue
515+
}
516+
517+
parsed, err := strconv.ParseBool(value)
518+
if err != nil {
519+
return false
520+
}
521+
522+
withoutLockfile = parsed
523+
}
524+
525+
return withoutLockfile
526+
}
527+
461528
// Hand-parses a few critical configuration flags from the command line -- just enough to
462529
// find the project and load configuration so we can properly use cobra facilities to
463530
// parse the full command line.

internal/app/azldev/app_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,100 @@ func TestApp_PermissiveConfigOption_DefaultFalse(t *testing.T) {
193193
assert.Zero(t, result)
194194
assert.True(t, ran)
195195
}
196+
197+
func TestApp_WithoutLockfileOption(t *testing.T) {
198+
testCases := []struct {
199+
name string
200+
args []string
201+
expected bool
202+
}{
203+
{name: "absent", args: []string{"test-cmd"}, expected: false},
204+
{name: "bare", args: []string{"--without-lockfile", "test-cmd"}, expected: true},
205+
{name: "explicit true", args: []string{"--without-lockfile=true", "test-cmd"}, expected: true},
206+
{name: "explicit false", args: []string{"--without-lockfile=false", "test-cmd"}, expected: false},
207+
{name: "after command", args: []string{"test-cmd", "--without-lockfile"}, expected: true},
208+
{name: "positional lookalike", args: []string{"test-cmd", "--", "--without-lockfile"}, expected: false},
209+
{name: "prefix lookalike", args: []string{"test-cmd", "--without-lockfiles"}, expected: false},
210+
}
211+
212+
for _, testCase := range testCases {
213+
t.Run(testCase.name, func(t *testing.T) {
214+
app := createTestApp(t)
215+
216+
app.PreParseGlobalFlags(testCase.args)
217+
218+
assert.Equal(t, testCase.expected, app.WithoutLockfile())
219+
})
220+
}
221+
}
222+
223+
func TestApp_WithoutLockfileOption_ReachesEnv(t *testing.T) {
224+
testCases := []struct {
225+
name string
226+
args []string
227+
expected bool
228+
}{
229+
{name: "default", args: []string{"test-cmd"}, expected: false},
230+
{name: "enabled", args: []string{"--without-lockfile", "test-cmd"}, expected: true},
231+
{name: "disabled", args: []string{"--without-lockfile=false", "test-cmd"}, expected: false},
232+
}
233+
234+
for _, testCase := range testCases {
235+
t.Run(testCase.name, func(t *testing.T) {
236+
app := createTestApp(t)
237+
238+
ran := false
239+
cmd := &cobra.Command{
240+
Use: "test-cmd",
241+
RunE: func(cmd *cobra.Command, _ []string) error {
242+
env, err := azldev.GetEnvFromCommand(cmd)
243+
require.NoError(t, err)
244+
245+
assert.Equal(t, testCase.expected, env.WithoutLockfile())
246+
247+
ran = true
248+
249+
return nil
250+
},
251+
}
252+
253+
app.AddTopLevelCommand(cmd)
254+
255+
assert.Zero(t, app.Execute(testCase.args))
256+
assert.True(t, ran)
257+
})
258+
}
259+
}
260+
261+
// PreParseGlobalFlags is called both by the CLI entry point (before command
262+
// registration) and by Execute; repeated calls must not accumulate state.
263+
func TestApp_PreParseGlobalFlags_Idempotent(t *testing.T) {
264+
app := createTestApp(t)
265+
args := []string{"--without-lockfile", "--config-file", "extra.toml", "test-cmd"}
266+
267+
ran := false
268+
cmd := &cobra.Command{
269+
Use: "test-cmd",
270+
RunE: func(cmd *cobra.Command, _ []string) error {
271+
env, err := azldev.GetEnvFromCommand(cmd)
272+
require.NoError(t, err)
273+
274+
assert.True(t, env.WithoutLockfile())
275+
276+
ran = true
277+
278+
return nil
279+
},
280+
}
281+
282+
app.AddTopLevelCommand(cmd)
283+
app.PreParseGlobalFlags(args)
284+
app.PreParseGlobalFlags(args)
285+
286+
assert.True(t, app.WithoutLockfile())
287+
288+
// Execute pre-parses the same arguments a third time; a command that only
289+
// accumulated config files would fail to load its (nonexistent) extra file.
290+
assert.Zero(t, app.Execute(args))
291+
assert.True(t, ran)
292+
}

internal/app/azldev/env.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ type EnvOptions struct {
3333
// The loaded configuration for the project.
3434
Config *projectconfig.ProjectConfig
3535

36+
// WithoutLockfile selects the preview lock-file-free mode, in which
37+
// component state is tracked by generated upstream-commit config instead
38+
// of per-component lock files. Set from the global '--without-lockfile'
39+
// flag; false selects the default lock-file behavior.
40+
WithoutLockfile bool
41+
3642
// Injected dependencies.
3743
DryRunnable opctx.DryRunnable
3844
EventListener opctx.EventListener
@@ -73,6 +79,7 @@ type Env struct {
7379
acceptAllPrompts bool
7480
networkRetries int
7581
permissiveConfigParsing bool
82+
withoutLockfile bool
7683

7784
// Injected dependencies.
7885
cmdFactory opctx.CmdFactory
@@ -96,7 +103,8 @@ type Env struct {
96103
fixSuggestions *fixSuggestionState
97104

98105
// lockStore provides cached access to per-component lock files.
99-
// Nil when no project directory is configured.
106+
// Nil when no project directory is configured, or when lock-file-free
107+
// mode is selected.
100108
lockStore *lockfile.Store
101109
}
102110

@@ -167,15 +175,17 @@ func NewEnv(ctx context.Context, options EnvOptions) *Env {
167175
quiet: false,
168176
promptsAllowed: isatty.IsTerminal(os.Stdin.Fd()),
169177
permissiveConfigParsing: false,
178+
withoutLockfile: options.WithoutLockfile,
170179

171180
// Start time.
172181
constructionTime: time.Now(),
173182

174183
// No fix suggestions to start.
175184
fixSuggestions: &fixSuggestionState{},
176185

177-
// Lock store: created when we have a project directory.
178-
lockStore: newLockStore(options.ProjectDir, options.Config, options.Interfaces.FileSystemFactory),
186+
// Lock store: created when we have a project directory, unless
187+
// lock-file-free mode is selected.
188+
lockStore: newLockStore(options, options.Interfaces.FileSystemFactory),
179189
}
180190
}
181191

@@ -242,6 +252,13 @@ func (env *Env) SetPermissiveConfigParsing(permissive bool) {
242252
env.permissiveConfigParsing = permissive
243253
}
244254

255+
// WithoutLockfile reports whether the preview lock-file-free mode is active.
256+
// In that mode azldev tracks resolved upstream commits in generated component
257+
// config instead of per-component lock files, and no lock store is available.
258+
func (env *Env) WithoutLockfile() bool {
259+
return env.withoutLockfile
260+
}
261+
245262
// SetEventListener registers the event listener to be used in this environment.
246263
func (env *Env) SetEventListener(eventListener opctx.EventListener) {
247264
env.eventListener = eventListener
@@ -385,14 +402,16 @@ func (env *Env) LockReader() lockfile.LockReader {
385402
}
386403

387404
// newLockStore creates a lock store from the project config's lock-dir.
388-
// Returns nil when the project directory, filesystem, or config is unavailable,
389-
// or when the config's lock-dir is empty.
405+
// Returns nil when lock-file-free mode is selected, or when the project
406+
// directory, filesystem, or config is unavailable, or when the config's
407+
// lock-dir is empty.
390408
func newLockStore(
391-
projectDir string,
392-
config *projectconfig.ProjectConfig,
409+
options EnvOptions,
393410
fsFactory opctx.FileSystemFactory,
394411
) *lockfile.Store {
395-
if projectDir == "" || fsFactory == nil || config == nil || config.Project.LockDir == "" {
412+
config := options.Config
413+
if options.WithoutLockfile || options.ProjectDir == "" || fsFactory == nil ||
414+
config == nil || config.Project.LockDir == "" {
396415
return nil
397416
}
398417

pkg/app/azldev_cli/azldev.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,35 @@ import (
2626
// Main constructs the azldev CLI application, runs it with the process
2727
// arguments, and exits the process with the resulting status code.
2828
func Main() {
29-
// Instantiate the main CLI app instance.
30-
app := InstantiateApp()
29+
args := os.Args[1:]
30+
31+
// Instantiate the main CLI app instance. The arguments are needed up front
32+
// because command registration depends on the global flags they carry.
33+
app := InstantiateAppForArgs(args)
3134

3235
// Execute! We'll get back an exit code that we will exit with.
33-
ret := app.Execute(os.Args[1:])
36+
ret := app.Execute(args)
3437

3538
os.Exit(ret)
3639
}
3740

3841
// InstantiateApp constructs a new instance of the azldev CLI application with
39-
// all subcommands registered.
42+
// all subcommands registered for azldev's default (lock file) mode.
4043
func InstantiateApp() *azldev.App {
44+
return InstantiateAppForArgs(nil)
45+
}
46+
47+
// InstantiateAppForArgs constructs a new instance of the azldev CLI application
48+
// with the subcommands registered for the mode selected by args. Global flags
49+
// that select a mode (such as '--without-lockfile') are hand-parsed from args
50+
// before registration, because the registered command set differs by mode.
51+
func InstantiateAppForArgs(args []string) *azldev.App {
4152
// Instantiate the main CLI application.
4253
app := azldev.NewApp(azldev.DefaultFileSystemFactory(), azldev.DefaultOSEnvFactory())
4354

55+
// Resolve the global flags that command registration depends on.
56+
app.PreParseGlobalFlags(args)
57+
4458
// Give top level command packages an opportunity to register their commands (or in some cases,
4559
// request post-init callbacks).
4660
advanced.OnAppInit(app)

0 commit comments

Comments
 (0)