diff --git a/benchmarks/changed/changed_bench_test.go b/benchmarks/changed/changed_bench_test.go index a1581e3dc9..33eb6d77bf 100644 --- a/benchmarks/changed/changed_bench_test.go +++ b/benchmarks/changed/changed_bench_test.go @@ -42,7 +42,7 @@ func BenchmarkChangeDetection(b *testing.B) { b.StartTimer() for i := 0; i < b.N; i++ { - report, err := manager.ListChanged("origin/main") + report, err := manager.ListChanged(stack.ChangeConfig{BaseRef: "origin/main"}) assert.NoError(b, err) assert.EqualInts(b, 1, len(report.Stacks)) assert.EqualStrings(b, fmt.Sprintf("/stack-%d", nstacks-1), report.Stacks[0].Stack.Dir.String()) @@ -91,7 +91,7 @@ func BenchmarkChangeDetectionTFAndTG(b *testing.B) { b.StartTimer() for i := 0; i < b.N; i++ { - report, err := manager.ListChanged("origin/main") + report, err := manager.ListChanged(stack.ChangeConfig{BaseRef: "origin/main"}) assert.NoError(b, err) assert.EqualInts(b, 2, len(report.Stacks)) assert.EqualStrings(b, fmt.Sprintf("/stack-%d", nTfStacks-1), report.Stacks[0].Stack.Dir.String()) diff --git a/cmd/terramate/cli/cli.go b/cmd/terramate/cli/cli.go index f36b3666ea..c73c612f8d 100644 --- a/cmd/terramate/cli/cli.go +++ b/cmd/terramate/cli/cli.go @@ -12,6 +12,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "time" @@ -150,10 +151,13 @@ type cliSpec struct { cloudFilterFlags Target string `help:"Select the deployment target of the filtered stacks."` RunOrder bool `default:"false" help:"Sort listed stacks by order of execution"` + + disableChangeDetectionFlag } `cmd:"" help:"List stacks."` Run struct { cloudFilterFlags + enableChangeDetectionFlag Target string `help:"Set the deployment target for stacks synchronized to Terramate Cloud."` FromTarget string `help:"Migrate stacks from given deployment target."` EnableSharing bool `help:"Enable sharing of stack outputs as stack inputs."` @@ -200,6 +204,7 @@ type cliSpec struct { } `cmd:"" help:"Show detailed information about a script"` Run struct { cloudFilterFlags + enableChangeDetectionFlag Target string `help:"Set the deployment target for stacks synchronized to Terramate Cloud."` FromTarget string `help:"Migrate stacks from given deployment target."` NoRecursive bool `default:"false" help:"Do not recurse into nested child stacks."` @@ -253,6 +258,7 @@ type cliSpec struct { IgnoreChange bool `default:"false" help:"Trigger stacks to be ignored by change detection"` Reason string `default:"" name:"reason" help:"Set a reason for triggering the stack."` cloudFilterFlags + disableChangeDetectionFlag } `cmd:"" help:"Mark a stack as changed so it will be triggered in Change Detection."` RunGraph struct { @@ -334,6 +340,14 @@ type cloudFilterFlags struct { DriftStatus string `help:"Filter by Terramate Cloud drift status of the stack"` } +type disableChangeDetectionFlag struct { + DisableChangeDetection []string `help:"Disable specific change detection modes" enum:"git-untracked,git-uncommitted"` +} + +type enableChangeDetectionFlag struct { + EnableChangeDetection []string `help:"Enable specific change detection modes" enum:"git-untracked,git-uncommitted"` +} + // Exec will execute terramate with the provided flags defined on args. // Only flags should be on the args slice. // @@ -380,6 +394,13 @@ type cli struct { checkpointResults chan *checkpoint.CheckResponse tags filter.TagClause + + changeDetection changeDetection +} + +type changeDetection struct { + untracked *bool + uncommitted *bool } func newCLI(version string, args []string, stdin io.Reader, stdout, stderr io.Writer) *cli { @@ -662,11 +683,13 @@ func (c *cli) run() { c.scanCreate() case "list": c.setupGit() + c.setupListChangeDetection() c.printStacks() case "run": fatal("no command specified") case "run ": c.setupGit() + c.setupRunChangeDetection() c.setupSafeguards(c.parsedArgs.Run.runSafeguardsCliSpec) c.runOnStacks() case "generate": @@ -674,8 +697,10 @@ func (c *cli) run() { case "experimental clone ": c.cloneStack() case "experimental trigger": + c.setupRunChangeDetection() c.triggerStackByFilter() case "experimental trigger ": + c.setupRunChangeDetection() c.triggerStack(c.parsedArgs.Experimental.Trigger.Stack) case "experimental vendor download ": c.vendorDownload() @@ -693,6 +718,7 @@ func (c *cli) run() { c.generateGraph() case "experimental run-order": c.setupGit() + c.setupRunChangeDetection() c.printRunOrder(false) case "debug show runtime-env": c.setupGit() @@ -735,6 +761,7 @@ func (c *cli) run() { case "script run ": c.checkScriptEnabled() c.setupGit() + c.setupRunChangeDetection() c.setupSafeguards(c.parsedArgs.Script.Run.runSafeguardsCliSpec) c.runScript() default: @@ -1228,6 +1255,29 @@ func (c *cli) gitSafeguardDefaultBranchIsReachable() { } } +func isChangeDetectionMode(set bool, name string, options []string) *bool { + var value *bool + if slices.Contains(options, name) { + value = &set + } + return value +} + +func (c *cli) setupListChangeDetection() { + c.changeDetection.untracked = isChangeDetectionMode(false, "git-untracked", c.parsedArgs.List.DisableChangeDetection) + c.changeDetection.uncommitted = isChangeDetectionMode(false, "git-uncommitted", c.parsedArgs.List.DisableChangeDetection) +} + +func (c *cli) setupRunChangeDetection() { + c.changeDetection.untracked = isChangeDetectionMode(true, "git-untracked", c.parsedArgs.Run.EnableChangeDetection) + c.changeDetection.uncommitted = isChangeDetectionMode(true, "git-uncommitted", c.parsedArgs.Run.EnableChangeDetection) +} + +func (c *cli) setupScriptRunChangeDetection() { + c.changeDetection.untracked = isChangeDetectionMode(true, "git-untracked", c.parsedArgs.Script.Run.EnableChangeDetection) + c.changeDetection.uncommitted = isChangeDetectionMode(true, "git-uncommitted", c.parsedArgs.Script.Run.EnableChangeDetection) +} + func (c *cli) listStacks(isChanged bool, target string, stackFilters cloud.StatusFilters) (*stack.Report, error) { var ( err error @@ -1237,7 +1287,11 @@ func (c *cli) listStacks(isChanged bool, target string, stackFilters cloud.Statu mgr := c.stackManager() if isChanged { - report, err = mgr.ListChanged(c.baseRef()) + report, err = mgr.ListChanged(stack.ChangeConfig{ + BaseRef: c.baseRef(), + UntrackedChanges: c.changeDetection.untracked, + UncommittedChanges: c.changeDetection.uncommitted, + }) } else { report, err = mgr.List(true) } diff --git a/cmd/terramate/cli/run.go b/cmd/terramate/cli/run.go index 4544e23d8c..2d247f348c 100644 --- a/cmd/terramate/cli/run.go +++ b/cmd/terramate/cli/run.go @@ -890,7 +890,11 @@ func (c *cli) getAffectedStacks() []stack.Entry { var report *stack.Report var err error if c.parsedArgs.Changed { - report, err = mgr.ListChanged(c.baseRef()) + report, err = mgr.ListChanged(stack.ChangeConfig{ + BaseRef: c.baseRef(), + UntrackedChanges: c.changeDetection.untracked, + UncommittedChanges: c.changeDetection.uncommitted, + }) if err != nil { fatalWithDetailf(err, "listing changed stacks") } diff --git a/e2etests/core/change_detection_test.go b/e2etests/core/change_detection_test.go new file mode 100644 index 0000000000..71c6e500c0 --- /dev/null +++ b/e2etests/core/change_detection_test.go @@ -0,0 +1,270 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package core_test + +import ( + "path/filepath" + "testing" + + "github.com/madlambda/spells/assert" + . "github.com/terramate-io/terramate/e2etests/internal/runner" + "github.com/terramate-io/terramate/stack" + "github.com/terramate-io/terramate/test" + "github.com/terramate-io/terramate/test/sandbox" +) + +func TestChangeDetection(t *testing.T) { + t.Parallel() + // TODO(i4k): migrate all tests in manager_test.go to use the sandbox. + prepareBranch := func(t *testing.T) *sandbox.S { + s := sandbox.New(t) + s.BuildTree([]string{ + "s:stacks/s1", + "f:stacks/s1/main.tf:# main", + "s:stacks/s2", + "f:stacks/s2/main.tf:# main", + }) + s.Git().CommitAll("create stacks") + s.Git().Push("main") + s.Git().CheckoutNew("test-branch") + return &s + } + t.Run("no changes", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 0) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 0) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 0) + + tmcli := NewCLI(t, s.RootDir()) + AssertRun(t, tmcli.Run("list", "--changed")) + AssertRun(t, tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked")) + AssertRun(t, tmcli.Run("list", "--changed", "--disable-change-detection=git-uncommitted")) + AssertRun(t, tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked,git-uncommitted")) + }) + t.Run("committed/single stack changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + s.Git().CommitAll("s1 changed") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 0) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 0) + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + + tmcli := NewCLI(t, s.RootDir()) + AssertRunResult(t, + tmcli.Run("list", "--changed"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-uncommitted"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked,git-uncommitted"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + }) + + t.Run("untracked=default/single stack changed by untracked file", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 0) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s1/untracked.tf") + + tmcli := NewCLI(t, s.RootDir()) + AssertRunResult(t, + tmcli.Run("list", "--changed"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked"), + RunExpected{}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-uncommitted"), + RunExpected{Stdout: nljoin("stacks/s1")}, + ) + + AssertRunResult(t, + tmcli.Run("list", "--changed", "--disable-change-detection=git-untracked,git-uncommitted"), + RunExpected{}, + ) + }) + + t.Run("uncommitted=default/single stack changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 0) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + }) + + t.Run("uncommitted=default+untracked=default/multiple stacks changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 2) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Stacks[1].Stack.Dir.String(), "/stacks/s2") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) + + t.Run("uncommitted=default+untracked=default/multiple stacks changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 2) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Stacks[1].Stack.Dir.String(), "/stacks/s2") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) + + t.Run("uncommitted=on+untracked=on/multiple stacks changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + on := true + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + UncommittedChanges: &on, + UntrackedChanges: &on, + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 2) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Stacks[1].Stack.Dir.String(), "/stacks/s2") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) + + t.Run("uncommitted=default+untracked=off/single stack changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + off := false + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + UntrackedChanges: &off, + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) + + t.Run("uncommitted=on+untracked=off/single stack changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + off := false + on := true + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + UntrackedChanges: &off, + UncommittedChanges: &on, + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s1") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) + + t.Run("uncommitted=off+untracked=default/single stack changed", func(t *testing.T) { + t.Parallel() + s := prepareBranch(t) + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s1"), "main.tf", "# changed") + test.WriteFile(t, filepath.Join(s.RootDir(), "stacks/s2"), "untracked.tf", "# something") + mgr := stack.NewGitAwareManager(s.Config(), s.Git().Unwrap()) + off := false + report, err := mgr.ListChanged(stack.ChangeConfig{ + BaseRef: "origin/main", + UncommittedChanges: &off, + }) + assert.NoError(t, err) + assert.EqualInts(t, len(report.Stacks), 1) + assert.EqualInts(t, len(report.Checks.UntrackedFiles), 1) + assert.EqualInts(t, len(report.Checks.UncommittedFiles), 1) + + assert.EqualStrings(t, report.Stacks[0].Stack.Dir.String(), "/stacks/s2") + assert.EqualStrings(t, report.Checks.UncommittedFiles[0], "stacks/s1/main.tf") + assert.EqualStrings(t, report.Checks.UntrackedFiles[0], "stacks/s2/untracked.tf") + }) +} diff --git a/e2etests/core/run_test.go b/e2etests/core/run_test.go index 507451e87e..a06177f27c 100644 --- a/e2etests/core/run_test.go +++ b/e2etests/core/run_test.go @@ -1882,27 +1882,32 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { }) }) - t.Run("ensure list is not affected by untracked check", func(t *testing.T) { + t.Run("ensure list **is** affected by untracked check (by default)", func(t *testing.T) { tmcli := NewCLI(t, s.RootDir()) - AssertRun(t, tmcli.Run("list", "--changed")) AssertRunResult(t, tmcli.Run("list"), RunExpected{ Stdout: nljoin("stack"), }) + AssertRunResult(t, tmcli.Run("list", "--changed"), RunExpected{ + Stdout: nljoin("stack"), + }) }) // disabling the check must work for both with and without --changed t.Run("disable check using deprecated cmd args", func(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", "--disable-check-git-untracked", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -1918,14 +1923,17 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { t.Run("disable check using --disable-safeguards=git-untracked cmd args", func(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=git-untracked", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "--quiet", @@ -1941,14 +1949,17 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { t.Run("disable check using --disable-safeguards=all cmd args", func(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=all", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "--quiet", @@ -1964,14 +1975,17 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { t.Run("disable check using -X", func(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "-X", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "--quiet", @@ -1989,13 +2003,16 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) tmcli.AppendEnv = append(tmcli.AppendEnv, "TM_DISABLE_CHECK_GIT_UNTRACKED=true") - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2012,13 +2029,16 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) tmcli.AppendEnv = append(tmcli.AppendEnv, "TM_DISABLE_CHECK_GIT_UNTRACKED=1") - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2046,13 +2066,16 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { defer s.RootEntry().RemoveFile(rootConfig) tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2078,13 +2101,16 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { defer s.RootEntry().RemoveFile(rootConfig) tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2110,13 +2136,16 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { defer s.RootEntry().RemoveFile(rootConfig) tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2144,14 +2173,17 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { defer s.RootEntry().RemoveFile(rootConfig) tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=git-untracked", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", @@ -2178,19 +2210,23 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { defer s.RootEntry().RemoveFile(rootConfig) tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) - AssertRun(t, tmcli.Run( + AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=git-untracked", "--changed", HelperPath, "cat", mainTfFileName, - )) + ), RunExpected{ + Stdout: mainTfContents, + }) AssertRunResult(t, tmcli.Run( "run", "--disable-safeguards=git-untracked", "--quiet", + "--", HelperPath, "cat", mainTfFileName, @@ -2216,7 +2252,10 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { tmcli := NewCLI(t, s.RootDir(), testEnviron(t)...) AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=none", + "--changed", + "--", HelperPath, "cat", mainTfFileName, @@ -2227,6 +2266,7 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { AssertRunResult(t, tmcli.Run( "run", + "--quiet", "--disable-safeguards=none", HelperPath, "cat", @@ -2255,6 +2295,9 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { tmcli.AppendEnv = append(tmcli.AppendEnv, "TM_DISABLE_SAFEGUARDS=none") AssertRunResult(t, tmcli.Run( "run", + "--quiet", + "--changed", + "--", HelperPath, "cat", mainTfFileName, @@ -2265,6 +2308,8 @@ func TestRunFailIfGitSafeguardUntracked(t *testing.T) { AssertRunResult(t, tmcli.Run( "run", + "--quiet", + "--", HelperPath, "cat", mainTfFileName, diff --git a/hcl/hcl.go b/hcl/hcl.go index a0bfe65c4e..eb9642fcaa 100644 --- a/hcl/hcl.go +++ b/hcl/hcl.go @@ -208,11 +208,18 @@ type GitConfig struct { // ChangeDetectionConfig is the `terramate.config.change_detection` config. type ChangeDetectionConfig struct { - Terragrunt *TerragruntConfig + Terragrunt *TerragruntChangeDetectionConfig + Git *GitChangeDetectionConfig } -// TerragruntConfig is the `terramate.config.change_detection.terragrunt` config. -type TerragruntConfig struct { +// GitChangeDetectionConfig is the `terramate.config.change_detection.git` config. +type GitChangeDetectionConfig struct { + Untracked *bool + Uncommitted *bool +} + +// TerragruntChangeDetectionConfig is the `terramate.config.change_detection.terragrunt` config. +type TerragruntChangeDetectionConfig struct { Enabled TerragruntChangeDetectionEnabledOption } @@ -1879,20 +1886,31 @@ func parseGenerateRootConfig(cfg *GenerateRootConfig, generateBlock *ast.MergedB } func parseChangeDetectionConfig(cfg *ChangeDetectionConfig, changeDetectionBlock *ast.MergedBlock) error { - err := changeDetectionBlock.ValidateSubBlocks("terragrunt") + err := changeDetectionBlock.ValidateSubBlocks("terragrunt", "git") if err != nil { return err } terragruntBlock, ok := changeDetectionBlock.Blocks[ast.NewEmptyLabelBlockType("terragrunt")] - if !ok { - return nil + if ok { + cfg.Terragrunt = &TerragruntChangeDetectionConfig{} + err := parseTerragruntChangeDetectionConfig(cfg.Terragrunt, terragruntBlock) + if err != nil { + return err + } } - cfg.Terragrunt = &TerragruntConfig{} - return parseTerragruntConfig(cfg.Terragrunt, terragruntBlock) + gitBlock, ok := changeDetectionBlock.Blocks[ast.NewEmptyLabelBlockType("git")] + if ok { + cfg.Git = &GitChangeDetectionConfig{} + err := parseGitChangeDetectionConfig(cfg.Git, gitBlock) + if err != nil { + return err + } + } + return nil } -func parseTerragruntConfig(cfg *TerragruntConfig, terragruntBlock *ast.MergedBlock) error { +func parseTerragruntChangeDetectionConfig(cfg *TerragruntChangeDetectionConfig, terragruntBlock *ast.MergedBlock) error { errs := errors.L() errs.Append(terragruntBlock.ValidateSubBlocks()) @@ -1942,6 +1960,56 @@ func parseTerragruntConfig(cfg *TerragruntConfig, terragruntBlock *ast.MergedBlo return nil } +func parseGitChangeDetectionConfig(cfg *GitChangeDetectionConfig, gitBlock *ast.MergedBlock) error { + errs := errors.L() + errs.Append(gitBlock.ValidateSubBlocks()) + + handleAttr := func(attr ast.Attribute, option **bool) { + value, diags := attr.Expr.Value(nil) + if diags.HasErrors() { + errs.Append(errors.E(diags, + "failed to evaluate terramate.config.change_detection.git.%s attribute", attr.Name, + )) + return + } + switch value.Type() { + case cty.String: + valStr := value.AsString() + switch valStr { + case "on": + val := true + *option = &val + case "off": + val := false + *option = &val + default: + errs.Append(errors.E("unexpected value %q in the `terramate.config.change_detection.git.%s` attribute", attr.Name)) + } + case cty.Bool: + valBool := value.True() + *option = &valBool + default: + errs.Append(errors.E("expected `string` or `bool` but type %s is set in the `terramate.config.change_detection.git.%s` attribute", attr.Name, value.Type().FriendlyName())) + } + } + + for _, attr := range gitBlock.Attributes { + switch attr.Name { + case "untracked": + handleAttr(attr, &cfg.Untracked) + case "uncommitted": + handleAttr(attr, &cfg.Uncommitted) + default: + errs.Append(errors.E( + attr.NameRange, + "unrecognized attribute terramate.config.change_detection.git.%s", + attr.Name, + )) + } + } + return errs.AsError() +} + func parseRunEnv(runEnv *RunEnv, envBlock *ast.MergedBlock) error { if len(envBlock.Attributes) > 0 { runEnv.Attributes = envBlock.Attributes diff --git a/hcl/hcl_test.go b/hcl/hcl_test.go index 87b8526b21..3f5bba9db6 100644 --- a/hcl/hcl_test.go +++ b/hcl/hcl_test.go @@ -985,7 +985,7 @@ func TestHCLParserRootConfig(t *testing.T) { Terramate: &hcl.Terramate{ Config: &hcl.RootConfig{ ChangeDetection: &hcl.ChangeDetectionConfig{ - Terragrunt: &hcl.TerragruntConfig{ + Terragrunt: &hcl.TerragruntChangeDetectionConfig{ Enabled: hcl.TerragruntAutoOption, }, }, @@ -1017,7 +1017,7 @@ func TestHCLParserRootConfig(t *testing.T) { Terramate: &hcl.Terramate{ Config: &hcl.RootConfig{ ChangeDetection: &hcl.ChangeDetectionConfig{ - Terragrunt: &hcl.TerragruntConfig{ + Terragrunt: &hcl.TerragruntChangeDetectionConfig{ Enabled: hcl.TerragruntOffOption, }, }, @@ -1049,7 +1049,7 @@ func TestHCLParserRootConfig(t *testing.T) { Terramate: &hcl.Terramate{ Config: &hcl.RootConfig{ ChangeDetection: &hcl.ChangeDetectionConfig{ - Terragrunt: &hcl.TerragruntConfig{ + Terragrunt: &hcl.TerragruntChangeDetectionConfig{ Enabled: hcl.TerragruntForceOption, }, }, diff --git a/stack/manager.go b/stack/manager.go index c0c1bb0bc3..0b90c072b9 100644 --- a/stack/manager.go +++ b/stack/manager.go @@ -37,6 +37,12 @@ type ( } } + ChangeConfig struct { + BaseRef string + UncommittedChanges *bool + UntrackedChanges *bool + } + // Report is the report of project's stacks and the result of its default checks. Report struct { Stacks []Entry @@ -112,7 +118,7 @@ func (m *Manager) List(checkRepo bool) (*Report, error) { // It's an error to call this method in a directory that's not // inside a repository or a repository with no commits in it. // It never returns cached values. -func (m *Manager) ListChanged(gitBaseRef string) (*Report, error) { +func (m *Manager) ListChanged(cfg ChangeConfig) (*Report, error) { logger := log.With(). Str("action", "ListChanged()"). Logger() @@ -130,11 +136,21 @@ func (m *Manager) ListChanged(gitBaseRef string) (*Report, error) { return nil, errors.E(errListChanged, err) } - changedFiles, err := m.changedFiles(gitBaseRef) + _, err = m.changedFiles(cfg.BaseRef) if err != nil { return nil, errors.E(errListChanged, err) } + if cfg.UncommittedChanges == nil || *cfg.UncommittedChanges { + m.appendChangedFiles(cfg.BaseRef, checks.UncommittedFiles...) + } + + if cfg.UntrackedChanges == nil || *cfg.UntrackedChanges { + m.appendChangedFiles(cfg.BaseRef, checks.UntrackedFiles...) + } + + changedFiles, _ := m.changedFiles(cfg.BaseRef) + if len(changedFiles) == 0 { return &Report{ Checks: checks, @@ -299,7 +315,7 @@ rangeStacks: } for _, mod := range modules { - changed, why, err := m.tfModuleChanged(mod, stack.HostDir(m.root), gitBaseRef, make(map[string]bool)) + changed, why, err := m.tfModuleChanged(mod, stack.HostDir(m.root), cfg.BaseRef, make(map[string]bool)) if err != nil { return errors.E(errListChanged, err, "checking module %q", mod.Source) } @@ -334,7 +350,7 @@ rangeStacks: continue } - changed, why, err := m.tgModuleChanged(stack, tgMod, gitBaseRef, stackSet, tgModulesMap) + changed, why, err := m.tgModuleChanged(stack, tgMod, cfg.BaseRef, stackSet, tgModulesMap) if err != nil { return nil, errors.E(errListChanged, err, "checking if Terragrunt module changes") } @@ -611,6 +627,13 @@ func (m *Manager) changedFiles(gitBaseRef string) ([]string, error) { return changedFiles, nil } +func (m *Manager) appendChangedFiles(gitBaseRef string, files ...string) { + if _, ok := m.cache.changedFiles[gitBaseRef]; !ok { + m.cache.changedFiles[gitBaseRef] = []string{} + } + m.cache.changedFiles[gitBaseRef] = append(m.cache.changedFiles[gitBaseRef], files...) +} + func (m *Manager) tgModuleChanged( stack *config.Stack, tgMod *tg.Module, gitBaseRef string, stackSet map[project.Path]Entry, tgModuleMap map[project.Path]*tg.Module, ) (changed bool, why string, err error) { diff --git a/stack/manager_test.go b/stack/manager_test.go index 1b32575fe7..8a05178188 100644 --- a/stack/manager_test.go +++ b/stack/manager_test.go @@ -247,7 +247,9 @@ func TestListChangedStacks(t *testing.T) { g := test.NewGitWrapper(t, repo.Dir, []string{}) m := stack.NewGitAwareManager(root, g) - report, err := m.ListChanged(tc.baseRef) + report, err := m.ListChanged(stack.ChangeConfig{ + BaseRef: tc.baseRef, + }) assert.EqualErrs(t, tc.want.err, err, "ListChanged() error") changedStacks := report.Stacks @@ -267,7 +269,7 @@ func TestListChangedStackReason(t *testing.T) { repo := singleNotMergedCommitBranch(t) m := newManager(t, repo.Dir) - report, err := m.ListChanged(defaultBranch) + report, err := m.ListChanged(stack.ChangeConfig{BaseRef: defaultBranch}) assert.NoError(t, err, "unexpected error") changed := report.Stacks @@ -278,7 +280,7 @@ func TestListChangedStackReason(t *testing.T) { repo = singleStackDependentModuleChangedRepo(t) m = newManager(t, repo.Dir) - report, err = m.ListChanged(defaultBranch) + report, err = m.ListChanged(stack.ChangeConfig{BaseRef: defaultBranch}) assert.NoError(t, err, "unexpected error") changed = report.Stacks