feat: add refresh-only flag for plan and apply methods (#402)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b83a3cf..443e17f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
 
  - Fix bug in which the `TF_WORKSPACE` env var was set to an empty string, instead of being unset as intended. [GH-388]
 
+ENHANCEMENTS:
+
+- tfexec: Add `-refresh-only` to `(Terraform).Apply()` and `(Terraform).Plan()` methods ([#402](https://github.com/hashicorp/terraform-exec/pull/402))
+
 # 0.18.1 (March 01, 2023)
 
 BUG FIXES:
diff --git a/tfexec/apply.go b/tfexec/apply.go
index a13ff85..2c5a6d0 100644
--- a/tfexec/apply.go
+++ b/tfexec/apply.go
@@ -22,6 +22,7 @@
 	parallelism  int
 	reattachInfo ReattachInfo
 	refresh      bool
+	refreshOnly  bool
 	replaceAddrs []string
 	state        string
 	stateOut     string
@@ -80,6 +81,10 @@
 	conf.refresh = opt.refresh
 }
 
+func (opt *RefreshOnlyOption) configureApply(conf *applyConfig) {
+	conf.refreshOnly = opt.refreshOnly
+}
+
 func (opt *ReplaceOption) configureApply(conf *applyConfig) {
 	conf.replaceAddrs = append(conf.replaceAddrs, opt.address)
 }
@@ -187,6 +192,17 @@
 	args = append(args, "-parallelism="+fmt.Sprint(c.parallelism))
 	args = append(args, "-refresh="+strconv.FormatBool(c.refresh))
 
+	if c.refreshOnly {
+		err := tf.compatible(ctx, tf0_15_4, nil)
+		if err != nil {
+			return nil, fmt.Errorf("refresh-only option was introduced in Terraform 0.15.4: %w", err)
+		}
+		if !c.refresh {
+			return nil, fmt.Errorf("you cannot use refresh=false in refresh-only planning mode")
+		}
+		args = append(args, "-refresh-only")
+	}
+
 	// string slice opts: split into separate args
 	if c.replaceAddrs != nil {
 		err := tf.compatible(ctx, tf0_15_2, nil)
diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go
index c226a1f..8e2ddee 100644
--- a/tfexec/apply_test.go
+++ b/tfexec/apply_test.go
@@ -69,6 +69,26 @@
 			"testfile",
 		}, nil, applyCmd)
 	})
+
+	t.Run("refresh-only operation", func(t *testing.T) {
+		applyCmd, err := tf.applyCmd(context.Background(),
+			RefreshOnly(true),
+		)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"apply",
+			"-no-color",
+			"-auto-approve",
+			"-input=false",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+			"-refresh-only",
+		}, nil, applyCmd)
+	})
 }
 
 func TestApplyJSONCmd(t *testing.T) {
diff --git a/tfexec/options.go b/tfexec/options.go
index 5cccde3..5f04680 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -327,6 +327,14 @@
 	return &RefreshOption{refresh}
 }
 
+type RefreshOnlyOption struct {
+	refreshOnly bool
+}
+
+func RefreshOnly(refreshOnly bool) *RefreshOnlyOption {
+	return &RefreshOnlyOption{refreshOnly}
+}
+
 type ReplaceOption struct {
 	address string
 }
diff --git a/tfexec/plan.go b/tfexec/plan.go
index a4aacfb..946ce8d 100644
--- a/tfexec/plan.go
+++ b/tfexec/plan.go
@@ -20,6 +20,7 @@
 	parallelism  int
 	reattachInfo ReattachInfo
 	refresh      bool
+	refreshOnly  bool
 	replaceAddrs []string
 	state        string
 	targets      []string
@@ -68,6 +69,10 @@
 	conf.refresh = opt.refresh
 }
 
+func (opt *RefreshOnlyOption) configurePlan(conf *planConfig) {
+	conf.refreshOnly = opt.refreshOnly
+}
+
 func (opt *ReplaceOption) configurePlan(conf *planConfig) {
 	conf.replaceAddrs = append(conf.replaceAddrs, opt.address)
 }
@@ -202,6 +207,17 @@
 	args = append(args, "-parallelism="+fmt.Sprint(c.parallelism))
 	args = append(args, "-refresh="+strconv.FormatBool(c.refresh))
 
+	if c.refreshOnly {
+		err := tf.compatible(ctx, tf0_15_4, nil)
+		if err != nil {
+			return nil, fmt.Errorf("refresh-only option was introduced in Terraform 0.15.4: %w", err)
+		}
+		if !c.refresh {
+			return nil, fmt.Errorf("you cannot use refresh=false in refresh-only planning mode")
+		}
+		args = append(args, "-refresh-only")
+	}
+
 	// unary flags: pass if true
 	if c.replaceAddrs != nil {
 		err := tf.compatible(ctx, tf0_15_2, nil)
diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go
index 8abfb8e..b0c404e 100644
--- a/tfexec/plan_test.go
+++ b/tfexec/plan_test.go
@@ -82,6 +82,25 @@
 			"earth",
 		}, nil, planCmd)
 	})
+
+	t.Run("run a refresh-only plan", func(t *testing.T) {
+		planCmd, err := tf.planCmd(context.Background(), RefreshOnly(true))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"plan",
+			"-no-color",
+			"-input=false",
+			"-detailed-exitcode",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+			"-refresh-only",
+		}, nil, planCmd)
+	})
 }
 
 func TestPlanJSONCmd(t *testing.T) {
diff --git a/tfexec/version.go b/tfexec/version.go
index 6233212..4ba4f6e 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -29,6 +29,7 @@
 	tf0_15_0 = version.Must(version.NewVersion("0.15.0"))
 	tf0_15_2 = version.Must(version.NewVersion("0.15.2"))
 	tf0_15_3 = version.Must(version.NewVersion("0.15.3"))
+	tf0_15_4 = version.Must(version.NewVersion("0.15.4"))
 	tf1_1_0  = version.Must(version.NewVersion("1.1.0"))
 	tf1_4_0  = version.Must(version.NewVersion("1.4.0"))
 	tf1_6_0  = version.Must(version.NewVersion("1.6.0"))