From 7a175c46b6b09afac4bccfc05c792a795e14db48 Mon Sep 17 00:00:00 2001 From: Igor Tsentylo Date: Thu, 28 Aug 2025 15:12:49 +0300 Subject: [PATCH 1/4] config(include): shallow merge IncludeInCopy/ExcludeFromCopy --- config/include.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/include.go b/config/include.go index 4c7cbad106..f073ce8980 100644 --- a/config/include.go +++ b/config/include.go @@ -307,6 +307,14 @@ func (cfg *TerragruntConfig) Merge(l log.Logger, sourceConfig *TerragruntConfig, cfg.Terraform.CopyTerraformLockFile = sourceConfig.Terraform.CopyTerraformLockFile } + if sourceConfig.Terraform.IncludeInCopy != nil { + cfg.Terraform.IncludeInCopy = sourceConfig.Terraform.IncludeInCopy + } + + if sourceConfig.Terraform.ExcludeFromCopy != nil { + cfg.Terraform.ExcludeFromCopy = sourceConfig.Terraform.ExcludeFromCopy + } + mergeExtraArgs(l, sourceConfig.Terraform.ExtraArgs, &cfg.Terraform.ExtraArgs) mergeHooks(l, sourceConfig.Terraform.BeforeHooks, &cfg.Terraform.BeforeHooks) From bf9bf5199527314167225833d5575821e170fcf5 Mon Sep 17 00:00:00 2001 From: Igor Tsentylo Date: Thu, 28 Aug 2025 15:24:02 +0300 Subject: [PATCH 2/4] tests(config): cover shallow+deep merge for copy filters --- config/include_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/config/include_test.go b/config/include_test.go index eb40191a71..cdbfbc5768 100644 --- a/config/include_test.go +++ b/config/include_test.go @@ -158,6 +158,30 @@ func TestMergeConfigIntoIncludedConfig(t *testing.T) { &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"abc"}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], ExcludeFromCopy: &[]string{"abc"}}}, }, + { + // child-only lists survive when parent has Terraform but no lists + &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc1", "child.inc2"}, ExcludeFromCopy: &[]string{"child.exc1"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc1", "child.inc2"}, ExcludeFromCopy: &[]string{"child.exc1"}}}, + }, + { + // parent-only lists remain when child has empty Terraform block + &config.TerragruntConfig{Terraform: &config.TerraformConfig{}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1"}, ExcludeFromCopy: &[]string{"parent.exc1"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1"}, ExcludeFromCopy: &[]string{"parent.exc1"}}}, + }, + { + // both set -> shallow merge keeps child lists (no concatenation in shallow) + &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc"}, ExcludeFromCopy: &[]string{"parent.exc"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + }, + { + // parent Terraform present but lists nil -> child lists must NOT be dropped + &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + }, } for _, tc := range testCases { @@ -336,6 +360,42 @@ func TestDeepMergeConfigIntoIncludedConfig(t *testing.T) { target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"abc"}}}, expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{CopyTerraformLockFile: &[]bool{false}[0], ExcludeFromCopy: &[]string{"abc"}}}, }, + { + name: "terraform parent only lists", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1"}, ExcludeFromCopy: &[]string{"parent.exc1"}}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1"}, ExcludeFromCopy: &[]string{"parent.exc1"}}}, + }, + { + name: "terraform child only lists", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc1"}, ExcludeFromCopy: &[]string{"child.exc1"}}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc1"}, ExcludeFromCopy: &[]string{"child.exc1"}}}, + }, + { + name: "terraform concatenate include/exclude lists", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1", "parent.inc2"}, ExcludeFromCopy: &[]string{"parent.exc1"}}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc1"}, ExcludeFromCopy: &[]string{"child.exc1", "child.exc2"}}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc1", "parent.inc2", "child.inc1"}, ExcludeFromCopy: &[]string{"parent.exc1", "child.exc1", "child.exc2"}}}, + }, + { + name: "terraform parent nil does not clobber child lists", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo")}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, + }, + { + name: "terraform parent empty slice treated as present (concat no-op + child)", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{}}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child"}}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child"}}}, + }, + { + name: "terraform duplicates preserved (no dedupe at merge layer)", + source: &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"a", "b"}}}, + target: &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"b", "c"}}}, + expected: &config.TerragruntConfig{Terraform: &config.TerraformConfig{ExcludeFromCopy: &[]string{"a", "b", "b", "c"}}}, + }, } for _, tc := range testCases { From 18bc2516c6a9594f30c1e97c14860b8eec13ed00 Mon Sep 17 00:00:00 2001 From: Igor Tsentylo Date: Thu, 28 Aug 2025 18:24:40 +0300 Subject: [PATCH 3/4] fix: Avoid aliasing: deep-copy slice pointers on shallow merge. --- config/include.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/config/include.go b/config/include.go index f073ce8980..07744e2c62 100644 --- a/config/include.go +++ b/config/include.go @@ -307,12 +307,14 @@ func (cfg *TerragruntConfig) Merge(l log.Logger, sourceConfig *TerragruntConfig, cfg.Terraform.CopyTerraformLockFile = sourceConfig.Terraform.CopyTerraformLockFile } - if sourceConfig.Terraform.IncludeInCopy != nil { - cfg.Terraform.IncludeInCopy = sourceConfig.Terraform.IncludeInCopy + if src := sourceConfig.Terraform.IncludeInCopy; src != nil { + copied := append([]string(nil), *src...) + cfg.Terraform.IncludeInCopy = &copied } - if sourceConfig.Terraform.ExcludeFromCopy != nil { - cfg.Terraform.ExcludeFromCopy = sourceConfig.Terraform.ExcludeFromCopy + if src := sourceConfig.Terraform.ExcludeFromCopy; src != nil { + copied := append([]string(nil), *src...) + cfg.Terraform.ExcludeFromCopy = &copied } mergeExtraArgs(l, sourceConfig.Terraform.ExtraArgs, &cfg.Terraform.ExtraArgs) From 5e54d3fd181b3ce6a94ef0212ae8314877e93ebd Mon Sep 17 00:00:00 2001 From: Igor Tsentylo Date: Thu, 28 Aug 2025 19:31:48 +0300 Subject: [PATCH 4/4] fix(config): add mergeListArgPreserveEmpty helper for include_in_copy / exclude_from_copy --- config/include.go | 28 +++++++++++++++++++--------- config/include_test.go | 6 ++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/config/include.go b/config/include.go index 07744e2c62..82076a7d94 100644 --- a/config/include.go +++ b/config/include.go @@ -307,15 +307,8 @@ func (cfg *TerragruntConfig) Merge(l log.Logger, sourceConfig *TerragruntConfig, cfg.Terraform.CopyTerraformLockFile = sourceConfig.Terraform.CopyTerraformLockFile } - if src := sourceConfig.Terraform.IncludeInCopy; src != nil { - copied := append([]string(nil), *src...) - cfg.Terraform.IncludeInCopy = &copied - } - - if src := sourceConfig.Terraform.ExcludeFromCopy; src != nil { - copied := append([]string(nil), *src...) - cfg.Terraform.ExcludeFromCopy = &copied - } + mergeListArgPreserveEmpty(sourceConfig.Terraform.IncludeInCopy, &cfg.Terraform.IncludeInCopy) + mergeListArgPreserveEmpty(sourceConfig.Terraform.ExcludeFromCopy, &cfg.Terraform.ExcludeFromCopy) mergeExtraArgs(l, sourceConfig.Terraform.ExtraArgs, &cfg.Terraform.ExtraArgs) @@ -685,6 +678,23 @@ func deepMergeDependencyBlocks(targetDependencies []Dependency, sourceDependenci return combinedDeps, nil } +// Merge list argument preserving empty lists. +// +// If child provides an explicit empty list, we store a non-nil empty slice +// to represent "clear the list", instead of a nil slice. +func mergeListArgPreserveEmpty(childListArg *[]string, parentListArg **[]string) { + if childListArg == nil { + return + } + if len(*childListArg) == 0 { + empty := make([]string, 0) + *parentListArg = &empty + return + } + copied := append([]string(nil), *childListArg...) + *parentListArg = &copied +} + // Merge the extra arguments. // // If a child's extra_arguments has the same name a parent's extra_arguments, diff --git a/config/include_test.go b/config/include_test.go index cdbfbc5768..9c44b389cf 100644 --- a/config/include_test.go +++ b/config/include_test.go @@ -176,6 +176,12 @@ func TestMergeConfigIntoIncludedConfig(t *testing.T) { &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc"}, ExcludeFromCopy: &[]string{"parent.exc"}}}, &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}}, }, + { + // child explicitly empties lists -> overrides and clears parent + &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{}, ExcludeFromCopy: &[]string{}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{"parent.inc"}, ExcludeFromCopy: &[]string{"parent.exc"}}}, + &config.TerragruntConfig{Terraform: &config.TerraformConfig{Source: ptr("foo"), IncludeInCopy: &[]string{}, ExcludeFromCopy: &[]string{}}}, + }, { // parent Terraform present but lists nil -> child lists must NOT be dropped &config.TerragruntConfig{Terraform: &config.TerraformConfig{IncludeInCopy: &[]string{"child.inc"}, ExcludeFromCopy: &[]string{"child.exc"}}},