Repository: gruntwork-io/terragrunt

Issue:
Recursive `terragrunt stack clean`
## Describe the enhancement

A flag (or if desired a change to default behavior) that would allow `terragrunt stack clean` to clean recursively down a directory hierarchy.

## Additional context

It seems `generate` is more inclusive than `clean` and it would speed up local development considerably if there was a way to have clean even optionally exhibit the same inclusivity of directories. 

Ex. `terragrunt stack clean` in the `stacks` directory as follows

```
stacks/
  /stack01/terragrunt.stack.hcl
  /stack02/terragrunt.stack.hcl
```

will not clean `.terragrunt-stack/` folders

Discussion:

Solution patch:
diff --git a/cli/commands/stack/stack.go b/cli/commands/stack/stack.go
index 29383ebc..b1595c59 100644
--- a/cli/commands/stack/stack.go
+++ b/cli/commands/stack/stack.go
@@ -137,21 +137,53 @@ func FilterOutputs(outputs cty.Value, index string) cty.Value {
 	return nested
 }
 
-// RunClean cleans the stack directory
+// RunClean recursively removes all stack directories under the specified WorkingDir.
 func RunClean(ctx context.Context, opts *options.TerragruntOptions) error {
-	baseDir := filepath.Join(opts.WorkingDir, stackDir)
-	err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_clean", map[string]any{
+	telemeter := telemetry.TelemeterFromContext(ctx)
+	err := telemeter.Collect(ctx, "stack_clean", map[string]any{
 		"stack_config_path": opts.TerragruntStackConfigPath,
 		"working_dir":       opts.WorkingDir,
 	}, func(ctx context.Context) error {
-		opts.Logger.Debugf("Cleaning stack directory: %s", baseDir)
-		err := os.RemoveAll(baseDir)
+		errs := &errors.MultiError{}
 
-		return err
+		walkFn := func(path string, d os.DirEntry, walkErr error) error {
+			if walkErr != nil {
+				opts.Logger.Warnf("Error accessing path %s: %v", path, walkErr)
+
+				errs = errs.Append(walkErr)
+
+				return nil
+			}
+
+			if d.IsDir() && d.Name() == stackDir {
+				relPath, relErr := filepath.Rel(opts.WorkingDir, path)
+				if relErr != nil {
+					relPath = path // fallback to absolute if error
+				}
+
+				opts.Logger.Infof("Deleting stack directory: %s", relPath)
+
+				if rmErr := os.RemoveAll(path); rmErr != nil {
+					opts.Logger.Errorf("Failed to delete stack directory %s: %v", relPath, rmErr)
+
+					errs = errs.Append(rmErr)
+				}
+
+				return filepath.SkipDir
+			}
+
+			return nil
+		}
+
+		if walkErr := filepath.WalkDir(opts.WorkingDir, walkFn); walkErr != nil {
+			errs = errs.Append(walkErr)
+		}
+
+		return errs.ErrorOrNil()
 	})
 
 	if err != nil {
-		return errors.Errorf("failed to clean stack directory: %s %w", baseDir, err)
+		return errors.Errorf("failed to clean stack directories under %q: %w", opts.WorkingDir, err)
 	}
 
 	return nil



Test patch:
diff --git a/test/fixtures/stacks/nested/live-v2/terragrunt.stack.hcl b/test/fixtures/stacks/nested/live-v2/terragrunt.stack.hcl
new file mode 100644
index 00000000..3601ad1a
--- /dev/null
+++ b/test/fixtures/stacks/nested/live-v2/terragrunt.stack.hcl
@@ -0,0 +1,11 @@
+
+stack "dev" {
+	source = "${get_repo_root()}/stacks/dev"
+	path = "dev"
+}
+
+
+stack "prod" {
+	source = "${get_repo_root()}/stacks/prod"
+	path = "prod"
+}
diff --git a/test/integration_stacks_test.go b/test/integration_stacks_test.go
index e3475b94..2d17a416 100644
--- a/test/integration_stacks_test.go
+++ b/test/integration_stacks_test.go
@@ -249,6 +249,32 @@ func TestStacksApplyClean(t *testing.T) {
 	assert.NoDirExists(t, path)
 }
 
+func TestStackCleanRecursively(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureNestedStacks)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureNestedStacks)
+	gitPath := util.JoinPath(tmpEnvPath, testFixtureNestedStacks)
+	helpers.CreateGitRepo(t, gitPath)
+	live := util.JoinPath(gitPath, "live")
+
+	_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --working-dir "+live)
+	require.NoError(t, err)
+
+	liveV2 := util.JoinPath(gitPath, "live-v2")
+	_, _, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --working-dir "+liveV2)
+	require.NoError(t, err)
+
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack clean --working-dir "+gitPath)
+	require.NoError(t, err)
+
+	assert.NoDirExists(t, util.JoinPath(live, ".terragrunt-stack"))
+	assert.NoDirExists(t, util.JoinPath(liveV2, ".terragrunt-stack"))
+
+	assert.Contains(t, stderr, "Deleting stack directory: live/.terragrunt-stack")
+	assert.Contains(t, stderr, "Deleting stack directory: live-v2/.terragrunt-stack")
+}
+
 func TestStacksDestroy(t *testing.T) {
 	t.Parallel()