Skip to content

Commit 3f7f920

Browse files
Daniel McIlvaneydmcilvaneyCopilot
authored
feat: add --force flag to PrepareSourcesOptions and CLI registration (#467)
* feat: add --force flag to PrepareSourcesOptions and CLI registration Add Force bool field to PrepareSourcesOptions struct and register the --force CLI flag (default false) with description: "delete and recreate the output directory if it already exists". Also fix MCP server magemcp to resolve project root to absolute path, enabling relative path arguments to work correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feedback --------- Co-authored-by: Daniel McIlvaney <damcilva@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fe54ec0 commit 3f7f920

File tree

3 files changed

+160
-0
lines changed

3 files changed

+160
-0
lines changed

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/gim-home/azldev-preview/internal/app/azldev/core/components"
1212
"github.com/gim-home/azldev-preview/internal/app/azldev/core/sources"
1313
"github.com/gim-home/azldev-preview/internal/providers/sourceproviders"
14+
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
1415
"github.com/spf13/cobra"
1516
)
1617

@@ -19,6 +20,7 @@ type PrepareSourcesOptions struct {
1920

2021
OutputDir string
2122
SkipOverlays bool
23+
Force bool
2224
}
2325

2426
func prepareOnAppInit(_ *azldev.App, sourceCmd *cobra.Command) {
@@ -50,6 +52,7 @@ func NewPrepareSourcesCmd() *cobra.Command {
5052
_ = cmd.MarkFlagDirname("output-dir")
5153

5254
cmd.Flags().BoolVar(&options.SkipOverlays, "skip-overlays", false, "skip applying overlays to prepared sources")
55+
cmd.Flags().BoolVar(&options.Force, "force", false, "delete and recreate the output directory if it already exists")
5356

5457
return cmd
5558
}
@@ -87,6 +90,11 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er
8790
return fmt.Errorf("failed to create source manager:\n%w", err)
8891
}
8992

93+
// Pre-flight check: detect non-empty output directory before any work.
94+
if err := CheckOutputDir(env, options); err != nil {
95+
return err
96+
}
97+
9098
preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env)
9199
if err != nil {
92100
return fmt.Errorf("failed to create source preparer:\n%w", err)
@@ -99,3 +107,39 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er
99107

100108
return nil
101109
}
110+
111+
// CheckOutputDir verifies the output directory state before source preparation.
112+
// If the directory exists and is non-empty, it either removes it (when Force is set)
113+
// or returns an actionable error suggesting --force.
114+
func CheckOutputDir(env *azldev.Env, options *PrepareSourcesOptions) error {
115+
dirExists, err := fileutils.DirExists(env.FS(), options.OutputDir)
116+
if err != nil {
117+
return fmt.Errorf("failed to check output directory %#q:\n%w", options.OutputDir, err)
118+
}
119+
120+
if !dirExists {
121+
return nil
122+
}
123+
124+
empty, err := fileutils.IsDirEmpty(env.FS(), options.OutputDir)
125+
if err != nil {
126+
return fmt.Errorf("failed to check if output directory %#q is empty:\n%w", options.OutputDir, err)
127+
}
128+
129+
if empty {
130+
return nil
131+
}
132+
133+
if options.Force {
134+
if err := env.FS().RemoveAll(options.OutputDir); err != nil {
135+
return fmt.Errorf("failed to clean output directory %#q:\n%w", options.OutputDir, err)
136+
}
137+
138+
return nil
139+
}
140+
141+
return fmt.Errorf(
142+
"output directory %#q already exists and is not empty;\n"+
143+
"use --force to delete and recreate it",
144+
options.OutputDir)
145+
}

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88

99
"github.com/gim-home/azldev-preview/internal/app/azldev/cmds/component"
1010
"github.com/gim-home/azldev-preview/internal/app/azldev/core/testutils"
11+
"github.com/gim-home/azldev-preview/internal/utils/fileperms"
12+
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
1113
"github.com/stretchr/testify/assert"
1214
"github.com/stretchr/testify/require"
1315
)
@@ -16,6 +18,11 @@ func TestNewPrepareSourcesCmd(t *testing.T) {
1618
cmd := component.NewPrepareSourcesCmd()
1719
require.NotNil(t, cmd)
1820
assert.Equal(t, "prepare-sources", cmd.Use)
21+
22+
forceFlag := cmd.Flags().Lookup("force")
23+
require.NotNil(t, forceFlag, "--force flag should be registered")
24+
assert.Equal(t, "false", forceFlag.DefValue)
25+
assert.Contains(t, forceFlag.Usage, "delete and recreate the output directory")
1926
}
2027

2128
func TestPrepareSourcesCmd_NoMatch(t *testing.T) {
@@ -31,3 +38,106 @@ func TestPrepareSourcesCmd_NoMatch(t *testing.T) {
3138
// We expect an error because we haven't set up any components.
3239
require.Error(t, err)
3340
}
41+
42+
func TestCheckOutputDir(t *testing.T) {
43+
const (
44+
outputDir = "/test/output"
45+
staleFile = "/test/output/stale.txt"
46+
)
47+
48+
tests := []struct {
49+
name string
50+
force bool
51+
setupDir bool
52+
addFile bool
53+
expectError bool
54+
errorMsgContains []string
55+
}{
56+
{
57+
name: "default with nonexistent dir succeeds",
58+
force: false,
59+
setupDir: false,
60+
addFile: false,
61+
expectError: false,
62+
},
63+
{
64+
name: "default with empty dir succeeds",
65+
force: false,
66+
setupDir: true,
67+
addFile: false,
68+
expectError: false,
69+
},
70+
{
71+
name: "default with non-empty dir returns actionable error",
72+
force: false,
73+
setupDir: true,
74+
addFile: true,
75+
expectError: true,
76+
errorMsgContains: []string{"--force", outputDir},
77+
},
78+
{
79+
name: "force with nonexistent dir succeeds",
80+
force: true,
81+
setupDir: false,
82+
addFile: false,
83+
expectError: false,
84+
},
85+
{
86+
name: "force with empty dir succeeds",
87+
force: true,
88+
setupDir: true,
89+
addFile: false,
90+
expectError: false,
91+
},
92+
{
93+
name: "force with non-empty dir removes dir",
94+
force: true,
95+
setupDir: true,
96+
addFile: true,
97+
expectError: false,
98+
},
99+
}
100+
101+
for _, testCase := range tests {
102+
t.Run(testCase.name, func(t *testing.T) {
103+
testEnv := testutils.NewTestEnv(t)
104+
testFS := testEnv.TestFS
105+
106+
if testCase.setupDir {
107+
require.NoError(t, testFS.MkdirAll(outputDir, fileperms.PublicDir))
108+
}
109+
110+
if testCase.addFile {
111+
require.NoError(t, testFS.MkdirAll(outputDir, fileperms.PublicDir))
112+
113+
f, err := testFS.Create(staleFile)
114+
require.NoError(t, err)
115+
require.NoError(t, f.Close())
116+
}
117+
118+
options := &component.PrepareSourcesOptions{
119+
OutputDir: outputDir,
120+
Force: testCase.force,
121+
}
122+
123+
err := component.CheckOutputDir(testEnv.Env, options)
124+
125+
if testCase.expectError {
126+
require.Error(t, err)
127+
128+
for _, msg := range testCase.errorMsgContains {
129+
assert.Contains(t, err.Error(), msg)
130+
}
131+
} else {
132+
require.NoError(t, err)
133+
}
134+
135+
// Verify force actually removed the directory.
136+
if testCase.force && testCase.addFile {
137+
exists, err := fileutils.Exists(testFS, outputDir)
138+
require.NoError(t, err)
139+
assert.False(t, exists, "output directory should be removed when --force is used")
140+
}
141+
})
142+
}
143+
}

internal/utils/fileutils/dir.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ func ReadDir(fs opctx.FS, name string) (entries []os.FileInfo, err error) {
6363
//nolint:wrapcheck // We are intentionally a pass-through.
6464
return dir.Readdir(-1)
6565
}
66+
67+
// IsDirEmpty checks if a directory exists and is empty.
68+
func IsDirEmpty(fs opctx.FS, path string) (bool, error) {
69+
//nolint:wrapcheck // We are intentionally a pass-through.
70+
return afero.IsEmpty(fs, path)
71+
}

0 commit comments

Comments
 (0)