Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/data/changelog/v1.0.1.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ Previously, the run queue display showed units in apply order even for destroy c

Dependents are now correctly discovered when units are discovered in worktrees. Previously, dependent discovery could fail to find related units when operating within a git worktree.

#### Filter exclusions now respected in worktree sub-discoveries

Negated filters (e.g., `!./catalog/**` from `.terragrunt-filters` or `--filter`) are now propagated to worktree sub-discoveries used by git-based filtering (`--filter-affected`, `--filter '[ref...ref]'`).

Previously, excluded source catalog units in worktrees were still discovered and parsed, causing errors when they referenced `values.*` or `dependency.*` variables without the stack generation context.

#### `read_terragrunt_config()` behavior in implicit stacks fixed

A regression introduced in v0.99.4 caused `read_terragrunt_config()` to fail to parse `dependency` blocks in external configurations during stack execution. This is fixed by resetting parsing context fields that prevented proper evaluation of dependencies in configurations read by `read_terragrunt_config()`.
Expand Down
14 changes: 11 additions & 3 deletions internal/discovery/phase_worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,14 @@ func (p *WorktreePhase) discoverInWorktree(
return nil, err
}

// Propagate non-git filters from the parent discovery to the worktree sub-discovery.
// Git expressions are excluded to avoid infinite recursion (the worktree phase is
// already handling them). All other filters (path, attribute, negation) are included
// so that exclusions and type constraints apply within sub-discoveries.
allFilters := slices.Concat(filters, discovery.filters.ExcludingGitFilters())

subDiscovery := NewDiscovery(wt.Path).
WithFilters(filters).
WithFilters(allFilters).
WithDiscoveryContext(discoveryContext).
WithNumWorkers(p.numWorkers)

Expand Down Expand Up @@ -312,10 +318,12 @@ func (p *WorktreePhase) walkChangedStack(
errs = make([]error, 0, 2) //nolint:mnd
)

parentFilters := discovery.filters.ExcludingGitFilters()

discoveryGroup.Go(func() error {
fromDiscovery := NewDiscovery(fromStack.Path()).
WithDiscoveryContext(fromDiscoveryContext).
WithFilters(filter.Filters{}).
WithFilters(parentFilters).
WithNumWorkers(p.numWorkers)

var fromDiscoveryErr error
Expand Down Expand Up @@ -343,7 +351,7 @@ func (p *WorktreePhase) walkChangedStack(
discoveryGroup.Go(func() error {
toDiscovery := NewDiscovery(toStack.Path()).
WithDiscoveryContext(toDiscoveryContext).
WithFilters(filter.Filters{}).
WithFilters(parentFilters).
WithNumWorkers(p.numWorkers)

var toDiscoveryErr error
Expand Down
247 changes: 247 additions & 0 deletions internal/discovery/phase_worktree_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2365,3 +2365,250 @@ unit "myapp" {
"Expected at least one unit to be discovered when a read file changes, "+
"but got no units. All components: %v", componentPaths)
}

// TestWorktreePhase_Integration_NegatedFiltersAppliedInWorktreeSubDiscoveries tests that
// negated path filters (e.g., from .terragrunt-filters) are applied within worktree
// sub-discoveries, not just during the final filter evaluation.
// This is a regression test for #5821: source catalog units in worktrees were being
// discovered and parsed despite being excluded by a negated path filter, because the
// worktree sub-discoveries did not receive the exclusion filters.
func TestWorktreePhase_Integration_NegatedFiltersAppliedInWorktreeSubDiscoveries(t *testing.T) {
t.Parallel()

tmpDir, runner := setupGitRepo(t)

// Create two units: one that should be discovered and one that should be excluded.
createUnit(t, tmpDir, "app", `# App unit`)
// This catalog unit references values.* — it's a template that only works
// when generated through a stack. Without the fix, the worktree sub-discovery
// tries to parse it and fails with "Unknown variable: values".
createUnit(t, tmpDir, "catalog/units/svc", `
locals {
environment = values.environment
}
`)

commitChanges(t, runner, "Initial commit")

// Modify both units
err := os.WriteFile(filepath.Join(tmpDir, "app", "terragrunt.hcl"), []byte(`# Modified app`), 0o644)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(tmpDir, "catalog", "units", "svc", "terragrunt.hcl"), []byte(`
locals {
environment = values.environment
region = "us-east-1"
}
`), 0o644)
require.NoError(t, err)

commitChanges(t, runner, "Modify both units")

l := logger.CreateLogger()

// Parse filters: git expression + negated path that excludes catalog
filterQueries := []string{"[HEAD~1...HEAD]", "!./catalog/**"}
filters, parseErr := filter.ParseFilterQueries(l, filterQueries)
require.NoError(t, parseErr)

w, err := worktrees.NewWorktrees(t.Context(), l, worktrees.WorktreeOpts{
WorkingDir: tmpDir,
GitExpressions: filters.UniqueGitFilters(),
})
require.NoError(t, err)

t.Cleanup(func() {
cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l)
require.NoError(t, cleanupErr)
})

opts := options.NewTerragruntOptions()
opts.WorkingDir = tmpDir
opts.RootWorkingDir = tmpDir

d := discovery.NewDiscovery(tmpDir).
WithDiscoveryContext(&component.DiscoveryContext{
WorkingDir: tmpDir,
Cmd: "plan",
}).
WithWorktrees(w).
WithRelationships().
WithFilters(filters)

components, err := d.Discover(t.Context(), l, opts)
require.NoError(t, err)

unitPaths := components.Filter(component.UnitKind).Paths()

worktreePair := w.WorktreePairs["[HEAD~1...HEAD]"]
require.NotEmpty(t, worktreePair)

toWorktree := worktreePair.ToWorktree.Path

// The app unit should be discovered (it's in the git diff and not excluded)
assert.Contains(t, unitPaths, filepath.Join(toWorktree, "app"),
"app unit should be discovered")

// The catalog unit should NOT be discovered (excluded by !./catalog/**)
for _, p := range unitPaths {
assert.NotContains(t, p, "catalog",
"catalog units should be excluded by the negated filter, but found: %s", p)
}
}

// TestWorktreePhase_Integration_GitFilterExclusionPreventsParsingInWorktree verifies that
// a negated filter prevents excluded units from being parsed inside worktree sub-discoveries.
// The land-mine unit uses run_cmd("exit 1") which would cause a fatal parse error if evaluated.
func TestWorktreePhase_Integration_GitFilterExclusionPreventsParsingInWorktree(t *testing.T) {
t.Parallel()

tmpDir, runner := setupGitRepo(t)

createUnit(t, tmpDir, "app", `# App unit`)
createUnit(t, tmpDir, "land-mine", `
locals {
boom = run_cmd("--terragrunt-quiet", "bash", "-c", "exit 1")
}
`)

commitChanges(t, runner, "Initial commit")

err := os.WriteFile(filepath.Join(tmpDir, "app", "terragrunt.hcl"), []byte(`# Modified app`), 0o644)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(tmpDir, "land-mine", "terragrunt.hcl"), []byte(`
locals {
boom = run_cmd("--terragrunt-quiet", "bash", "-c", "exit 1")
modified = true
}
`), 0o644)
require.NoError(t, err)

commitChanges(t, runner, "Modify both units")

l := logger.CreateLogger()
filters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]", "!./land-mine"})
require.NoError(t, parseErr)

w, err := worktrees.NewWorktrees(t.Context(), l, worktrees.WorktreeOpts{
WorkingDir: tmpDir,
GitExpressions: filters.UniqueGitFilters(),
})
require.NoError(t, err)

t.Cleanup(func() {
cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l)
require.NoError(t, cleanupErr)
})

opts := options.NewTerragruntOptions()
opts.WorkingDir = tmpDir
opts.RootWorkingDir = tmpDir

d := discovery.NewDiscovery(tmpDir).
WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir, Cmd: "plan"}).
WithWorktrees(w).
WithRelationships().
WithFilters(filters)

// If the land-mine unit is parsed, run_cmd("exit 1") causes a fatal error.
components, err := d.Discover(t.Context(), l, opts)
require.NoError(t, err)

unitPaths := components.Filter(component.UnitKind).Paths()

toWorktree := w.WorktreePairs["[HEAD~1...HEAD]"].ToWorktree.Path
assert.Contains(t, unitPaths, filepath.Join(toWorktree, "app"), "app should be discovered")

for _, p := range unitPaths {
assert.NotContains(t, p, "land-mine", "land-mine should not be discovered: %s", p)
}
}

// TestWorktreePhase_Integration_StackDiscoveryDoesNotParseUnits verifies that
// discoverStacks (via GenerateStacks) does not parse non-stack components even when
// reading filters trigger the parse phase. The land-mine unit uses run_cmd("exit 1")
// which would cause a fatal error if parsed.
func TestWorktreePhase_Integration_StackDiscoveryDoesNotParseUnits(t *testing.T) {
t.Parallel()

tmpDir, runner := setupGitRepo(t)

// Create a land-mine unit at the repo root
createUnit(t, tmpDir, "land-mine", `
locals {
boom = run_cmd("--terragrunt-quiet", "bash", "-c", "exit 1")
}
`)

// Create a catalog unit for the stack to source
catalogDir := filepath.Join(tmpDir, "catalog", "units", "myapp")
err := os.MkdirAll(catalogDir, 0o755)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(catalogDir, "terragrunt.hcl"), []byte(`# catalog unit`), 0o644)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(catalogDir, "main.tf"), []byte(`output "ok" { value = "ok" }`), 0o644)
require.NoError(t, err)

// Create a stack that reads a config file (triggers reading filter path in worktreeStacksToGenerate)
stackDir := filepath.Join(tmpDir, "live", "stack")
err = os.MkdirAll(stackDir, 0o755)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(stackDir, "config.hcl"), []byte(`inputs = { v = "v1" }`), 0o644)
require.NoError(t, err)

err = os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(`
locals {
config = read_terragrunt_config("config.hcl")
}

unit "myapp" {
source = "${get_repo_root()}/catalog/units/myapp"
path = "myapp"
values = local.config.inputs
}
`), 0o644)
require.NoError(t, err)

commitChanges(t, runner, "Initial commit")

// Change only the config file (triggers reading filter, not a direct stack change)
err = os.WriteFile(filepath.Join(stackDir, "config.hcl"), []byte(`inputs = { v = "v2" }`), 0o644)
require.NoError(t, err)

commitChanges(t, runner, "Update config file")

l := logger.CreateLogger()
gitExpressions := filter.GitExpressions{filter.NewGitExpression("HEAD~1", "HEAD")}

w, err := worktrees.NewWorktrees(t.Context(), l, worktrees.WorktreeOpts{
WorkingDir: tmpDir,
GitExpressions: gitExpressions,
})
require.NoError(t, err)

t.Cleanup(func() {
cleanupErr := w.Cleanup(context.WithoutCancel(t.Context()), l)
require.NoError(t, cleanupErr)
})

opts := options.NewTerragruntOptions()
opts.WorkingDir = tmpDir
opts.RootWorkingDir = tmpDir

parsedFilters, parseErr := filter.ParseFilterQueries(l, []string{"[HEAD~1...HEAD]"})
require.NoError(t, parseErr)

opts.Filters = parsedFilters
opts.Experiments = experiment.NewExperiments()
err = opts.Experiments.EnableExperiment(experiment.FilterFlag)
require.NoError(t, err)

// GenerateStacks internally calls discoverStacks with reading filters.
// If the land-mine unit is parsed, run_cmd("exit 1") causes a fatal error.
err = generate.GenerateStacks(t.Context(), l, opts, w)
require.NoError(t, err)
}
Loading
Loading