Repository: gruntwork-io/terragrunt

Issue:
Add support for `--terragrunt-source-map` as a more consistent, predictable way to override source URLs
Terragrunt's `--terragrunt-source` parameter, which is designed to make it easy to override `source` URLs in `terragrunt.hcl`, has a couple issues:

1. If you run `terragrunt apply --terragrunt-source /foo/bar` in folder `yyy`, as of now, the *only* thing it does is override the `source` URL in `yyy/terragrunt.hcl` to the exact value `/foo/bar`. However, if you run `terragrunt apply-all --terragrunt-source /foo/bar` in folder `yyy`, it overrides the `source` value of each child module within `yyy/sub/path` to something like `/foo/bar/sub/path`. This inconsistency can be confusing and only gets worse when working with `read_terragrunt_config`, where we have to parse another `terragrunt.hcl` and decide whether to override its `source` URL or not.
1. `--terragrunt-source` assumes all your modules are coming from the *same* root repo. You won't be able to do an `apply-all` if you're pulling from multiple repos (e.g., your own infra modules, Terraform Registry, Gruntwork repos, etc). 

Perhaps a better solution to this is to support some sort of mapping:

```
terragrunt apply --terragrunt-source-map source:dest
```

The `--terragrunt-source-map` param would replace any `source` URL that starts with `source` to instead start with `dest`. 

For example:

```
terragrunt apply --terragrunt-source-map github.com/org/modules.git:/local/path/to/modules
```

The above would replace `source = "github.com/org/modules.git//xxx"` with `/local/path/to/modules//xxx` regardless of whether you were running `apply`, or `apply-all`, or using a `dependency`. This would give us a consistent behavior across all commands _and_ support mapping multiple repos by specifying the param multiple times:

```
terragrunt apply \
  --terragrunt-source-map github.com/org/modules.git:/local/path/to/modules \
  --terragrunt-source-map github.com/org/another-repo.git:/local/path/to/another-repo
```

We could also consider allowing users to specify this mapping in a file:

```
terragrunt apply --terragrunt-source-map mapping.hcl
```

Where `mapping.hcl` contains:

```hcl
mappings = {
  "github.com/org/modules.git"      = "/local/path/to/modules"
  "github.com/org/another-repo.git" = "/local/path/to/another-repo"  
}
```

See https://github.com/gruntwork-io/terragrunt/pull/1134#discussion_r407167726 for context for this issue.

Hints from issue comments:
@yorinasub17: Core feature was implemented in https://github.com/gruntwork-io/terragrunt/releases/tag/v0.29.3, but still missing the following:

- [x] Environment variable configuration (starting `v0.29.5`)
- [ ] file based configuration@yorinasub17: Env var config PR: https://github.com/gruntwork-io/terragrunt/pull/1676@queglay: I am wondering, can this handle the immutable ref suffix if used?
https://github.com/gruntwork-io/terragrunt/issues/1627#issuecomment-847082445

in the example linked, there may be modules with these types of links:
```
source = "github.com/firehawkvfx/firehawk-main.git//modules/vault?ref=v0.0.20"
source = "github.com/firehawkvfx/firehawk-main.git//modules/vault-kv-init?ref=v0.0.20"
source = "github.com/firehawkvfx/firehawk-main.git//modules/vault-policies?ref=v0.0.20"
```
We would want to override all these to point at another path to test before the next release.@yorinasub17: Yes this should work with `--terragrunt-source-map github.com/firehawkvfx/firehawk-main.git=/path/to/local/modules`. The source map feature ignores and drops query parameters when doing the mapping.@queglay: Thank you for clarifying.@ppozeti: First of all this is a great feature! I've got a lot of nested modules and it is a pain to test them.

Sadly I got no luck configuring that in Windows. I tried to run like `--terragrunt-source-map git::https://azuredevopsurl/_git/=C:\IaC` and it fails with "Did not find any Terraform files (*.tf)".

I compared debug logs with/without the --terragrunt-source-map argument and it seems that the "WorkingDir" variable gets a non hashed value when using --terragrunt-source-map with C:\ in the path.

Have anyone being able to use this with Windows?@davidwneary: > The source map feature ignores and drops query parameters when doing the mapping.

Are there any plans to allow this behaviour to be configurable? e.g. `--preserve-query-parameters` or equivalent?

My use case is that I define the paths to my modules using something like 
```
git::ssh://git@gitlab.com/company/project.git?ref=1.0
```
This means a developer can check the project out and run a plan without any faffing with credentials or personal access tokens.

However, my CI runner doesn't have an SSH key, so would ideally use a path like

```
git::https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/company/project.git?ref=1.0
```
I tried using TERRAFORM_SOURCE_MAP for this but unfortunately, because the query parameters are ignored, it will always point to the master branch rather than the ref specified.

Cheers!@angeloskaltsikis: > > The source map feature ignores and drops query parameters when doing the mapping.
> 
> Are there any plans to allow this behaviour to be configurable? e.g. `--preserve-query-parameters` or equivalent?
> 
> My use case is that I define the paths to my modules using something like
> 
> ```
> git::ssh://git@gitlab.com/company/project.git?ref=1.0
> ```
> 
> This means a developer can check the project out and run a plan without any faffing with credentials or personal access tokens.
> 
> However, my CI runner doesn't have an SSH key, so would ideally use a path like
> 
> ```
> git::https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/company/project.git?ref=1.0
> ```
> 
> I tried using TERRAFORM_SOURCE_MAP for this but unfortunately, because the query parameters are ignored, it will always point to the master branch rather than the ref specified.
> 
> Cheers!

Can you try to add to your CI `.gitconfig` the following contents like Atlantis does 
https://github.com/runatlantis/helm-charts/blob/main/charts/atlantis/values.yaml#L53 ?
I think this will solve your issue.@davidwneary: @angeloskaltsikis amazing! Thanks so much for the quick response, this did just the trick. I added the following as a pre-script:

```
git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com".insteadOf ssh://git@gitlab.com
```
And now no need to use TERRAFORM_SOURCE_MAP at all in the CI. Thanks a lot!@angeloskaltsikis: > @angeloskaltsikis amazing! Thanks so much for the quick response, this did just the trick. I added the following as a pre-script:
> 
> ```
> git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com".insteadOf ssh://git@gitlab.com
> ```
> 
> And now no need to use TERRAFORM_SOURCE_MAP at all in the CI. Thanks a lot!

That's great to hear. I would be glad if you can add me in LinkedIn to discuss further about your CI and how do you use it :-)@github-actions: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for raising this issue.@yhakbar: This is available in the CLI:
https://terragrunt.gruntwork.io/docs/reference/cli-options/#source-map

Solution patch:
diff --git a/cli/args.go b/cli/args.go
index 54ccb3a2..9e38a92c 100644
--- a/cli/args.go
+++ b/cli/args.go
@@ -95,6 +95,11 @@ func parseTerragruntOptionsFromArgs(terragruntVersion string, args []string, wri
 		return nil, err
 	}
 
+	terraformSourceMap, err := parseMutliStringKeyValueArg(args, OPT_TERRAGRUNT_SOURCE_MAP, nil)
+	if err != nil {
+		return nil, err
+	}
+
 	sourceUpdate := parseBooleanArg(args, OPT_TERRAGRUNT_SOURCE_UPDATE, os.Getenv("TERRAGRUNT_SOURCE_UPDATE") == "true" || os.Getenv("TERRAGRUNT_SOURCE_UPDATE") == "1")
 
 	ignoreDependencyErrors := parseBooleanArg(args, OPT_TERRAGRUNT_IGNORE_DEPENDENCY_ERRORS, false)
@@ -166,6 +171,7 @@ func parseTerragruntOptionsFromArgs(terragruntVersion string, args []string, wri
 	opts.Logger.Logger.SetOutput(errWriter)
 	opts.RunTerragrunt = RunTerragrunt
 	opts.Source = terraformSource
+	opts.SourceMap = terraformSourceMap
 	opts.SourceUpdate = sourceUpdate
 	opts.TerragruntVersion, err = version.NewVersion(terragruntVersion)
 	if err != nil {
diff --git a/cli/cli_app.go b/cli/cli_app.go
index 96b46851..c06901ec 100644
--- a/cli/cli_app.go
+++ b/cli/cli_app.go
@@ -32,6 +32,7 @@ const OPT_NON_INTERACTIVE = "terragrunt-non-interactive"
 const OPT_WORKING_DIR = "terragrunt-working-dir"
 const OPT_DOWNLOAD_DIR = "terragrunt-download-dir"
 const OPT_TERRAGRUNT_SOURCE = "terragrunt-source"
+const OPT_TERRAGRUNT_SOURCE_MAP = "terragrunt-source-map"
 const OPT_TERRAGRUNT_SOURCE_UPDATE = "terragrunt-source-update"
 const OPT_TERRAGRUNT_IAM_ROLE = "terragrunt-iam-role"
 const OPT_TERRAGRUNT_IGNORE_DEPENDENCY_ERRORS = "terragrunt-ignore-dependency-errors"
@@ -67,6 +68,7 @@ var ALL_TERRAGRUNT_STRING_OPTS = []string{
 	OPT_WORKING_DIR,
 	OPT_DOWNLOAD_DIR,
 	OPT_TERRAGRUNT_SOURCE,
+	OPT_TERRAGRUNT_SOURCE_MAP,
 	OPT_TERRAGRUNT_IAM_ROLE,
 	OPT_TERRAGRUNT_EXCLUDE_DIR,
 	OPT_TERRAGRUNT_INCLUDE_DIR,
@@ -406,7 +408,11 @@ func RunTerragrunt(terragruntOptions *options.TerragruntOptions) error {
 	}
 
 	updatedTerragruntOptions := terragruntOptions
-	if sourceUrl := config.GetTerraformSourceUrl(terragruntOptions, terragruntConfig); sourceUrl != "" {
+	sourceUrl, err := config.GetTerraformSourceUrl(terragruntOptions, terragruntConfig)
+	if err != nil {
+		return err
+	}
+	if sourceUrl != "" {
 		updatedTerragruntOptions, err = downloadTerraformSource(sourceUrl, terragruntOptions, terragruntConfig)
 		if err != nil {
 			return err
diff --git a/config/config.go b/config/config.go
index 31f1f4a9..4bf34414 100644
--- a/config/config.go
+++ b/config/config.go
@@ -2,14 +2,16 @@ package config
 
 import (
 	"fmt"
-	"github.com/mitchellh/mapstructure"
+	"net/url"
 	"os"
 	"path/filepath"
 	"reflect"
 	"strings"
 
+	"github.com/hashicorp/go-getter"
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclparse"
+	"github.com/mitchellh/mapstructure"
 	"github.com/sirupsen/logrus"
 	"github.com/zclconf/go-cty/cty"
 
@@ -317,14 +319,79 @@ func (conf *TerraformExtraArguments) GetVarFiles(logger *logrus.Entry) []string
 // There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific
 // URL: via a command-line option or via an entry in the Terragrunt configuration. If the user used one of these, this
 // method returns the source URL or an empty string if there is no source url
-func GetTerraformSourceUrl(terragruntOptions *options.TerragruntOptions, terragruntConfig *TerragruntConfig) string {
+func GetTerraformSourceUrl(terragruntOptions *options.TerragruntOptions, terragruntConfig *TerragruntConfig) (string, error) {
 	if terragruntOptions.Source != "" {
-		return terragruntOptions.Source
+		return terragruntOptions.Source, nil
 	} else if terragruntConfig.Terraform != nil && terragruntConfig.Terraform.Source != nil {
-		return *terragruntConfig.Terraform.Source
+		return adjustSourceWithMap(terragruntOptions.SourceMap, *terragruntConfig.Terraform.Source, terragruntOptions.OriginalTerragruntConfigPath)
 	} else {
-		return ""
+		return "", nil
+	}
+}
+
+// adjustSourceWithMap implements the --terragrunt-source-map feature. This function will check if the URL portion of a
+// terraform source matches any entry in the provided source map and if it does, replace it with the configured source
+// in the map. Note that this only performs literal matches with the URL portion.
+//
+// Example:
+// Suppose terragrunt is called with:
+//
+//   --terragrunt-source-map git::ssh://git@github.com/gruntwork-io/i-dont-exist.git=/path/to/local-modules
+//
+// and the terraform source is:
+//
+//   git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/app?ref=master
+//
+// This function will take that source and transform it to:
+//
+//   /path/to/local-modules/fixture-source-map/modules/app
+//
+func adjustSourceWithMap(sourceMap map[string]string, source string, modulePath string) (string, error) {
+	// Skip logic if source map is not configured
+	if len(sourceMap) == 0 {
+		return source, nil
 	}
+
+	// use go-getter to split the module source string into a valid URL and subdirectory (if // is present)
+	moduleUrl, moduleSubdir := getter.SourceDirSubdir(source)
+
+	// if both URL and subdir are missing, something went terribly wrong
+	if moduleUrl == "" && moduleSubdir == "" {
+		return "", errors.WithStackTrace(InvalidSourceUrlWithMap{ModulePath: modulePath, ModuleSourceUrl: source})
+	}
+
+	// If module URL is missing, return the source as is as it will not match anything in the map.
+	if moduleUrl == "" {
+		return source, nil
+	}
+
+	// Before looking up in sourceMap, make sure to drop any query parameters.
+	moduleUrlParsed, err := url.Parse(moduleUrl)
+	if err != nil {
+		return source, err
+	}
+	moduleUrlParsed.RawQuery = ""
+	moduleUrlQuery := moduleUrlParsed.String()
+
+	// Check if there is an entry to replace the URL portion in the map. Return the source as is if there is no entry in
+	// the map.
+	sourcePath, hasKey := sourceMap[moduleUrlQuery]
+	if hasKey == false {
+		return source, nil
+	}
+
+	// Since there is a source mapping, replace the module URL portion with the entry in the map, and join with the
+	// subdir.
+	// If subdir is missing, check if we can obtain a valid module name from the URL portion.
+	if moduleSubdir == "" {
+		moduleSubdirFromUrl, err := getModulePathFromSourceUrl(moduleUrl)
+		if err != nil {
+			return moduleSubdirFromUrl, err
+		}
+		moduleSubdir = moduleSubdirFromUrl
+	}
+	return util.JoinTerraformModulePath(sourcePath, moduleSubdir), nil
+
 }
 
 // Return the default hcl path to use for the Terragrunt configuration file in the given directory
diff --git a/config/config_helpers.go b/config/config_helpers.go
index 323e4888..1f554be2 100644
--- a/config/config_helpers.go
+++ b/config/config_helpers.go
@@ -657,6 +657,15 @@ func (err InvalidSourceUrl) Error() string {
 	return fmt.Sprintf("The --terragrunt-source parameter is set to '%s', but the source URL in the module at '%s' is invalid: '%s'. Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!", err.TerragruntSource, err.ModulePath, err.ModuleSourceUrl)
 }
 
+type InvalidSourceUrlWithMap struct {
+	ModulePath      string
+	ModuleSourceUrl string
+}
+
+func (err InvalidSourceUrlWithMap) Error() string {
+	return fmt.Sprintf("The --terragrunt-source-map parameter was passed in, but the source URL in the module at '%s' is invalid: '%s'. Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!", err.ModulePath, err.ModuleSourceUrl)
+}
+
 type ErrorParsingModulePath struct {
 	ModuleSourceUrl string
 }
diff --git a/config/dependency.go b/config/dependency.go
index e252f150..efa82117 100644
--- a/config/dependency.go
+++ b/config/dependency.go
@@ -452,7 +452,10 @@ func terragruntAlreadyInit(terragruntOptions *options.TerragruntOptions, configP
 		return false, "", err
 	}
 	var workingDir string
-	sourceUrl := GetTerraformSourceUrl(terragruntOptions, terraformBlockTGConfig)
+	sourceUrl, err := GetTerraformSourceUrl(terragruntOptions, terraformBlockTGConfig)
+	if err != nil {
+		return false, "", err
+	}
 	if sourceUrl == "" || sourceUrl == "." {
 		// When there is no source URL, there is no download process and the working dir is the same as the directory
 		// where the config is.
diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md
index e28656ff..293942bc 100644
--- a/docs/_docs/04_reference/cli-options.md
+++ b/docs/_docs/04_reference/cli-options.md
@@ -389,6 +389,7 @@ prefix `--terragrunt-` (e.g., `--terragrunt-config`). The currently available op
 - [terragrunt-working-dir](#terragrunt-working-dir)
 - [terragrunt-download-dir](#terragrunt-download-dir)
 - [terragrunt-source](#terragrunt-source)
+- [terragrunt-source-map](#terragrunt-source-map)
 - [terragrunt-source-update](#terragrunt-source-update)
 - [terragrunt-ignore-dependency-errors](#terragrunt-ignore-dependency-errors)
 - [terragrunt-iam-role](#terragrunt-iam-role)
@@ -501,6 +502,35 @@ for all of your Terraform modules, and for each module processed by the `xxx-all
 append the path of `source` parameter in each module to the `--terragrunt-source` parameter you passed in.
 
 
+### terragrunt-source-map
+
+**CLI Arg**: `--terragrunt-source-map`<br/>
+**Requires an argument**: `--terragrunt-source-map git::ssh://github.com=/path/to/local-terraform-code`
+
+Can be supplied multiple times: `--terragrunt-source-map source1=dest1 --terragrunt-source-map source2=dest2`
+
+The `--terragrunt-source-map source=dest` param replaces any `source` URL (including the source URL of a config pulled
+in with `dependency` blocks) that has root `source` with `dest`.
+
+For example:
+
+```
+terragrunt apply --terragrunt-source-map github.com/org/modules.git:/local/path/to/modules
+```
+
+The above would replace `terraform { source = "github.com/org/modules.git//xxx" }` with `terraform { source = /local/path/to/modules//xxx }` regardless of
+whether you were running `apply`, or `run-all`, or using a `dependency`.
+
+**NOTE**: This setting is ignored if you pass in `--terragrunt-source`.
+
+Note that this only performs literal matches on the URL portion. For example, a map key of
+`ssh://git@github.com/gruntwork-io/terragrunt.git` will only match terragrunt configurations with source `source =
+"ssh://git@github.com/gruntwork-io/terragrunt.git//xxx"` and not sources of the form `source =
+"git::ssh://git@github.com/gruntwork-io/terragrunt.git//xxx"`. The latter requires a map key of
+`git::ssh://git@github.com/gruntwork-io/terragrunt.git`.
+
+
+
 ### terragrunt-source-update
 
 **CLI Arg**: `--terragrunt-source-update`<br/>
diff --git a/options/options.go b/options/options.go
index da471ab9..b3153ebc 100644
--- a/options/options.go
+++ b/options/options.go
@@ -91,6 +91,10 @@ type TerragruntOptions struct {
 	// Terraform in that temporary folder
 	Source string
 
+	// Map to replace terraform source locations. This will replace occurences of the given source with the target
+	// value.
+	SourceMap map[string]string
+
 	// If set to true, delete the contents of the temporary folder before downloading Terraform source code into it
 	SourceUpdate bool
 
@@ -190,6 +194,7 @@ func NewTerragruntOptions(terragruntConfigPath string) (*TerragruntOptions, erro
 		LogLevel:                    DEFAULT_LOG_LEVEL,
 		Env:                         map[string]string{},
 		Source:                      "",
+		SourceMap:                   map[string]string{},
 		SourceUpdate:                false,
 		DownloadDir:                 downloadDir,
 		IgnoreDependencyErrors:      false,
@@ -265,6 +270,7 @@ func (terragruntOptions *TerragruntOptions) Clone(terragruntConfigPath string) *
 		LogLevel:                     terragruntOptions.LogLevel,
 		Env:                          util.CloneStringMap(terragruntOptions.Env),
 		Source:                       terragruntOptions.Source,
+		SourceMap:                    terragruntOptions.SourceMap,
 		SourceUpdate:                 terragruntOptions.SourceUpdate,
 		DownloadDir:                  terragruntOptions.DownloadDir,
 		Debug:                        terragruntOptions.Debug,



Test patch:
diff --git a/test/fixture-source-map/modules/app/main.tf b/test/fixture-source-map/modules/app/main.tf
new file mode 100644
index 00000000..a4f5a570
--- /dev/null
+++ b/test/fixture-source-map/modules/app/main.tf
@@ -0,0 +1,7 @@
+variable "name" {}
+
+variable "vpc_id" {}
+
+output "app_url" {
+  value = "https://${var.name}.${var.vpc_id}.foo.io"
+}
diff --git a/test/fixture-source-map/modules/vpc/main.tf b/test/fixture-source-map/modules/vpc/main.tf
new file mode 100644
index 00000000..6ee69450
--- /dev/null
+++ b/test/fixture-source-map/modules/vpc/main.tf
@@ -0,0 +1,5 @@
+variable "name" {}
+
+output "vpc_id" {
+  value = "vpc-${var.name}-asdf1234"
+}
diff --git a/test/fixture-source-map/multiple-match/terragrunt-vpc/terragrunt.hcl b/test/fixture-source-map/multiple-match/terragrunt-vpc/terragrunt.hcl
new file mode 100644
index 00000000..78171048
--- /dev/null
+++ b/test/fixture-source-map/multiple-match/terragrunt-vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/another-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terragrunt"
+}
diff --git a/test/fixture-source-map/multiple-match/terratest-vpc/terragrunt.hcl b/test/fixture-source-map/multiple-match/terratest-vpc/terragrunt.hcl
new file mode 100644
index 00000000..de2aceda
--- /dev/null
+++ b/test/fixture-source-map/multiple-match/terratest-vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terratest"
+}
diff --git a/test/fixture-source-map/multiple-only-one-match/terragrunt-vpc/terragrunt.hcl b/test/fixture-source-map/multiple-only-one-match/terragrunt-vpc/terragrunt.hcl
new file mode 100644
index 00000000..bdaab54e
--- /dev/null
+++ b/test/fixture-source-map/multiple-only-one-match/terragrunt-vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "../../modules/vpc"
+}
+
+inputs = {
+  name = "terragrunt"
+}
diff --git a/test/fixture-source-map/multiple-only-one-match/terratest-vpc/terragrunt.hcl b/test/fixture-source-map/multiple-only-one-match/terratest-vpc/terragrunt.hcl
new file mode 100644
index 00000000..de2aceda
--- /dev/null
+++ b/test/fixture-source-map/multiple-only-one-match/terratest-vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terratest"
+}
diff --git a/test/fixture-source-map/multiple-with-dependency-same-url/app/terragrunt.hcl b/test/fixture-source-map/multiple-with-dependency-same-url/app/terragrunt.hcl
new file mode 100644
index 00000000..896d24b4
--- /dev/null
+++ b/test/fixture-source-map/multiple-with-dependency-same-url/app/terragrunt.hcl
@@ -0,0 +1,12 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/app?ref=master"
+}
+
+dependency "vpc" {
+  config_path = "../vpc"
+}
+
+inputs = {
+  name   = "terragrunt"
+  vpc_id = dependency.vpc.outputs.vpc_id
+}
diff --git a/test/fixture-source-map/multiple-with-dependency-same-url/vpc/terragrunt.hcl b/test/fixture-source-map/multiple-with-dependency-same-url/vpc/terragrunt.hcl
new file mode 100644
index 00000000..8c9c5d9e
--- /dev/null
+++ b/test/fixture-source-map/multiple-with-dependency-same-url/vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terragrunt"
+}
diff --git a/test/fixture-source-map/multiple-with-dependency/app/terragrunt.hcl b/test/fixture-source-map/multiple-with-dependency/app/terragrunt.hcl
new file mode 100644
index 00000000..de8a6890
--- /dev/null
+++ b/test/fixture-source-map/multiple-with-dependency/app/terragrunt.hcl
@@ -0,0 +1,12 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/another-dont-exist.git//fixture-source-map/modules/app?ref=master"
+}
+
+dependency "vpc" {
+  config_path = "../vpc"
+}
+
+inputs = {
+  name   = "terragrunt"
+  vpc_id = dependency.vpc.outputs.vpc_id
+}
diff --git a/test/fixture-source-map/multiple-with-dependency/vpc/terragrunt.hcl b/test/fixture-source-map/multiple-with-dependency/vpc/terragrunt.hcl
new file mode 100644
index 00000000..8c9c5d9e
--- /dev/null
+++ b/test/fixture-source-map/multiple-with-dependency/vpc/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terragrunt"
+}
diff --git a/test/fixture-source-map/single/terragrunt.hcl b/test/fixture-source-map/single/terragrunt.hcl
new file mode 100644
index 00000000..8c9c5d9e
--- /dev/null
+++ b/test/fixture-source-map/single/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixture-source-map/modules/vpc?ref=master"
+}
+
+inputs = {
+  name = "terragrunt"
+}
diff --git a/test/integration_local_dev_test.go b/test/integration_local_dev_test.go
new file mode 100644
index 00000000..66a8909d
--- /dev/null
+++ b/test/integration_local_dev_test.go
@@ -0,0 +1,120 @@
+package test
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"github.com/gruntwork-io/terragrunt/util"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTerragruntSourceMap(t *testing.T) {
+	t.Parallel()
+
+	fixtureSourceMapPath := "fixture-source-map"
+	cleanupTerraformFolder(t, fixtureSourceMapPath)
+	tmpEnvPath := copyEnvironment(t, fixtureSourceMapPath)
+	rootPath := filepath.Join(tmpEnvPath, fixtureSourceMapPath)
+	sourceMapArgs := fmt.Sprintf(
+		"--terragrunt-source-map %s --terragrunt-source-map %s",
+		fmt.Sprintf("git::ssh://git@github.com/gruntwork-io/i-dont-exist.git=%s", tmpEnvPath),
+		fmt.Sprintf("git::ssh://git@github.com/gruntwork-io/another-dont-exist.git=%s", tmpEnvPath),
+	)
+
+	testCases := []struct {
+		name     string
+		applyAll bool
+	}{
+		{
+			name:     "multiple-match",
+			applyAll: true,
+		},
+		{
+			name:     "multiple-only-one-match",
+			applyAll: true,
+		},
+		{
+			name:     "multiple-with-dependency",
+			applyAll: true,
+		},
+		{
+			name:     "multiple-with-dependency-same-url",
+			applyAll: true,
+		},
+		{
+			name:     "single",
+			applyAll: false,
+		},
+	}
+
+	for _, testCase := range testCases {
+		// capture range variable to avoid it changing across for loop runs during goroutine transitions.
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			tgPath := filepath.Join(rootPath, testCase.name)
+
+			action := "apply"
+			if testCase.applyAll {
+				action = "run-all apply"
+			}
+
+			tgArgs := fmt.Sprintf("terragrunt %s -auto-approve --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-working-dir %s %s", action, tgPath, sourceMapArgs)
+			runTerragrunt(t, tgArgs)
+		})
+	}
+}
+
+func TestGetTerragruntSourceHCL(t *testing.T) {
+	t.Parallel()
+
+	cleanupTerraformFolder(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
+	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
+	terraformSource := "" // get_terragrunt_source_cli_flag() only returns the source when it is passed in via the CLI
+
+	runTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath))
+
+	// verify expected outputs are not empty
+	stdout := bytes.Buffer{}
+	stderr := bytes.Buffer{}
+
+	require.NoError(
+		t,
+		runTerragruntCommand(t, fmt.Sprintf("terragrunt output -no-color -json --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath), &stdout, &stderr),
+	)
+
+	outputs := map[string]TerraformOutput{}
+
+	require.NoError(t, json.Unmarshal([]byte(stdout.String()), &outputs))
+	assert.Equal(t, fmt.Sprintf("HCL: %s", terraformSource), outputs["terragrunt_source"].Value)
+}
+
+func TestGetTerragruntSourceCLI(t *testing.T) {
+	t.Parallel()
+
+	cleanupTerraformFolder(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
+	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
+	terraformSource := "terraform_config_cli"
+
+	runTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-source %s", rootPath, terraformSource))
+
+	// verify expected outputs are not empty
+	stdout := bytes.Buffer{}
+	stderr := bytes.Buffer{}
+
+	require.NoError(
+		t,
+		runTerragruntCommand(t, fmt.Sprintf("terragrunt output -no-color -json --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-source %s", rootPath, terraformSource), &stdout, &stderr),
+	)
+
+	outputs := map[string]TerraformOutput{}
+
+	require.NoError(t, json.Unmarshal([]byte(stdout.String()), &outputs))
+	assert.Equal(t, fmt.Sprintf("CLI: %s", terraformSource), outputs["terragrunt_source"].Value)
+}
diff --git a/test/integration_test.go b/test/integration_test.go
index 938aa0e4..d5e61085 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -2767,56 +2767,6 @@ func TestAWSGetCallerIdentityFunctions(t *testing.T) {
 	assert.Equal(t, outputs["user_id"].Value, *identity.UserId)
 }
 
-func TestGetTerragruntSourceHCL(t *testing.T) {
-	t.Parallel()
-
-	cleanupTerraformFolder(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
-	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
-	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_HCL)
-	terraformSource := "" // get_terragrunt_source_cli_flag() only returns the source when it is passed in via the CLI
-
-	runTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath))
-
-	// verify expected outputs are not empty
-	stdout := bytes.Buffer{}
-	stderr := bytes.Buffer{}
-
-	require.NoError(
-		t,
-		runTerragruntCommand(t, fmt.Sprintf("terragrunt output -no-color -json --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath), &stdout, &stderr),
-	)
-
-	outputs := map[string]TerraformOutput{}
-
-	require.NoError(t, json.Unmarshal([]byte(stdout.String()), &outputs))
-	assert.Equal(t, fmt.Sprintf("HCL: %s", terraformSource), outputs["terragrunt_source"].Value)
-}
-
-func TestGetTerragruntSourceCLI(t *testing.T) {
-	t.Parallel()
-
-	cleanupTerraformFolder(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
-	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
-	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_GET_TERRAGRUNT_SOURCE_CLI)
-	terraformSource := "terraform_config_cli"
-
-	runTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-source %s", rootPath, terraformSource))
-
-	// verify expected outputs are not empty
-	stdout := bytes.Buffer{}
-	stderr := bytes.Buffer{}
-
-	require.NoError(
-		t,
-		runTerragruntCommand(t, fmt.Sprintf("terragrunt output -no-color -json --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-source %s", rootPath, terraformSource), &stdout, &stderr),
-	)
-
-	outputs := map[string]TerraformOutput{}
-
-	require.NoError(t, json.Unmarshal([]byte(stdout.String()), &outputs))
-	assert.Equal(t, fmt.Sprintf("CLI: %s", terraformSource), outputs["terragrunt_source"].Value)
-}
-
 func TestGetPlatform(t *testing.T) {
 	t.Parallel()