Repository: runatlantis/atlantis (language: go)

Issue:
Define projects in atlantis.yaml and allow undefined projects to auto plan
<!--- Please keep this note for the community --->

### Community Note

- Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

<!--- Thank you for keeping this note for the community --->

---

- [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
<!--
A clear and concise description of what workflow is meant to be improved.
Example: "As a developer, I often want to do <something>, but I often face <problem>".
-->
If we have 100 projects in a single repo and only need to customize 1, we'll have to define every project or we can no longer auto plan projects.

This is stated in the [docs](https://www.runatlantis.io/docs/repo-level-atlantis-yaml.html#enabling-atlantis-yaml) with this warning

> Once an `atlantis.yaml` file exists in a repo, Atlantis won't try to determine where to run plan automatically. Instead it will just follow the project configuration. This means that you'll need to define each project in your repo.
>
> If you have many directories with Terraform configuration, each directory will need to be defined.

According to the code, the warning in the documentation has been there since 2019. I'm not sure if this was an intentional limitation or if it is an issue that never got prioritized. I couldn't find an existing ticket for this either (happy to close if there is one).

https://github.com/runatlantis/atlantis/blame/ada4e53931953ada1afc75faac3ba9ebda126472/runatlantis.io/docs/repo-level-atlantis-yaml.md#L36

**Describe the solution you'd like**
<!--
A clear and concise description of what you want to happen. Consider that atlantis is used
by many people, and your particular use case might not make sense to implement in the core.
-->

**Describe the drawbacks of your solution**
<!--
This section is important not only to identify future issues, but also for us to see whether
you thought through your request. When filling it, ask yourself what are the problems we could
have maintaining what you propose. How often will it break?
-->
I'd like all projects to have a default, as if there isn't an atlantis.yaml file, and if there is an atlantis.yaml file with defined projects, only override the configurations of specific directories/projects and continue to autoplan the rest.

**Describe alternatives you've considered**
<!--
A clear and concise description of any alternative solutions or features you've considered,
and why you think they wouldn't be good enough.
-->
I currently run the script in the [docs](https://www.runatlantis.io/docs/repo-level-atlantis-yaml.html#auto-generate-projects) to auto generate the projects in the atlantis.yaml file.

There are other projects that also auto generate the atlantis.yaml file such as [terragrunt-atlantis-config](https://github.com/transcend-io/terragrunt-atlantis-config) and [atmos](https://github.com/cloudposse/atmos)


Discussion / hints:
Hi, I'd like to tackle this issue. I imagine this can be solved rather simply with a new top level config option (to avoid breaking existing behavior), for example:

```
version: 3
discover_projects_with_missing_config: true
projects:
- dir: some-dir
  autoplan:
    when_modified: ["*.tf", "custom_file"]
    enabled: true
```

In this case "discover_projects_with_missing_config" would default to false for the existing behavior. When set to true, we'd run through the DetermineProjects (https://github.com/runatlantis/atlantis/blob/main/server/events/project_finder.go#L140) logic unconditionally and merge the collection with the results of DetermineProjectsViaConfig (https://github.com/runatlantis/atlantis/blob/main/server/events/project_finder.go#L178). To merge, a project returned from DetermineProjectsViaConfig would override any project from DetermineProjects which has the same dir.

Let me know if this sounds reasonable and I can work on a PR.

Fix patch:
diff --git a/cmd/server.go b/cmd/server.go
index 795d165276..93c698ffe8 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -54,6 +54,7 @@ const (
 	AllowForkPRsFlag                 = "allow-fork-prs"
 	AllowRepoConfigFlag              = "allow-repo-config"
 	AtlantisURLFlag                  = "atlantis-url"
+	AutoDiscoverModeFlag             = "autodiscover-mode"
 	AutomergeFlag                    = "automerge"
 	ParallelPlanFlag                 = "parallel-plan"
 	ParallelApplyFlag                = "parallel-apply"
@@ -148,6 +149,7 @@ const (
 	DefaultADBasicUser                  = ""
 	DefaultADBasicPassword              = ""
 	DefaultADHostname                   = "dev.azure.com"
+	DefaultAutoDiscoverMode             = "auto"
 	DefaultAutoplanFileList             = "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl"
 	DefaultAllowCommands                = "version,plan,apply,unlock,approve_policies"
 	DefaultCheckoutStrategy             = CheckoutStrategyBranch
@@ -207,6 +209,12 @@ var stringFlags = map[string]stringFlag{
 	AtlantisURLFlag: {
 		description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.",
 	},
+	AutoDiscoverModeFlag: {
+		description: "Auto discover mode controls whether projects in a repo are discovered by Atlantis. Defaults to 'auto' which " +
+			"means projects will be discovered when no explicit projects are defined in repo config. Also supports 'enabled' (always " +
+			"discover projects) and 'disabled' (never discover projects).",
+		defaultValue: DefaultAutoDiscoverMode,
+	},
 	AutoplanModulesFromProjects: {
 		description: "Comma separated list of file patterns to select projects Atlantis will index for module dependencies." +
 			" Indexed projects will automatically be planned if a module they depend on is modified." +
@@ -871,6 +879,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) {
 	if c.WebPassword == "" {
 		c.WebPassword = DefaultWebPassword
 	}
+	if c.AutoDiscoverModeFlag == "" {
+		c.AutoDiscoverModeFlag = DefaultAutoDiscoverMode
+	}
 }
 
 func (s *ServerCmd) validate(userConfig server.UserConfig) error {
diff --git a/runatlantis.io/docs/repo-level-atlantis-yaml.md b/runatlantis.io/docs/repo-level-atlantis-yaml.md
index 5484f26e34..777552c634 100644
--- a/runatlantis.io/docs/repo-level-atlantis-yaml.md
+++ b/runatlantis.io/docs/repo-level-atlantis-yaml.md
@@ -34,12 +34,18 @@ By default, this is not allowed.
 :::
 
 ::: warning
-Once an `atlantis.yaml` file exists in a repo, Atlantis won't try to determine
-where to run plan automatically. Instead it will just follow the project configuration.
-This means that you'll need to define each project in your repo.
+Once an `atlantis.yaml` file exists in a repo and one or more `projects` are configured,
+Atlantis won't try to determine where to run plan automatically. Instead it will just
+follow the project configuration. This means that you'll need to define each project
+in your repo.
 
 If you have many directories with Terraform configuration, each directory will
 need to be defined.
+
+This behavior can be overriden by setting `autodiscover.mode` to
+`enabled` in which case Atlantis will still try to discover projects which were not
+explicitly configured. If the directory of any discovered project conflicts with a
+manually configured project, the manually configured project will take precedence.
 :::
 
 ## Example Using All Keys
@@ -47,6 +53,8 @@ need to be defined.
 ```yaml
 version: 3
 automerge: true
+autodiscover:
+  mode: auto
 delete_source_branch_on_merge: true
 parallel_plan: true
 parallel_apply: true
@@ -281,6 +289,34 @@ in each group one by one.
 If any plan/apply fails and `abort_on_execution_order_fail` is set to true on a repo level, all the 
 following groups will be aborted. For this example, if project2 fails then project1 will not run.
 
+### Autodiscovery Config
+```yaml
+autodiscover:
+  mode: "auto"
+```
+The above is the default configuration for `autodiscover.mode`. When `autodiscover.mode` is auto,
+projects will be discovered only if the repo has no `projects` configured.
+
+```yaml
+autodiscover:
+  mode: "disabled"
+```
+With the config above, Atlantis will never try to discover projects, even when there are no
+`projects` configured. This is useful if dynamically generating Atlantis config in pre_workflow hooks.
+See [Dynamic Repo Config Generation](pre-workflow-hooks.html#dynamic-repo-config-generation).
+
+```yaml
+autodiscover:
+  mode: "enabled"
+```
+With the config above, Atlantis will unconditionally try to discover projects based on modified_files,
+even when the directory of the project is missing from the configured `projects` in the repo configuration.
+If a discovered project has the same directory as a project which was manually configured in `projects`,
+the manual configuration will take precedence.
+
+Use this feature when some projects require specific configuration in a repo with many projects yet
+it's still desirable for Atlantis to plan/apply for projects not enumerated in the config.
+
 ### Custom Backend Config
 See [Custom Workflow Use Cases: Custom Backend Config](custom-workflows.html#custom-backend-config)
 
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index f87ec83144..6781abb149 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -106,6 +106,22 @@ Values are chosen in this order:
   * If a load balancer with a non http/https port (not the one defined in the `--port` flag) is used, update the URL to include the port like in the example above.
    * This URL is used as the `details` link next to each atlantis job to view the job's logs.
 
+### `--autodiscover-mode`
+  ```bash
+  atlantis server --autodiscover-mode="<auto|enabled|disabled>"
+  # or
+  ATLANTIS_AUTODISCOVER_MODE="<auto|enabled|disabled>"
+  ```
+  Sets auto discover mode, default is `auto`. When set to `auto`, projects in a repo will be discovered by
+  Atlantis when there are no projects configured in the repo config. If one or more projects are defined
+  in the repo config then auto discovery will be completely disabled.
+
+  When set to `enabled` projects will be discovered unconditionally. If an auto discovered project is already
+  defined in the projects section of the repo config, the project from the repo config will take precedence over
+  the auto discovered project.
+
+  When set to `disabled` projects will never be discovered, even if there are no projects configured in the repo config.
+
 ### `--automerge`
   ```bash
   atlantis server --automerge
diff --git a/runatlantis.io/docs/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md
index 01d8a175aa..178d2afe56 100644
--- a/runatlantis.io/docs/server-side-repo-config.md
+++ b/runatlantis.io/docs/server-side-repo-config.md
@@ -88,6 +88,10 @@ repos:
   # policy_check defines if policy checking should be enable on this repository.
   policy_check: false
 
+  # autodiscover defines how atlantis should automatically discover projects in this repository.
+  autodiscover:
+    mode: auto
+
   # id can also be an exact match.
 - id: github.com/myorg/specific-repo
 
@@ -496,6 +500,7 @@ If you set a workflow with the key `default`, it will override this.
 | repo_locking                  | bool     | false   | no       | Whether or not to get a lock.                                                                                                                                                                                                                                                                             |
 | policy_check                  | bool     | false   | no       | Whether or not to run policy checks on this repository.                                                                                                                                                                                                                                                   |
 | custom_policy_check                  | bool     | false   | no       | Whether or not to enable custom policy check tools outside of Conftest on this repository.                                                                                                                                                                                                       |
+| autodiscover                  | AutoDiscover     | none   | no       | Auto discover settings for this repo
 
 
 :::tip Notes
diff --git a/server/core/config/raw/autodiscover.go b/server/core/config/raw/autodiscover.go
new file mode 100644
index 0000000000..156128d271
--- /dev/null
+++ b/server/core/config/raw/autodiscover.go
@@ -0,0 +1,38 @@
+package raw
+
+import (
+	validation "github.com/go-ozzo/ozzo-validation"
+	"github.com/runatlantis/atlantis/server/core/config/valid"
+)
+
+var DefaultAutoDiscoverMode = valid.AutoDiscoverAutoMode
+
+type AutoDiscover struct {
+	Mode *valid.AutoDiscoverMode `yaml:"mode,omitempty"`
+}
+
+func (a AutoDiscover) ToValid() *valid.AutoDiscover {
+	var v valid.AutoDiscover
+
+	if a.Mode != nil {
+		v.Mode = *a.Mode
+	} else {
+		v.Mode = DefaultAutoDiscoverMode
+	}
+
+	return &v
+}
+
+func (a AutoDiscover) Validate() error {
+	res := validation.ValidateStruct(&a,
+		// If a.Mode is nil, this should still pass validation.
+		validation.Field(&a.Mode, validation.In(valid.AutoDiscoverAutoMode, valid.AutoDiscoverDisabledMode, valid.AutoDiscoverEnabledMode)),
+	)
+	return res
+}
+
+func DefaultAutoDiscover() *valid.AutoDiscover {
+	return &valid.AutoDiscover{
+		Mode: DefaultAutoDiscoverMode,
+	}
+}
diff --git a/server/core/config/raw/global_cfg.go b/server/core/config/raw/global_cfg.go
index 92a9ec29b6..b795294239 100644
--- a/server/core/config/raw/global_cfg.go
+++ b/server/core/config/raw/global_cfg.go
@@ -36,6 +36,7 @@ type Repo struct {
 	RepoLocking               *bool          `yaml:"repo_locking,omitempty" json:"repo_locking,omitempty"`
 	PolicyCheck               *bool          `yaml:"policy_check,omitempty" json:"policy_check,omitempty"`
 	CustomPolicyCheck         *bool          `yaml:"custom_policy_check,omitempty" json:"custom_policy_check,omitempty"`
+	AutoDiscover              *AutoDiscover  `yaml:"autodiscover,omitempty" json:"autodiscover,omitempty"`
 }
 
 func (g GlobalCfg) Validate() error {
@@ -211,6 +212,14 @@ func (r Repo) Validate() error {
 		return nil
 	}
 
+	autoDiscoverValid := func(value interface{}) error {
+		autoDiscover := value.(*AutoDiscover)
+		if autoDiscover != nil {
+			return autoDiscover.Validate()
+		}
+		return nil
+	}
+
 	return validation.ValidateStruct(&r,
 		validation.Field(&r.ID, validation.Required, validation.By(idValid)),
 		validation.Field(&r.Branch, validation.By(branchValid)),
@@ -221,6 +230,7 @@ func (r Repo) Validate() error {
 		validation.Field(&r.ImportRequirements, validation.By(validImportReq)),
 		validation.Field(&r.Workflow, validation.By(workflowExists)),
 		validation.Field(&r.DeleteSourceBranchOnMerge, validation.By(deleteSourceBranchOnMergeValid)),
+		validation.Field(&r.AutoDiscover, validation.By(autoDiscoverValid)),
 	)
 }
 
@@ -281,7 +291,7 @@ OuterGlobalPlanReqs:
 		}
 
 		// dont add policy_check step if repo have it explicitly disabled
-		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && *r.PolicyCheck == false {
+		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck {
 			continue
 		}
 		mergedPlanReqs = append(mergedPlanReqs, globalReq)
@@ -295,7 +305,7 @@ OuterGlobalApplyReqs:
 		}
 
 		// dont add policy_check step if repo have it explicitly disabled
-		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && *r.PolicyCheck == false {
+		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck {
 			continue
 		}
 		mergedApplyReqs = append(mergedApplyReqs, globalReq)
@@ -309,12 +319,17 @@ OuterGlobalImportReqs:
 		}
 
 		// dont add policy_check step if repo have it explicitly disabled
-		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && *r.PolicyCheck == false {
+		if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck {
 			continue
 		}
 		mergedImportReqs = append(mergedImportReqs, globalReq)
 	}
 
+	var autoDiscover *valid.AutoDiscover
+	if r.AutoDiscover != nil {
+		autoDiscover = r.AutoDiscover.ToValid()
+	}
+
 	return valid.Repo{
 		ID:                        id,
 		IDRegex:                   idRegex,
@@ -333,5 +348,6 @@ OuterGlobalImportReqs:
 		RepoLocking:               r.RepoLocking,
 		PolicyCheck:               r.PolicyCheck,
 		CustomPolicyCheck:         r.CustomPolicyCheck,
+		AutoDiscover:              autoDiscover,
 	}
 }
diff --git a/server/core/config/raw/repo_cfg.go b/server/core/config/raw/repo_cfg.go
index eb511b04aa..f3a688725d 100644
--- a/server/core/config/raw/repo_cfg.go
+++ b/server/core/config/raw/repo_cfg.go
@@ -19,6 +19,7 @@ type RepoCfg struct {
 	Projects                   []Project           `yaml:"projects,omitempty"`
 	Workflows                  map[string]Workflow `yaml:"workflows,omitempty"`
 	PolicySets                 PolicySets          `yaml:"policies,omitempty"`
+	AutoDiscover               *AutoDiscover       `yaml:"autodiscover,omitempty"`
 	Automerge                  *bool               `yaml:"automerge,omitempty"`
 	ParallelApply              *bool               `yaml:"parallel_apply,omitempty"`
 	ParallelPlan               *bool               `yaml:"parallel_plan,omitempty"`
@@ -71,10 +72,16 @@ func (r RepoCfg) ToValid() valid.RepoCfg {
 		abortOnExcecutionOrderFail = *r.AbortOnExcecutionOrderFail
 	}
 
+	var autoDiscover *valid.AutoDiscover
+	if r.AutoDiscover != nil {
+		autoDiscover = r.AutoDiscover.ToValid()
+	}
+
 	return valid.RepoCfg{
 		Version:                    *r.Version,
 		Projects:                   validProjects,
 		Workflows:                  validWorkflows,
+		AutoDiscover:               autoDiscover,
 		Automerge:                  automerge,
 		ParallelApply:              parallelApply,
 		ParallelPlan:               parallelPlan,
diff --git a/server/core/config/valid/autodiscover.go b/server/core/config/valid/autodiscover.go
new file mode 100644
index 0000000000..c131c3bffe
--- /dev/null
+++ b/server/core/config/valid/autodiscover.go
@@ -0,0 +1,14 @@
+package valid
+
+// AutoDiscoverMode enum
+type AutoDiscoverMode string
+
+const (
+	AutoDiscoverEnabledMode  AutoDiscoverMode = "enabled"
+	AutoDiscoverDisabledMode AutoDiscoverMode = "disabled"
+	AutoDiscoverAutoMode     AutoDiscoverMode = "auto"
+)
+
+type AutoDiscover struct {
+	Mode AutoDiscoverMode
+}
diff --git a/server/core/config/valid/global_cfg.go b/server/core/config/valid/global_cfg.go
index 8aab42f67b..0006e937c5 100644
--- a/server/core/config/valid/global_cfg.go
+++ b/server/core/config/valid/global_cfg.go
@@ -28,6 +28,7 @@ const DeleteSourceBranchOnMergeKey = "delete_source_branch_on_merge"
 const RepoLockingKey = "repo_locking"
 const PolicyCheckKey = "policy_check"
 const CustomPolicyCheckKey = "custom_policy_check"
+const AutoDiscoverKey = "autodiscover"
 
 // DefaultAtlantisFile is the default name of the config file for each repo.
 const DefaultAtlantisFile = "atlantis.yaml"
@@ -84,6 +85,7 @@ type Repo struct {
 	RepoLocking               *bool
 	PolicyCheck               *bool
 	CustomPolicyCheck         *bool
+	AutoDiscover              *AutoDiscover
 }
 
 type MergedProjectCfg struct {
@@ -245,6 +247,7 @@ func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg {
 	deleteSourceBranchOnMerge := false
 	repoLockingKey := true
 	customPolicyCheck := false
+	autoDiscover := AutoDiscover{Mode: AutoDiscoverAutoMode}
 	if args.AllowRepoCfg {
 		allowedOverrides = []string{PlanRequirementsKey, ApplyRequirementsKey, ImportRequirementsKey, WorkflowKey, DeleteSourceBranchOnMergeKey, RepoLockingKey, PolicyCheckKey}
 		allowCustomWorkflows = true
@@ -269,6 +272,7 @@ func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg {
 				RepoLocking:               &repoLockingKey,
 				PolicyCheck:               &policyCheck,
 				CustomPolicyCheck:         &customPolicyCheck,
+				AutoDiscover:              &autoDiscover,
 			},
 		},
 		Workflows: map[string]Workflow{
@@ -305,7 +309,7 @@ func (r Repo) IDString() string {
 // final config. It assumes that all configs have been validated.
 func (g GlobalCfg) MergeProjectCfg(log logging.SimpleLogging, repoID string, proj Project, rCfg RepoCfg) MergedProjectCfg {
 	log.Debug("MergeProjectCfg started")
-	planReqs, applyReqs, importReqs, workflow, allowedOverrides, allowCustomWorkflows, deleteSourceBranchOnMerge, repoLocking, policyCheck, customPolicyCheck := g.getMatchingCfg(log, repoID)
+	planReqs, applyReqs, importReqs, workflow, allowedOverrides, allowCustomWorkflows, deleteSourceBranchOnMerge, repoLocking, policyCheck, customPolicyCheck, _ := g.getMatchingCfg(log, repoID)
 
 	// If repos are allowed to override certain keys then override them.
 	for _, key := range allowedOverrides {
@@ -407,7 +411,7 @@ func (g GlobalCfg) MergeProjectCfg(log logging.SimpleLogging, repoID string, pro
 // repo with id repoID. It is used when there is no repo config.
 func (g GlobalCfg) DefaultProjCfg(log logging.SimpleLogging, repoID string, repoRelDir string, workspace string) MergedProjectCfg {
 	log.Debug("building config based on server-side config")
-	planReqs, applyReqs, importReqs, workflow, _, _, deleteSourceBranchOnMerge, repoLocking, policyCheck, customPolicyCheck := g.getMatchingCfg(log, repoID)
+	planReqs, applyReqs, importReqs, workflow, _, _, deleteSourceBranchOnMerge, repoLocking, policyCheck, customPolicyCheck, _ := g.getMatchingCfg(log, repoID)
 	return MergedProjectCfg{
 		PlanRequirements:          planReqs,
 		ApplyRequirements:         applyReqs,
@@ -426,6 +430,17 @@ func (g GlobalCfg) DefaultProjCfg(log logging.SimpleLogging, repoID string, repo
 	}
 }
 
+// RepoAutoDiscoverCfg returns the AutoDiscover config from the global config
+// for the repo with id repoID. If no matching repo is found or there is no
+// AutoDiscover config then this function returns nil.
+func (g GlobalCfg) RepoAutoDiscoverCfg(repoID string) *AutoDiscover {
+	repo := g.MatchingRepo(repoID)
+	if repo != nil {
+		return repo.AutoDiscover
+	}
+	return nil
+}
+
 // ValidateRepoCfg validates that rCfg for repo with id repoID is valid based
 // on our global config.
 func (g GlobalCfg) ValidateRepoCfg(rCfg RepoCfg, repoID string) error {
@@ -528,7 +543,7 @@ func (g GlobalCfg) ValidateRepoCfg(rCfg RepoCfg, repoID string) error {
 }
 
 // getMatchingCfg returns the key settings for repoID.
-func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (planReqs []string, applyReqs []string, importReqs []string, workflow Workflow, allowedOverrides []string, allowCustomWorkflows bool, deleteSourceBranchOnMerge bool, repoLocking bool, policyCheck bool, customPolicyCheck bool) {
+func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (planReqs []string, applyReqs []string, importReqs []string, workflow Workflow, allowedOverrides []string, allowCustomWorkflows bool, deleteSourceBranchOnMerge bool, repoLocking bool, policyCheck bool, customPolicyCheck bool, autoDiscover AutoDiscover) {
 	toLog := make(map[string]string)
 	traceF := func(repoIdx int, repoID string, key string, val interface{}) string {
 		from := "default server config"
@@ -550,6 +565,9 @@ func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (pla
 		return fmt.Sprintf("setting %s: %s from %s", key, valStr, from)
 	}
 
+	// Can't use raw.DefaultAutoDiscoverMode() because of an import cycle. Should refactor to avoid that.
+	autoDiscover = AutoDiscover{Mode: AutoDiscoverAutoMode}
+
 	for _, key := range []string{PlanRequirementsKey, ApplyRequirementsKey, ImportRequirementsKey, WorkflowKey, AllowedOverridesKey, AllowCustomWorkflowsKey, DeleteSourceBranchOnMergeKey, RepoLockingKey, PolicyCheckKey, CustomPolicyCheckKey} {
 		for i, repo := range g.Repos {
 			if repo.IDMatches(repoID) {
@@ -604,6 +622,11 @@ func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (pla
 						toLog[CustomPolicyCheckKey] = traceF(i, repo.IDString(), CustomPolicyCheckKey, *repo.CustomPolicyCheck)
 						customPolicyCheck = *repo.CustomPolicyCheck
 					}
+				case AutoDiscoverKey:
+					if repo.AutoDiscover != nil {
+						toLog[AutoDiscoverKey] = traceF(i, repo.IDString(), AutoDiscoverKey, repo.AutoDiscover.Mode)
+						autoDiscover = *repo.AutoDiscover
+					}
 				}
 			}
 		}
diff --git a/server/core/config/valid/repo_cfg.go b/server/core/config/valid/repo_cfg.go
index fe441f4d05..f0aed102ad 100644
--- a/server/core/config/valid/repo_cfg.go
+++ b/server/core/config/valid/repo_cfg.go
@@ -19,6 +19,7 @@ type RepoCfg struct {
 	Workflows                  map[string]Workflow
 	PolicySets                 PolicySets
 	Automerge                  *bool
+	AutoDiscover               *AutoDiscover
 	ParallelApply              *bool
 	ParallelPlan               *bool
 	ParallelPolicyCheck        *bool
@@ -91,6 +92,24 @@ func isRegexAllowed(name string, allowedRegexpPrefixes []string) bool {
 	return false
 }
 
+// This function returns a final true/false decision for whether AutoDiscover is enabled
+// for a repo. It takes into account the defaultAutoDiscoverMode when there is no explicit
+// repo config. The defaultAutoDiscoverMode param should be understood as the default
+// AutoDiscover mode as may be set via CLI params or server side repo config.
+func (r RepoCfg) AutoDiscoverEnabled(defaultAutoDiscoverMode AutoDiscoverMode) bool {
+	autoDiscoverMode := defaultAutoDiscoverMode
+	if r.AutoDiscover != nil {
+		autoDiscoverMode = r.AutoDiscover.Mode
+	}
+
+	if autoDiscoverMode == AutoDiscoverAutoMode {
+		// AutoDiscover is enabled by default when no projects are defined
+		return len(r.Projects) == 0
+	}
+
+	return autoDiscoverMode == AutoDiscoverEnabledMode
+}
+
 // validateWorkspaceAllowed returns an error if repoCfg defines projects in
 // repoRelDir but none of them use workspace. We want this to be an error
 // because if users have gone to the trouble of defining projects in repoRelDir
diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go
index 2347c9072a..25f0e2c3e9 100644
--- a/server/events/project_command_builder.go
+++ b/server/events/project_command_builder.go
@@ -18,6 +18,7 @@ import (
 
 	"github.com/runatlantis/atlantis/server/core/config"
 	"github.com/runatlantis/atlantis/server/events/command"
+	"github.com/runatlantis/atlantis/server/events/models"
 	"github.com/runatlantis/atlantis/server/events/vcs"
 )
 
@@ -54,6 +55,7 @@ func NewInstrumentedProjectCommandBuilder(
 	RestrictFileList bool,
 	SilenceNoProjects bool,
 	IncludeGitUntrackedFiles bool,
+	AutoDiscoverMode string,
 	scope tally.Scope,
 	logger logging.SimpleLogging,
 	terraformClient terraform.Client,
@@ -85,6 +87,7 @@ func NewInstrumentedProjectCommandBuilder(
 			RestrictFileList,
 			SilenceNoProjects,
 			IncludeGitUntrackedFiles,
+			AutoDiscoverMode,
 			scope,
 			logger,
 			terraformClient,
@@ -114,6 +117,7 @@ func NewProjectCommandBuilder(
 	RestrictFileList bool,
 	SilenceNoProjects bool,
 	IncludeGitUntrackedFiles bool,
+	AutoDiscoverMode string,
 	scope tally.Scope,
 	logger logging.SimpleLogging,
 	terraformClient terraform.Client,
@@ -136,6 +140,7 @@ func NewProjectCommandBuilder(
 		RestrictFileList:         RestrictFileList,
 		SilenceNoProjects:        SilenceNoProjects,
 		IncludeGitUntrackedFiles: IncludeGitUntrackedFiles,
+		AutoDiscoverMode:         AutoDiscoverMode,
 		ProjectCommandContextBuilder: NewProjectCommandContextBuilder(
 			policyChecksSupported,
 			commentBuilder,
@@ -242,6 +247,8 @@ type DefaultProjectCommandBuilder struct {
 	SilenceNoProjects bool
 	// User config option: Include git untracked files in the modified file list.
 	IncludeGitUntrackedFiles bool
+	// User config option: Controls auto-discovery of projects in a repository.
+	AutoDiscoverMode string
 	// Handles the actual running of Terraform commands.
 	TerraformExecutor terraform.Client
 }
@@ -336,6 +343,13 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex
 
 	ctx.Log.Debug("%d files were modified in this pull request. Modified files: %v", len(modifiedFiles), modifiedFiles)
 
+	// Get default AutoDiscoverMode from userConfig/globalConfig
+	defaultAutoDiscoverMode := valid.AutoDiscoverMode(p.AutoDiscoverMode)
+	globalAutoDiscover := p.GlobalCfg.RepoAutoDiscoverCfg(ctx.Pull.BaseRepo.ID())
+	if globalAutoDiscover != nil {
+		defaultAutoDiscoverMode = globalAutoDiscover.Mode
+	}
+
 	if p.SkipCloneNoChanges && p.VCSClient.SupportsSingleFileDownload(ctx.Pull.BaseRepo) {
 		repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID())
 		hasRepoCfg, repoCfgData, err := p.VCSClient.GetFileContent(ctx.Pull, repoCfgFile)
@@ -349,18 +363,27 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex
 				return nil, errors.Wrapf(err, "parsing %s", repoCfgFile)
 			}
 			ctx.Log.Info("successfully parsed remote %s file", repoCfgFile)
-			if len(repoCfg.Projects) > 0 {
-				matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, "", nil)
-				if err != nil {
-					return nil, err
-				}
-				ctx.Log.Info("%d projects are changed on MR %q based on their when_modified config", len(matchingProjects), ctx.Pull.Num)
-				if len(matchingProjects) == 0 {
-					ctx.Log.Info("skipping repo clone since no project was modified")
-					return []command.ProjectContext{}, nil
+
+			if repoCfg.AutoDiscover != nil {
+				defaultAutoDiscoverMode = repoCfg.AutoDiscover.Mode
+			}
+			// If auto discover is enabled, we never want to skip cloning
+			if !repoCfg.AutoDiscoverEnabled(defaultAutoDiscoverMode) {
+				if len(repoCfg.Projects) > 0 {
+					matchingProjects, err := p.ProjectFinder.DetermineProjectsViaConfig(ctx.Log, modifiedFiles, repoCfg, "", nil)
+					if err != nil {
+						return nil, err
+					}
+					ctx.Log.Info("%d projects are changed on MR %q based on their when_modified config", len(matchingProjects), ctx.Pull.Num)
+					if len(matchingProjects) == 0 {
+						ctx.Log.Info("skipping repo clone since no project was modified")
+						return []command.ProjectContext{}, nil
+					}
+				} else {
+					ctx.Log.Info("no projects are defined in %s. Will resume automatic detection", repoCfgFile)
 				}
 			} else {
-				ctx.Log.Info("No projects are defined in %s. Will resume automatic detection", repoCfgFile)
+				ctx.Log.Info("automatic project discovery enabled. Will resume automatic detection")
 			}
 			// NOTE: We discard this work here and end up doing it again after
 			// cloning to ensure all the return values are set properly with
@@ -402,6 +425,15 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex
 			return nil, errors.Wrapf(err, "parsing %s", repoCfgFile)
 		}
 		ctx.Log.Info("successfully parsed %s file", repoCfgFile)
+		// It's possible we've already set defaultAutoDiscoverMode
+		// from the config file while checking whether we can skip
+		// cloning. We still need to set it here in the case that
+		// we were not able to check whether we can skip cloning
+		// and thus were not able to previously fetch the repo
+		// config.
+		if repoCfg.AutoDiscover != nil {
+			defaultAutoDiscoverMode = repoCfg.AutoDiscover.Mode
+		}
 	}
 
 	moduleInfo, err := FindModuleProjects(repoDir, p.AutoDetectModuleFiles)
@@ -450,21 +482,46 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex
 					parallelApply,
 					parallelPlan,
 					verbose,
-					repoCfg.AbortOnExcecutionOrderFail,
+					abortOnExcecutionOrderFail,
 					p.TerraformExecutor,
 				)...)
 		}
-	} else {
+	}
+
+	if repoCfg.AutoDiscoverEnabled(defaultAutoDiscoverMode) {
 		// If there is no config file or it specified no projects, then we'll plan each project that
 		// our algorithm determines was modified.
 		if hasRepoCfg {
-			ctx.Log.Info("No projects are defined in %s. Will resume automatic detection", repoCfgFile)
+			if len(repoCfg.Projects) == 0 {
+				ctx.Log.Info("no projects are defined in %s. Will resume automatic detection", repoCfgFile)
+			} else {
+				ctx.Log.Info("automatic project discovery enabled. Will resume automatic detection")
+			}
 		} else {
 			ctx.Log.Info("found no %s file", repoCfgFile)
 		}
 		// build a module index for projects that are explicitly included
-		modifiedProjects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.Pull.BaseRepo.FullName, repoDir, p.AutoplanFileList, moduleInfo)
-		ctx.Log.Info("automatically determined that there were %d projects modified in this pull request: %s", len(modifiedProjects), modifiedProjects)
+		allModifiedProjects := p.ProjectFinder.DetermineProjects(
+			ctx.Log, modifiedFiles, ctx.Pull.BaseRepo.FullName, repoDir, p.AutoplanFileList, moduleInfo)
+		// If a project is already manually configured with the same dir as a discovered project, the manually configured
+		// project should take precedence
+		modifiedProjects := make([]models.Project, 0)
+		configuredProjDirs := make(map[string]bool)
+		// We compare against all configured projects instead of projects which match the modified files in case a
+		// project is being specifically excluded (ex: when_modified doesn't match). We don't want to accidentally
+		// "discover" it again.
+		for _, configProj := range repoCfg.Projects {
+			// Clean the path to make sure ./rel_path is equivalent to rel_path, etc
+			configuredProjDirs[filepath.Clean(configProj.Dir)] = true
+		}
+		for _, mp := range allModifiedProjects {
+			_, dirExists := configuredProjDirs[filepath.Clean(mp.Path)]
+			if !dirExists {
+				modifiedProjects = append(modifiedProjects, mp)
+			}
+		}
+		ctx.Log.Info("automatically determined that there were %d additional projects modified in this pull request: %s",
+			len(modifiedProjects), modifiedProjects)
 		for _, mp := range modifiedProjects {
 			ctx.Log.Debug("determining config for project at dir: %q", mp.Path)
 			pWorkspace, err := p.ProjectFinder.DetermineWorkspaceFromHCL(ctx.Log, repoDir)
diff --git a/server/server.go b/server/server.go
index d5c54e8e9e..adf74ed300 100644
--- a/server/server.go
+++ b/server/server.go
@@ -597,6 +597,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		userConfig.RestrictFileList,
 		userConfig.SilenceNoProjects,
 		userConfig.IncludeGitUntrackedFiles,
+		userConfig.AutoDiscoverModeFlag,
 		statsScope,
 		logger,
 		terraformClient,
diff --git a/server/user_config.go b/server/user_config.go
index aa6c120e0f..ce57d18a2f 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -15,6 +15,7 @@ type UserConfig struct {
 	AllowRepoConfig             bool   `mapstructure:"allow-repo-config"`
 	AllowCommands               string `mapstructure:"allow-commands"`
 	AtlantisURL                 string `mapstructure:"atlantis-url"`
+	AutoDiscoverModeFlag        string `mapstructure:"autodiscover-mode"`
 	Automerge                   bool   `mapstructure:"automerge"`
 	AutoplanFileList            string `mapstructure:"autoplan-file-list"`
 	AutoplanModules             bool   `mapstructure:"autoplan-modules"`


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index 387acf3de0..07a189f723 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -60,6 +60,7 @@ var testFlags = map[string]interface{}{
 	AllowCommandsFlag:                "version,plan,unlock,import,approve_policies", // apply is disabled by DisableApply
 	AllowForkPRsFlag:                 true,
 	AllowRepoConfigFlag:              true,
+	AutoDiscoverModeFlag:             "auto",
 	AutomergeFlag:                    true,
 	AutoplanFileListFlag:             "**/*.tf,**/*.yml",
 	BitbucketBaseURLFlag:             "https://bitbucket-base-url.com",
diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go
index e56c2c4131..e1d2085293 100644
--- a/server/controllers/events/events_controller_e2e_test.go
+++ b/server/controllers/events/events_controller_e2e_test.go
@@ -1352,6 +1352,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers
 		false,
 		false,
 		false,
+		"auto",
 		statsScope,
 		logger,
 		terraformClient,
diff --git a/server/core/config/parser_validator_test.go b/server/core/config/parser_validator_test.go
index 53caf9e539..3b269695da 100644
--- a/server/core/config/parser_validator_test.go
+++ b/server/core/config/parser_validator_test.go
@@ -1329,6 +1329,22 @@ func TestParseGlobalCfg(t *testing.T) {
   import_requirements: [invalid]`,
 			expErr: "repos: (0: (import_requirements: \"invalid\" is not a valid import_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.).).",
 		},
+		"disable autodiscover": {
+			input: `repos: 
+- id: /.*/
+  autodiscover:
+    mode: disabled`,
+			exp: valid.GlobalCfg{
+				Repos: []valid.Repo{
+					defaultCfg.Repos[0],
+					{
+						IDRegex:      regexp.MustCompile(".*"),
+						AutoDiscover: &valid.AutoDiscover{Mode: valid.AutoDiscoverDisabledMode},
+					},
+				},
+				Workflows: defaultCfg.Workflows,
+			},
+		},
 		"no workflows key": {
 			input: `repos: []`,
 			exp:   defaultCfg,
@@ -1404,6 +1420,8 @@ repos:
   allowed_overrides: [plan_requirements, apply_requirements, import_requirements, workflow, delete_source_branch_on_merge]
   allow_custom_workflows: true
   policy_check: true
+  autodiscover:
+    mode: enabled
 - id: /.*/
   branch: /(master|main)/
   pre_workflow_hooks:
@@ -1411,6 +1429,8 @@ repos:
   post_workflow_hooks:
     - run: custom workflow command
   policy_check: false
+  autodiscover:
+    mode: disabled
 workflows:
   custom1:
     plan:
@@ -1457,6 +1477,7 @@ policies:
 						AllowedOverrides:     []string{"plan_requirements", "apply_requirements", "import_requirements", "workflow", "delete_source_branch_on_merge"},
 						AllowCustomWorkflows: Bool(true),
 						PolicyCheck:          Bool(true),
+						AutoDiscover:         &valid.AutoDiscover{Mode: valid.AutoDiscoverEnabledMode},
 					},
 					{
 						IDRegex:           regexp.MustCompile(".*"),
@@ -1464,6 +1485,7 @@ policies:
 						PreWorkflowHooks:  preWorkflowHooks,
 						PostWorkflowHooks: postWorkflowHooks,
 						PolicyCheck:       Bool(false),
+						AutoDiscover:      &valid.AutoDiscover{Mode: valid.AutoDiscoverDisabledMode},
 					},
 				},
 				Workflows: map[string]valid.Workflow{
@@ -1574,6 +1596,7 @@ workflows:
 						RepoLocking:               Bool(true),
 						PolicyCheck:               Bool(false),
 						CustomPolicyCheck:         Bool(false),
+						AutoDiscover:              raw.DefaultAutoDiscover(),
 					},
 				},
 				Workflows: map[string]valid.Workflow{
@@ -1727,7 +1750,10 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) {
       "allowed_workflows": ["custom"],
       "apply_requirements": ["mergeable", "approved"],
       "allowed_overrides": ["workflow", "apply_requirements"],
-      "allow_custom_workflows": true
+      "allow_custom_workflows": true,
+      "autodiscover": {
+        "mode": "enabled"
+      }
     },
     {
       "id": "github.com/owner/repo"
@@ -1792,6 +1818,7 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) {
 						AllowedWorkflows:     []string{"custom"},
 						AllowedOverrides:     []string{"workflow", "apply_requirements"},
 						AllowCustomWorkflows: Bool(true),
+						AutoDiscover:         &valid.AutoDiscover{Mode: valid.AutoDiscoverEnabledMode},
 					},
 					{
 						ID:                   "github.com/owner/repo",
@@ -1799,6 +1826,7 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) {
 						ApplyRequirements:    nil,
 						AllowedOverrides:     nil,
 						AllowCustomWorkflows: nil,
+						AutoDiscover:         nil,
 					},
 				},
 				Workflows: map[string]valid.Workflow{
diff --git a/server/core/config/raw/autodiscover_test.go b/server/core/config/raw/autodiscover_test.go
new file mode 100644
index 0000000000..1485367fe8
--- /dev/null
+++ b/server/core/config/raw/autodiscover_test.go
@@ -0,0 +1,131 @@
+package raw_test
+
+import (
+	"testing"
+
+	"github.com/runatlantis/atlantis/server/core/config/raw"
+	"github.com/runatlantis/atlantis/server/core/config/valid"
+	. "github.com/runatlantis/atlantis/testing"
+	yaml "gopkg.in/yaml.v2"
+)
+
+func TestAutoDiscover_UnmarshalYAML(t *testing.T) {
+	autoDiscoverEnabled := valid.AutoDiscoverEnabledMode
+	cases := []struct {
+		description string
+		input       string
+		exp         raw.AutoDiscover
+	}{
+		{
+			description: "omit unset fields",
+			input:       "",
+			exp: raw.AutoDiscover{
+				Mode: nil,
+			},
+		},
+		{
+			description: "all fields set",
+			input: `
+mode: enabled
+`,
+			exp: raw.AutoDiscover{
+				Mode: &autoDiscoverEnabled,
+			},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.description, func(t *testing.T) {
+			var a raw.AutoDiscover
+			err := yaml.UnmarshalStrict([]byte(c.input), &a)
+			Ok(t, err)
+			Equals(t, c.exp, a)
+		})
+	}
+}
+
+func TestAutoDiscover_Validate(t *testing.T) {
+	autoDiscoverAuto := valid.AutoDiscoverAutoMode
+	autoDiscoverEnabled := valid.AutoDiscoverEnabledMode
+	autoDiscoverDisabled := valid.AutoDiscoverDisabledMode
+	randomString := valid.AutoDiscoverMode("random_string")
+	cases := []struct {
+		description string
+		input       raw.AutoDiscover
+		errContains *string
+	}{
+		{
+			description: "nothing set",
+			input:       raw.AutoDiscover{},
+			errContains: nil,
+		},
+		{
+			description: "mode set to auto",
+			input: raw.AutoDiscover{
+				Mode: &autoDiscoverAuto,
+			},
+			errContains: nil,
+		},
+		{
+			description: "mode set to disabled",
+			input: raw.AutoDiscover{
+				Mode: &autoDiscoverDisabled,
+			},
+			errContains: nil,
+		},
+		{
+			description: "mode set to enabled",
+			input: raw.AutoDiscover{
+				Mode: &autoDiscoverEnabled,
+			},
+			errContains: nil,
+		},
+		{
+			description: "mode set to random string",
+			input: raw.AutoDiscover{
+				Mode: &randomString,
+			},
+			errContains: String("valid value"),
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.description, func(t *testing.T) {
+			if c.errContains == nil {
+				Ok(t, c.input.Validate())
+			} else {
+				ErrContains(t, *c.errContains, c.input.Validate())
+			}
+		})
+	}
+}
+
+func TestAutoDiscover_ToValid(t *testing.T) {
+	autoDiscoverEnabled := valid.AutoDiscoverEnabledMode
+	cases := []struct {
+		description string
+		input       raw.AutoDiscover
+		exp         *valid.AutoDiscover
+	}{
+		{
+			description: "nothing set",
+			input:       raw.AutoDiscover{},
+			exp: &valid.AutoDiscover{
+				Mode: valid.AutoDiscoverAutoMode,
+			},
+		},
+		{
+			description: "value set",
+			input: raw.AutoDiscover{
+				Mode: &autoDiscoverEnabled,
+			},
+			exp: &valid.AutoDiscover{
+				Mode: valid.AutoDiscoverEnabledMode,
+			},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.description, func(t *testing.T) {
+			Equals(t, c.exp, c.input.ToValid())
+		})
+	}
+}
diff --git a/server/core/config/raw/repo_cfg_test.go b/server/core/config/raw/repo_cfg_test.go
index 7b11655c13..ff057548d0 100644
--- a/server/core/config/raw/repo_cfg_test.go
+++ b/server/core/config/raw/repo_cfg_test.go
@@ -11,6 +11,7 @@ import (
 )
 
 func TestConfig_UnmarshalYAML(t *testing.T) {
+	autoDiscoverEnabled := valid.AutoDiscoverEnabledMode
 	cases := []struct {
 		description string
 		input       string
@@ -126,6 +127,8 @@ func TestConfig_UnmarshalYAML(t *testing.T) {
 			input: `
 version: 3
 automerge: true
+autodiscover:
+  mode: enabled
 parallel_apply: true
 parallel_plan: false
 projects:
@@ -150,6 +153,7 @@ allowed_regexp_prefixes:
 - staging/`,
 			exp: raw.RepoCfg{
 				Version:       Int(3),
+				AutoDiscover:  &raw.AutoDiscover{Mode: &autoDiscoverEnabled},
 				Automerge:     Bool(true),
 				ParallelApply: Bool(true),
 				ParallelPlan:  Bool(false),
@@ -232,6 +236,7 @@ func TestConfig_Validate(t *testing.T) {
 }
 
 func TestConfig_ToValid(t *testing.T) {
+	autoDiscoverEnabled := valid.AutoDiscoverEnabledMode
 	cases := []struct {
 		description string
 		input       raw.RepoCfg
@@ -248,18 +253,20 @@ func TestConfig_ToValid(t *testing.T) {
 		{
 			description: "set to empty",
 			input: raw.RepoCfg{
-				Version:   Int(2),
-				Workflows: map[string]raw.Workflow{},
-				Projects:  []raw.Project{},
+				Version:      Int(2),
+				AutoDiscover: &raw.AutoDiscover{},
+				Workflows:    map[string]raw.Workflow{},
+				Projects:     []raw.Project{},
 			},
 			exp: valid.RepoCfg{
-				Version:   2,
-				Workflows: map[string]valid.Workflow{},
-				Projects:  nil,
+				Version:      2,
+				AutoDiscover: raw.DefaultAutoDiscover(),
+				Workflows:    map[string]valid.Workflow{},
+				Projects:     nil,
 			},
 		},
 		{
-			description: "automerge, parallel_apply and abort_on_execution_order_fail omitted",
+			description: "automerge, parallel_apply, abort_on_execution_order_fail omitted",
 			input: raw.RepoCfg{
 				Version: Int(2),
 			},
@@ -272,7 +279,7 @@ func TestConfig_ToValid(t *testing.T) {
 			},
 		},
 		{
-			description: "automerge, parallel_apply and abort_on_execution_order_fail true",
+			description: "automerge, parallel_apply, abort_on_execution_order_fail true",
 			input: raw.RepoCfg{
 				Version:                    Int(2),
 				Automerge:                  Bool(true),
@@ -288,7 +295,7 @@ func TestConfig_ToValid(t *testing.T) {
 			},
 		},
 		{
-			description: "automerge, parallel_apply and abort_on_execution_order_fail false",
+			description: "automerge, parallel_apply, abort_on_execution_order_fail false",
 			input: raw.RepoCfg{
 				Version:                    Int(2),
 				Automerge:                  Bool(false),
@@ -303,6 +310,30 @@ func TestConfig_ToValid(t *testing.T) {
 				Workflows:                  map[string]valid.Workflow{},
 			},
 		},
+		{
+			description: "autodiscover omitted",
+			input: raw.RepoCfg{
+				Version: Int(2),
+			},
+			exp: valid.RepoCfg{
+				Version:   2,
+				Workflows: map[string]valid.Workflow{},
+			},
+		},
+		{
+			description: "autodiscover included",
+			input: raw.RepoCfg{
+				Version:      Int(2),
+				AutoDiscover: &raw.AutoDiscover{Mode: &autoDiscoverEnabled},
+			},
+			exp: valid.RepoCfg{
+				Version: 2,
+				AutoDiscover: &valid.AutoDiscover{
+					Mode: valid.AutoDiscoverEnabledMode,
+				},
+				Workflows: map[string]valid.Workflow{},
+			},
+		},
 		{
 			description: "only plan stage set",
 			input: raw.RepoCfg{
@@ -339,6 +370,9 @@ func TestConfig_ToValid(t *testing.T) {
 				Version:       Int(2),
 				Automerge:     Bool(true),
 				ParallelApply: Bool(true),
+				AutoDiscover: &raw.AutoDiscover{
+					Mode: &autoDiscoverEnabled,
+				},
 				Workflows: map[string]raw.Workflow{
 					"myworkflow": {
 						Apply: &raw.Stage{
@@ -388,6 +422,9 @@ func TestConfig_ToValid(t *testing.T) {
 				Version:       2,
 				Automerge:     Bool(true),
 				ParallelApply: Bool(true),
+				AutoDiscover: &valid.AutoDiscover{
+					Mode: valid.AutoDiscoverEnabledMode,
+				},
 				Workflows: map[string]valid.Workflow{
 					"myworkflow": {
 						Name: "myworkflow",
diff --git a/server/core/config/valid/global_cfg_test.go b/server/core/config/valid/global_cfg_test.go
index d778a9f12d..5c51893470 100644
--- a/server/core/config/valid/global_cfg_test.go
+++ b/server/core/config/valid/global_cfg_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/hashicorp/go-version"
 	"github.com/mohae/deepcopy"
 	"github.com/runatlantis/atlantis/server/core/config"
+	"github.com/runatlantis/atlantis/server/core/config/raw"
 	"github.com/runatlantis/atlantis/server/core/config/valid"
 	"github.com/runatlantis/atlantis/server/logging"
 	. "github.com/runatlantis/atlantis/testing"
@@ -82,6 +83,7 @@ func TestNewGlobalCfg(t *testing.T) {
 				RepoLocking:               Bool(true),
 				PolicyCheck:               Bool(false),
 				CustomPolicyCheck:         Bool(false),
+				AutoDiscover:              raw.DefaultAutoDiscover(),
 			},
 		},
 		Workflows: map[string]valid.Workflow{
diff --git a/server/core/config/valid/repo_cfg_test.go b/server/core/config/valid/repo_cfg_test.go
index bfacaa7f2f..9b94994f51 100644
--- a/server/core/config/valid/repo_cfg_test.go
+++ b/server/core/config/valid/repo_cfg_test.go
@@ -216,3 +216,110 @@ func TestConfig_FindProjectsByDir(t *testing.T) {
 		})
 	}
 }
+
+func TestConfig_AutoDiscoverEnabled(t *testing.T) {
+	cases := []struct {
+		description         string
+		repoAutoDiscover    valid.AutoDiscoverMode
+		defaultAutoDiscover valid.AutoDiscoverMode
+		projects            []valid.Project
+		expEnabled          bool
+	}{
+		{
+			description:         "repo disabled autodiscover default enabled",
+			repoAutoDiscover:    valid.AutoDiscoverDisabledMode,
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			expEnabled:          false,
+		},
+		{
+			description:         "repo disabled autodiscover default disabled",
+			repoAutoDiscover:    valid.AutoDiscoverDisabledMode,
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			expEnabled:          false,
+		},
+		{
+			description:         "repo enabled autodiscover default enabled",
+			repoAutoDiscover:    valid.AutoDiscoverEnabledMode,
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo enabled autodiscover default disabled",
+			repoAutoDiscover:    valid.AutoDiscoverEnabledMode,
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo set auto autodiscover with no projects default enabled",
+			repoAutoDiscover:    valid.AutoDiscoverAutoMode,
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo set auto autodiscover with no projects default disabled",
+			repoAutoDiscover:    valid.AutoDiscoverAutoMode,
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo set auto autodiscover with a project default enabled",
+			repoAutoDiscover:    valid.AutoDiscoverAutoMode,
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			projects:            []valid.Project{{}},
+			expEnabled:          false,
+		},
+		{
+			description:         "repo set auto autodiscover with a project default disabled",
+			repoAutoDiscover:    valid.AutoDiscoverAutoMode,
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			projects:            []valid.Project{{}},
+			expEnabled:          false,
+		},
+		{
+			description:         "repo unset autodiscover with no projects default enabled",
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo unset autodiscover with no projects default disabled",
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			expEnabled:          false,
+		},
+		{
+			description:         "repo unset autodiscover with no projects default auto",
+			defaultAutoDiscover: valid.AutoDiscoverAutoMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo unset autodiscover with a project default enabled",
+			projects:            []valid.Project{{}},
+			defaultAutoDiscover: valid.AutoDiscoverEnabledMode,
+			expEnabled:          true,
+		},
+		{
+			description:         "repo unset autodiscover with a project default disabled",
+			projects:            []valid.Project{{}},
+			defaultAutoDiscover: valid.AutoDiscoverDisabledMode,
+			expEnabled:          false,
+		},
+		{
+			description:         "repo unset autodiscover with a project default auto",
+			projects:            []valid.Project{{}},
+			defaultAutoDiscover: valid.AutoDiscoverAutoMode,
+			expEnabled:          false,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.description, func(t *testing.T) {
+			r := valid.RepoCfg{
+				Projects:     c.projects,
+				AutoDiscover: nil,
+			}
+			if c.repoAutoDiscover != "" {
+				r.AutoDiscover = &valid.AutoDiscover{c.repoAutoDiscover}
+			}
+			enabled := r.AutoDiscoverEnabled(c.defaultAutoDiscover)
+			Equals(t, c.expEnabled, enabled)
+		})
+	}
+}
diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go
index e8804c5142..bd524dea90 100644
--- a/server/events/project_command_builder_internal_test.go
+++ b/server/events/project_command_builder_internal_test.go
@@ -673,6 +673,7 @@ projects:
 				false,
 				false,
 				false,
+				"auto",
 				statsScope,
 				logger,
 				terraformClient,
@@ -888,6 +889,7 @@ projects:
 				false,
 				false,
 				false,
+				"auto",
 				statsScope,
 				logger,
 				terraformClient,
@@ -1137,6 +1139,7 @@ workflows:
 				false,
 				false,
 				false,
+				"auto",
 				statsScope,
 				logger,
 				terraformClient,
@@ -1293,6 +1296,7 @@ projects:
 				false,
 				true,
 				false,
+				"auto",
 				statsScope,
 				logger,
 				terraformClient,
@@ -1318,6 +1322,149 @@ projects:
 	}
 }
 
+func TestBuildProjectCmdCtx_AutoDiscoverRespectsRepoConfig(t *testing.T) {
+	logger := logging.NewNoopLogger(t)
+	cases := map[string]struct {
+		globalCfg     string
+		repoCfg       string
+		modifiedFiles []string
+		expLen        int
+	}{
+		"autodiscover disabled": {
+			globalCfg: `
+repos:
+- id: /.*/
+  autodiscover:
+    mode: disabled
+`,
+			repoCfg: `
+version: 3
+automerge: true
+`,
+			modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"},
+			expLen:        0,
+		},
+		"autodiscover auto": {
+			globalCfg: `
+repos:
+- id: /.*/
+  autodiscover:
+    mode: auto
+`,
+			repoCfg: `
+version: 3
+automerge: true
+projects:
+- dir: project1
+  workspace: myworkspace
+`,
+			modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"},
+			expLen:        1,
+		},
+		"autodiscover enabled": {
+			globalCfg: `
+repos:
+- id: /.*/
+  autodiscover:
+    mode: enabled
+`,
+			repoCfg: `
+version: 3
+automerge: true
+projects:
+- dir: project1
+  workspace: myworkspace
+`,
+			modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"},
+			expLen:        3,
+		},
+	}
+
+	for name, c := range cases {
+		t.Run(name, func(t *testing.T) {
+			tmp := DirStructure(t, map[string]interface{}{
+				"project1": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project2": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project3": map[string]interface{}{
+					"main.tf": nil,
+				},
+			})
+
+			workingDir := NewMockWorkingDir()
+			When(workingDir.Clone(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, false, nil)
+			vcsClient := vcsmocks.NewMockClient()
+			When(vcsClient.GetModifiedFiles(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.modifiedFiles, nil)
+
+			// Write and parse the global config file.
+			globalCfgPath := filepath.Join(tmp, "global.yaml")
+			Ok(t, os.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600))
+			parser := &config.ParserValidator{}
+			globalCfgArgs := valid.GlobalCfgArgs{
+				AllowRepoCfg:  false,
+				MergeableReq:  false,
+				ApprovedReq:   false,
+				UnDivergedReq: false,
+			}
+
+			globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs))
+			Ok(t, err)
+
+			if c.repoCfg != "" {
+				Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600))
+			}
+			statsScope, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis")
+
+			terraformClient := mocks.NewMockClient()
+
+			builder := NewProjectCommandBuilder(
+				false,
+				parser,
+				&DefaultProjectFinder{},
+				vcsClient,
+				workingDir,
+				NewDefaultWorkingDirLocker(),
+				globalCfg,
+				&DefaultPendingPlanFinder{},
+				&CommentParser{ExecutableName: "atlantis"},
+				false,
+				false,
+				false,
+				false,
+				false,
+				"",
+				"**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl",
+				false,
+				true,
+				false,
+				"auto",
+				statsScope,
+				logger,
+				terraformClient,
+			)
+
+			ctxs, err := builder.BuildPlanCommands(
+				&command.Context{
+					Log:   logger,
+					Scope: statsScope,
+				},
+				&CommentCommand{
+					RepoRelDir: "",
+					Flags:      nil,
+					Name:       command.Plan,
+					Verbose:    false,
+				},
+			)
+			Equals(t, c.expLen, len(ctxs))
+			Ok(t, err)
+
+		})
+	}
+}
+
 func mustVersion(v string) *version.Version {
 	vers, err := version.NewVersion(v)
 	if err != nil {
diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go
index 6a3e73798d..6c3217fd24 100644
--- a/server/events/project_command_builder_test.go
+++ b/server/events/project_command_builder_test.go
@@ -33,6 +33,7 @@ var defaultUserConfig = struct {
 	RestrictFileList         bool
 	SilenceNoProjects        bool
 	IncludeGitUntrackedFiles bool
+	AutoDiscoverMode         string
 }{
 	SkipCloneNoChanges:       false,
 	EnableRegExpCmd:          false,
@@ -44,6 +45,7 @@ var defaultUserConfig = struct {
 	RestrictFileList:         false,
 	SilenceNoProjects:        false,
 	IncludeGitUntrackedFiles: true,
+	AutoDiscoverMode:         "auto",
 }
 
 func TestDefaultProjectCommandBuilder_BuildAutoplanCommands(t *testing.T) {
@@ -194,6 +196,7 @@ projects:
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -232,6 +235,7 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T)
 		ExpErr                     string
 		ExpApplyReqs               []string
 		EnableAutoMergeUserCfg     bool
+		AutoDiscoverModeUserCfg    string
 		EnableParallelPlanUserCfg  bool
 		EnableParallelApplyUserCfg bool
 		ExpAutoMerge               bool
@@ -550,6 +554,7 @@ projects:
 					userConfig.RestrictFileList,
 					c.Silenced,
 					userConfig.IncludeGitUntrackedFiles,
+					c.AutoDiscoverModeUserCfg,
 					scope,
 					logger,
 					terraformClient,
@@ -739,6 +744,7 @@ projects:
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -770,6 +776,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands(t *testing.T) {
 		RepoRelDir       string
 		Workspace        string
 		Automerge        bool
+		AutoDiscover     valid.AutoDiscover
 		ExpParallelPlan  bool
 		ExpParallelApply bool
 	}
@@ -956,6 +963,67 @@ projects:
 				},
 			},
 		},
+		"follow autodiscover enabled config": {
+			DirStructure: map[string]interface{}{
+				"project1": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project2": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project3": map[string]interface{}{
+					"main.tf": nil,
+				},
+			},
+			AtlantisYAML: `version: 3
+autodiscover:
+  mode: enabled
+projects:
+- name: project1-custom-name
+  dir: project1`,
+			ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"},
+			// project2 is autodiscovered, whereas project1 is not
+			Exp: []expCtxFields{
+				{
+					ProjectName: "project1-custom-name",
+					RepoRelDir:  "project1",
+					Workspace:   "default",
+				},
+				{
+					ProjectName: "",
+					RepoRelDir:  "project2",
+					Workspace:   "default",
+				},
+			},
+		},
+		"autodiscover enabled but project excluded by empty when_modified": {
+			DirStructure: map[string]interface{}{
+				"project1": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project2": map[string]interface{}{
+					"main.tf": nil,
+				},
+				"project3": map[string]interface{}{
+					"main.tf": nil,
+				},
+			},
+			AtlantisYAML: `version: 3
+autodiscover:
+  mode: enabled
+projects:
+- dir: project1
+  autoplan:
+    when_modified: []`,
+			ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"},
+			Exp: []expCtxFields{
+				{
+					ProjectName: "",
+					RepoRelDir:  "project2",
+					Workspace:   "default",
+				},
+			},
+		},
 	}
 
 	logger := logging.NewNoopLogger(t)
@@ -1007,6 +1075,7 @@ projects:
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -1021,7 +1090,7 @@ projects:
 					RepoRelDir:  "",
 					Flags:       nil,
 					Name:        command.Plan,
-					Verbose:     false,
+					Verbose:     true,
 					Workspace:   "",
 					ProjectName: "",
 				})
@@ -1111,6 +1180,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) {
 		userConfig.RestrictFileList,
 		userConfig.SilenceNoProjects,
 		userConfig.IncludeGitUntrackedFiles,
+		userConfig.AutoDiscoverMode,
 		scope,
 		logger,
 		terraformClient,
@@ -1206,6 +1276,7 @@ projects:
 		userConfig.RestrictFileList,
 		userConfig.SilenceNoProjects,
 		userConfig.IncludeGitUntrackedFiles,
+		userConfig.AutoDiscoverMode,
 		scope,
 		logger,
 		terraformClient,
@@ -1296,6 +1367,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) {
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -1467,6 +1539,7 @@ projects:
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -1523,6 +1596,17 @@ parallel_plan: true`,
 			ExpectedClones: Once(),
 			ModifiedFiles:  []string{"README.md"},
 		},
+		{
+			AtlantisYAML: `
+version: 3
+autodiscover:
+  mode: enabled
+projects:
+- dir: dir1`,
+			ExpectedCtxs:   0,
+			ExpectedClones: Once(),
+			ModifiedFiles:  []string{"dir2/main.tf"},
+		},
 	}
 
 	userConfig := defaultUserConfig
@@ -1568,6 +1652,7 @@ parallel_plan: true`,
 			userConfig.RestrictFileList,
 			userConfig.SilenceNoProjects,
 			userConfig.IncludeGitUntrackedFiles,
+			userConfig.AutoDiscoverMode,
 			scope,
 			logger,
 			terraformClient,
@@ -1638,6 +1723,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman
 		userConfig.RestrictFileList,
 		userConfig.SilenceNoProjects,
 		userConfig.IncludeGitUntrackedFiles,
+		userConfig.AutoDiscoverMode,
 		scope,
 		logger,
 		terraformClient,
@@ -1730,6 +1816,7 @@ func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) {
 		userConfig.RestrictFileList,
 		userConfig.SilenceNoProjects,
 		userConfig.IncludeGitUntrackedFiles,
+		userConfig.AutoDiscoverMode,
 		scope,
 		logger,
 		terraformClient,
@@ -1861,6 +1948,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_Single_With_RestrictFile
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,
@@ -1972,6 +2060,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_with_IncludeGitUntracked
 				userConfig.RestrictFileList,
 				userConfig.SilenceNoProjects,
 				userConfig.IncludeGitUntrackedFiles,
+				userConfig.AutoDiscoverMode,
 				scope,
 				logger,
 				terraformClient,