Repository: gruntwork-io/terragrunt

Issue:
terraform_binary attribute is ignored when cli-redesign experiment is enabled
## Describe 
terraform_binary attribute is ignored when cli-redesign experiment is enabled

## Steps To Reproduce

```
❯ cd /tmp/tg
  /tmp/tg                                                                                                                                                                                                                                                      at  02:26:27 PM
❯ l
.rw-rw-r-- 1 mkeisler mkeisler 31 B Fri Mar 21 14:25:19 2025  terragrunt.hcl
❯ cat terragrunt.hcl
terraform_binary = "terraform"
❯ tg --experiment cli-redesign run version
OpenTofu v1.9.0
on linux_amd64
❯ tg --experiment cli-redesign run --tf-path=terraform version
Terraform v1.9.8
on linux_amd64
❯ tg version
terragrunt version v0.76.5
```

## Expected behavior

honor the terraform_binary attribute.


Hints from issue comments:

Solution patch:
diff --git a/cli/commands/backend/bootstrap/bootstrap.go b/cli/commands/backend/bootstrap/bootstrap.go
index 0f76ccf0..4e11fd0d 100644
--- a/cli/commands/backend/bootstrap/bootstrap.go
+++ b/cli/commands/backend/bootstrap/bootstrap.go
@@ -13,6 +13,11 @@ func Run(ctx context.Context, opts *options.TerragruntOptions) error {
 	if err != nil {
 		return err
 	}
+	if remoteState == nil {
+		opts.Logger.Debug("Did not find remote `remote_state` block in the config")
+
+		return nil
+	}
 
 	return remoteState.Init(ctx, opts)
 }
diff --git a/cli/commands/backend/delete/delete.go b/cli/commands/backend/delete/delete.go
index 901984e4..12985004 100644
--- a/cli/commands/backend/delete/delete.go
+++ b/cli/commands/backend/delete/delete.go
@@ -15,6 +15,11 @@ func Run(ctx context.Context, cmdOpts *Options) error {
 	if err != nil {
 		return err
 	}
+	if remoteState == nil {
+		opts.Logger.Debug("Did not find remote `remote_state` block in the config")
+
+		return nil
+	}
 
 	if cmdOpts.DeleteBucket {
 		// TODO: Do an extra check before commenting out the code. //return remoteState.DeleteBucket(ctx, opts)
diff --git a/cli/commands/run/run.go b/cli/commands/run/run.go
index 0bba11a2..6c82f93f 100644
--- a/cli/commands/run/run.go
+++ b/cli/commands/run/run.go
@@ -85,16 +85,16 @@ func Run(ctx context.Context, opts *options.TerragruntOptions) error {
 		return errors.New(MissingCommand{})
 	}
 
-	return runTerraform(ctx, opts, new(Target))
+	return run(ctx, opts, new(Target))
 }
 
 func RunWithTarget(ctx context.Context, opts *options.TerragruntOptions, target *Target) error {
-	return runTerraform(ctx, opts, target)
+	return run(ctx, opts, target)
 }
 
-func runTerraform(ctx context.Context, terragruntOptions *options.TerragruntOptions, target *Target) error {
+func run(ctx context.Context, terragruntOptions *options.TerragruntOptions, target *Target) error {
 	if terragruntOptions.TerraformCommand == tf.CommandNameVersion {
-		return tf.RunCommand(ctx, terragruntOptions, tf.CommandNameVersion)
+		return runVersionCommand(ctx, terragruntOptions)
 	}
 
 	// We need to get the credentials from auth-provider-cmd at the very beginning, since the locals block may contain `get_aws_account_id()` func.
@@ -907,3 +907,15 @@ func setTerragruntNullValues(terragruntOptions *options.TerragruntOptions, terra
 func useLegacyNullValues() bool {
 	return os.Getenv(useLegacyNullValuesEnvVar) == "1"
 }
+
+func getTerragruntConfig(ctx context.Context, opts *options.TerragruntOptions) (*config.TerragruntConfig, error) {
+	configCtx := config.NewParsingContext(ctx, opts).WithDecodeList(
+		config.TerragruntVersionConstraints, config.FeatureFlagsBlock)
+
+	// TODO: See if we should be ignore this lint error
+	return config.PartialParseConfigFile( //nolint: contextcheck
+		configCtx,
+		opts.TerragruntConfigPath,
+		nil,
+	)
+}
diff --git a/cli/commands/run/version_check.go b/cli/commands/run/version_check.go
index 90fc58f6..623521fe 100644
--- a/cli/commands/run/version_check.go
+++ b/cli/commands/run/version_check.go
@@ -7,7 +7,6 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/internal/errors"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/tf"
@@ -35,15 +34,7 @@ const versionParts = 3
 // - FeatureFlags
 // TODO: Look into a way to refactor this function to avoid the side effect.
 func CheckVersionConstraints(ctx context.Context, terragruntOptions *options.TerragruntOptions) error {
-	configContext := config.NewParsingContext(ctx, terragruntOptions).WithDecodeList(
-		config.TerragruntVersionConstraints, config.FeatureFlagsBlock)
-
-	// TODO: See if we should be ignore this lint error
-	partialTerragruntConfig, err := config.PartialParseConfigFile( //nolint: contextcheck
-		configContext,
-		terragruntOptions.TerragruntConfigPath,
-		nil,
-	)
+	partialTerragruntConfig, err := getTerragruntConfig(ctx, terragruntOptions)
 	if err != nil {
 		return err
 	}
diff --git a/cli/commands/run/version_command.go b/cli/commands/run/version_command.go
new file mode 100644
index 00000000..538350ee
--- /dev/null
+++ b/cli/commands/run/version_command.go
@@ -0,0 +1,34 @@
+package run
+
+import (
+	"context"
+
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/tf"
+	"github.com/gruntwork-io/terragrunt/util"
+)
+
+func runVersionCommand(ctx context.Context, opts *options.TerragruntOptions) error {
+	if tfPath, err := getTfPathFromConfig(ctx, opts); err != nil {
+		return err
+	} else if tfPath != "" {
+		opts.TerraformPath = tfPath
+	}
+
+	return tf.RunCommand(ctx, opts, tf.CommandNameVersion)
+}
+
+func getTfPathFromConfig(ctx context.Context, opts *options.TerragruntOptions) (string, error) {
+	if !util.FileExists(opts.TerragruntConfigPath) {
+		opts.Logger.Debugf("Did not find the config file %s", opts.TerragruntConfigPath)
+
+		return "", nil
+	}
+
+	cfg, err := getTerragruntConfig(ctx, opts)
+	if err != nil {
+		return "", err
+	}
+
+	return cfg.TerraformBinary, nil
+}



Test patch:
diff --git a/internal/remotestate/backend/gcs/backend.go b/internal/remotestate/backend/gcs/backend.go
index 5e321734..0ff58358 100644
--- a/internal/remotestate/backend/gcs/backend.go
+++ b/internal/remotestate/backend/gcs/backend.go
@@ -147,7 +147,14 @@ func (backend *Backend) Delete(ctx context.Context, backendConfig backend.Config
 		prefix     = extGCSCfg.RemoteStateConfigGCS.Prefix
 	)
 
-	return client.DeleteGCSObjectIfNecessary(ctx, bucketName, prefix)
+	prompt := fmt.Sprintf("GCS bucket %s objects with prefix %s will be deleted. Do you want to continue?", bucketName, prefix)
+	if yes, err := shell.PromptUserForYesNo(ctx, prompt, opts); err != nil {
+		return err
+	} else if yes {
+		return client.DeleteGCSObjectIfNecessary(ctx, bucketName, prefix)
+	}
+
+	return nil
 }
 
 // DeleteBucket deletes the entire bucket specified in the given config.
diff --git a/internal/remotestate/backend/s3/backend.go b/internal/remotestate/backend/s3/backend.go
index 907c0bb8..5c12a301 100644
--- a/internal/remotestate/backend/s3/backend.go
+++ b/internal/remotestate/backend/s3/backend.go
@@ -159,12 +159,24 @@ func (backend *Backend) Delete(ctx context.Context, backendConfig backend.Config
 	if tableName != "" {
 		tableKey := path.Join(bucketName, bucketKey+stateIDSuffix)
 
-		if err := client.DeleteTableItemIfNecessary(ctx, tableName, tableKey); err != nil {
+		prompt := fmt.Sprintf("DynamoDB table %s key %s will be deleted. Do you want to continue?", tableName, tableKey)
+		if yes, err := shell.PromptUserForYesNo(ctx, prompt, opts); err != nil {
 			return err
+		} else if yes {
+			if err := client.DeleteTableItemIfNecessary(ctx, tableName, tableKey); err != nil {
+				return err
+			}
 		}
 	}
 
-	return client.DeleteS3ObjectIfNecessary(ctx, bucketName, bucketKey)
+	prompt := fmt.Sprintf("S3 bucket %s key %s will be deleted. Do you want to continue?", bucketName, bucketKey)
+	if yes, err := shell.PromptUserForYesNo(ctx, prompt, opts); err != nil {
+		return err
+	} else if yes {
+		return client.DeleteS3ObjectIfNecessary(ctx, bucketName, bucketKey)
+	}
+
+	return nil
 }
 
 // DeleteBucket deletes the entire bucket specified in the given config.
diff --git a/test/fixtures/tf-path/main.tf b/test/fixtures/tf-path/main.tf
new file mode 100644
index 00000000..bb7b160d
--- /dev/null
+++ b/test/fixtures/tf-path/main.tf
@@ -0,0 +1 @@
+# Intentionally empty
diff --git a/test/fixtures/tf-path/terragrunt.hcl b/test/fixtures/tf-path/terragrunt.hcl
new file mode 100644
index 00000000..1d5fd9a8
--- /dev/null
+++ b/test/fixtures/tf-path/terragrunt.hcl
@@ -0,0 +1,2 @@
+# We can't explicitly specify `tofu` or `terraform` because a CircleCI job contains either `terraform` or `tofu` binary, but not both in the same job.
+terraform_binary = "./tf.sh"
diff --git a/test/fixtures/tf-path/tf.sh b/test/fixtures/tf-path/tf.sh
new file mode 100755
index 00000000..5690151c
--- /dev/null
+++ b/test/fixtures/tf-path/tf.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+tfpath="${TG_TF_PATH:-tofu}"
+
+$tfpath "$@"
diff --git a/test/integration_test.go b/test/integration_test.go
index db0db98f..15e05e8a 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -111,6 +111,7 @@ const (
 	testFixtureLogStreaming                   = "fixtures/streaming"
 	testFixtureCLIFlagHints                   = "fixtures/cli-flag-hints"
 	testFixtureEphemeralInputs                = "fixtures/ephemeral-inputs"
+	testFixtureTfPath                         = "fixtures/tf-path"
 
 	terraformFolder = ".terraform"
 
@@ -1067,6 +1068,10 @@ func TestTerraformCommandCliArgs(t *testing.T) {
 		expected    string
 		command     []string
 	}{
+		{
+			command:  []string{"version"},
+			expected: wrappedBinary() + " version",
+		},
 		{
 			command:  []string{"--", "version"},
 			expected: wrappedBinary() + " version",
@@ -4173,3 +4178,19 @@ func TestTF110EphemeralVars(t *testing.T) {
 	require.NoError(t, err)
 	assert.Contains(t, stdout, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed")
 }
+
+func TestTfPath(t *testing.T) {
+	t.Parallel()
+	// Test that the terragrunt run version command correctly identifies and uses
+	// the terraform_binary path configuration if present
+	helpers.CleanupTerraformFolder(t, testFixtureTfPath)
+	rootPath := helpers.CopyEnvironment(t, testFixtureTfPath)
+	workingDir := util.JoinPath(rootPath, testFixtureTfPath)
+	workingDir, err := filepath.EvalSymlinks(workingDir)
+	require.NoError(t, err)
+
+	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run version --experiment cli-redesign --working-dir "+workingDir)
+	require.NoError(t, err)
+
+	assert.Regexp(t, "(?i)(terraform|opentofu)", stdout+stderr)
+}