Repository: gruntwork-io/terragrunt

Issue:
mark_as_read api inconsistent with file api
## Describe the enhancement

Right now mark_as_read requires an absolute path to function correctly but file can use a relpath. The docs (until very recently) explain the desired usage pattern is something like file(mark_as_read('myfile.txt')). If we use a relpath for this then this has the opportunity to break!

The inconsistency of api for both mark_as_read vs file is a UX issue.

Example breaking code (ignore the lack of fileset here- I didn't write it!)
```
locals {
  _all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
  _all_files_marked = [for f in local._all_files : mark_as_read("src/${f}")]
}
```

Example that correctly marks as read:
```
locals {
  _all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
  _all_files_marked = [for f in local._all_files : mark_as_read(abspath("${get_terragrunt_dir()}/src/${f}"))]
}
```


## Additional context

Flagged by a customer relying on tf files in their infrastructure live repo.


Link to a Proof of Concept if you have one:

- [ ] [PoC]()

Including a PoC can help others understand the feature better and implement it faster.

## RFC Not Needed

- [ ] I have evaluated the complexity of this enhancement, and I believe it does not require an RFC.



Discussion:

Solution patch:
diff --git a/cli/commands/stack/action.go b/cli/commands/stack/action.go
index f5b769e1..46e7e0d4 100644
--- a/cli/commands/stack/action.go
+++ b/cli/commands/stack/action.go
@@ -26,7 +26,10 @@ func RunGenerate(ctx context.Context, opts *options.TerragruntOptions) error {
 		return err
 	}
 
-	return generateStack(ctx, opts)
+	opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile)
+	opts.Logger.Infof("Generating stack from %s", opts.TerragruntStackConfigPath)
+
+	return config.GenerateStacks(ctx, opts)
 }
 
 // Run execute stack command.
diff --git a/cli/commands/stack/command.go b/cli/commands/stack/command.go
index 8976c432..4302dfca 100644
--- a/cli/commands/stack/command.go
+++ b/cli/commands/stack/command.go
@@ -25,26 +25,20 @@ const (
 	jsonOutputFormat = "json"
 )
 
-// NewFlags builds the flags for stack.
-func NewFlags(_ *options.TerragruntOptions) cli.Flags {
-	return cli.Flags{}
-}
-
 // NewCommand builds the command for stack.
 func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 	return &cli.Command{
 		Name:                 CommandName,
 		Usage:                "Terragrunt stack commands.",
 		ErrorOnUndefinedFlag: true,
-		Flags:                NewFlags(opts).Sort(),
 		Subcommands: cli.Commands{
 			&cli.Command{
 				Name:  generateCommandName,
 				Usage: "Generate a stack from a terragrunt.stack.hcl file",
 				Action: func(ctx *cli.Context) error {
 					return RunGenerate(ctx.Context, opts.OptionsFromContext(ctx))
-
 				},
+				Flags: defaultFlags(opts).Sort(),
 			},
 			&cli.Command{
 				Name:  runCommandName,
@@ -52,7 +46,7 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 				Action: func(ctx *cli.Context) error {
 					return Run(ctx.Context, opts.OptionsFromContext(ctx))
 				},
-				Flags: runFlags(opts).Sort(),
+				Flags: defaultFlags(opts).Sort(),
 			},
 			&cli.Command{
 				Name:  outputCommandName,
@@ -81,7 +75,7 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 func outputFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags {
 	tgPrefix := prefix.Prepend(flags.TgPrefix)
 
-	return cli.Flags{
+	flags := cli.Flags{
 		flags.NewFlag(&cli.GenericFlag[string]{
 			Name:        OutputFormatFlagName,
 			EnvVars:     tgPrefix.EnvVars(OutputFormatFlagName),
@@ -105,9 +99,11 @@ func outputFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags
 			},
 		}),
 	}
+
+	return append(defaultFlags(opts), flags...)
 }
 
-func runFlags(opts *options.TerragruntOptions) cli.Flags {
+func defaultFlags(opts *options.TerragruntOptions) cli.Flags {
 	flags := runall.NewFlags(opts, runCommandName, nil).Filter(runall.OutDirFlagName, runall.JSONOutDirFlagName)
 	flags = append(flags, run.NewFlags(opts, nil)...)
 
diff --git a/cli/commands/stack/generate.go b/cli/commands/stack/generate.go
deleted file mode 100644
index 90620e6f..00000000
--- a/cli/commands/stack/generate.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package stack
-
-import (
-	"context"
-	"path/filepath"
-
-	"github.com/gruntwork-io/terragrunt/config"
-	"github.com/gruntwork-io/terragrunt/options"
-)
-
-func generateStack(ctx context.Context, opts *options.TerragruntOptions) error {
-	opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile)
-	opts.Logger.Infof("Generating stack from %s", opts.TerragruntStackConfigPath)
-
-	return config.GenerateStacks(ctx, opts)
-}
diff --git a/config/config_helpers.go b/config/config_helpers.go
index 2026d8b9..b2609e98 100644
--- a/config/config_helpers.go
+++ b/config/config_helpers.go
@@ -3,11 +3,13 @@ package config
 import (
 	"encoding/json"
 	"fmt"
+	"maps"
 	"os"
 	"path/filepath"
 	"reflect"
 	"regexp"
 	"runtime"
+	"slices"
 	"strings"
 	"unicode/utf8"
 
@@ -185,17 +187,10 @@ func createTerragruntEvalContext(ctx *ParsingContext, configPath string) (*hcl.E
 	}
 
 	functions := map[string]function.Function{}
-	for k, v := range tfscope.Functions() {
-		functions[k] = v
-	}
-
-	for k, v := range terragruntFunctions {
-		functions[k] = v
-	}
 
-	for k, v := range ctx.PredefinedFunctions {
-		functions[k] = v
-	}
+	maps.Copy(functions, tfscope.Functions())
+	maps.Copy(functions, terragruntFunctions)
+	maps.Copy(functions, ctx.PredefinedFunctions)
 
 	evalCtx := &hcl.EvalContext{
 		Functions: functions,
@@ -355,11 +350,11 @@ func RunCommand(ctx *ParsingContext, args []string) (string, error) {
 		case "--terragrunt-quiet":
 			suppressOutput = true
 
-			args = append(args[:0], args[1:]...)
+			args = slices.Delete(args, 0, 1)
 		case "--terragrunt-global-cache":
 			cachePath = "_global_"
 
-			args = append(args[:0], args[1:]...)
+			args = slices.Delete(args, 0, 1)
 		default:
 			checkOptions = false
 		}
@@ -473,7 +468,7 @@ func FindInParentFolders(
 
 	// To avoid getting into an accidental infinite loop (e.g. do to cyclical symlinks), set a max on the number of
 	// parent folders we'll check
-	for i := 0; i < ctx.TerragruntOptions.MaxFoldersToCheck; i++ {
+	for range ctx.TerragruntOptions.MaxFoldersToCheck {
 		currentDir := filepath.ToSlash(filepath.Dir(previousDir))
 		if currentDir == previousDir {
 			if numParams == matchedPats {
@@ -674,9 +669,11 @@ func ParseTerragruntConfig(ctx *ParsingContext, configPath string, defaultVal *c
 		return *defaultVal, nil
 	}
 
-	path, err := util.CanonicalPath(targetConfig, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return cty.NilVal, errors.New(err)
+	path := targetConfig
+
+	if !filepath.IsAbs(path) {
+		path = util.JoinPath(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
@@ -702,7 +699,7 @@ func ParseTerragruntConfig(ctx *ParsingContext, configPath string, defaultVal *c
 	// serialize the config for consumption.
 	// NOTE: this will not call terragrunt output, since all the values are cached from the ParseConfigFile call
 	// NOTE: we don't use range here because range will copy the slice, thereby undoing the set attribute.
-	for i := 0; i < len(config.TerragruntDependencies); i++ {
+	for i := range len(config.TerragruntDependencies) {
 		err := config.TerragruntDependencies[i].setRenderedOutputs(ctx)
 		if err != nil {
 			return cty.NilVal, errors.New(err)
@@ -858,13 +855,15 @@ func sopsDecryptFile(ctx *ParsingContext, params []string) (string, error) {
 		return "", errors.New(err)
 	}
 
-	canonicalSourceFile, err := util.CanonicalPath(sourceFile, filepath.Dir(ctx.TerragruntOptions.TerragruntConfigPath))
-	if err != nil {
-		return "", errors.New(err)
+	path := sourceFile
+
+	if !filepath.IsAbs(path) {
+		path = util.JoinPath(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
-		canonicalSourceFile,
+		path,
 		ctx.TerragruntOptions.WorkingDir,
 	)
 
@@ -889,18 +888,18 @@ func sopsDecryptFile(ctx *ParsingContext, params []string) (string, error) {
 		}
 	}
 
-	if val, ok := sopsCache.Get(ctx, canonicalSourceFile); ok {
+	if val, ok := sopsCache.Get(ctx, path); ok {
 		return val, nil
 	}
 
-	rawData, err := decrypt.File(canonicalSourceFile, format)
+	rawData, err := decrypt.File(path, format)
 	if err != nil {
 		return "", errors.New(extractSopsErrors(err))
 	}
 
 	if utf8.Valid(rawData) {
 		value := string(rawData)
-		sopsCache.Put(ctx, canonicalSourceFile, value)
+		sopsCache.Put(ctx, path, value)
 
 		return value, nil
 	}
@@ -1055,9 +1054,9 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 
 	varFile := args[0]
 
-	varFile, err := util.CanonicalPath(varFile, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return "", errors.New(err)
+	if !filepath.IsAbs(varFile) {
+		varFile = filepath.Join(ctx.TerragruntOptions.WorkingDir, varFile)
+		varFile = filepath.Clean(varFile)
 	}
 
 	if !util.FileExists(varFile) {
@@ -1075,7 +1074,7 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 	}
 
 	if strings.HasSuffix(varFile, "json") {
-		var variables map[string]interface{}
+		var variables map[string]any
 		// just want to be sure that the file is valid json
 		if err := json.Unmarshal(fileContents, &variables); err != nil {
 			return "", errors.New(fmt.Errorf("could not unmarshal json body of tfvar file: %w", err))
@@ -1084,7 +1083,7 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 		return string(fileContents), nil
 	}
 
-	var variables map[string]interface{}
+	var variables map[string]any
 	if err := ParseAndDecodeVarFile(ctx.TerragruntOptions, varFile, fileContents, &variables); err != nil {
 		return "", err
 	}
@@ -1105,9 +1104,14 @@ func markAsRead(ctx *ParsingContext, args []string) (string, error) {
 
 	file := args[0]
 
-	path, err := util.CanonicalPath(file, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return "", errors.New(err)
+	// Copy the file path to avoid modifying the original.
+	// This is necessary so that the HCL function doesn't
+	// return a different value than the original file path.
+	path := file
+
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
@@ -1126,7 +1130,7 @@ func markAsRead(ctx *ParsingContext, args []string) (string, error) {
 
 // ParseAndDecodeVarFile uses the HCL2 file to parse the given varfile string into an HCL file body, and then decode it
 // into the provided output.
-func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, fileContents []byte, out interface{}) error {
+func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, fileContents []byte, out any) error {
 	parser := hclparse.NewParser(hclparse.WithLogger(opts.Logger))
 
 	file, err := parser.ParseFromBytes(fileContents, varFile)
@@ -1160,7 +1164,7 @@ func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, file
 		return nil
 	}
 
-	typedOut, hasType := out.(*map[string]interface{})
+	typedOut, hasType := out.(*map[string]any)
 	if hasType {
 		genericMap, err := ParseCtyValueToMap(ctyVal)
 		if err != nil {
@@ -1190,7 +1194,7 @@ func extractSopsErrors(err error) *errors.MultiError {
 	if errValue.Type().Name() == "getDataKeyError" {
 		groupResultsField := errValue.FieldByName("GroupResults")
 		if groupResultsField.IsValid() && groupResultsField.Kind() == reflect.Slice {
-			for i := 0; i < groupResultsField.Len(); i++ {
+			for i := range groupResultsField.Len() {
 				groupErr := groupResultsField.Index(i)
 				if groupErr.CanInterface() {
 					if err, ok := groupErr.Interface().(error); ok {
diff --git a/config/stack.go b/config/stack.go
index 770cd43d..7c5d600d 100644
--- a/config/stack.go
+++ b/config/stack.go
@@ -195,7 +195,7 @@ func generateUnits(ctx context.Context, opts *options.TerragruntOptions, pool *u
 		unitCopy := unit // Create a copy to avoid capturing the loop variable reference
 
 		pool.Submit(func() error {
-			item := itemToProcess{
+			item := componentToProcess{
 				sourceDir: sourceDir,
 				targetDir: targetDir,
 				name:      unitCopy.Name,
@@ -206,7 +206,7 @@ func generateUnits(ctx context.Context, opts *options.TerragruntOptions, pool *u
 
 			opts.Logger.Infof("Processing unit %s", unitCopy.Name)
 
-			if err := processItem(ctx, opts, &item); err != nil {
+			if err := processComponent(ctx, opts, &item); err != nil {
 				return err
 			}
 
@@ -224,7 +224,7 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 		stackCopy := stack // Create a copy to avoid capturing the loop variable reference
 
 		pool.Submit(func() error {
-			item := itemToProcess{
+			item := componentToProcess{
 				sourceDir: sourceDir,
 				targetDir: targetDir,
 				name:      stackCopy.Name,
@@ -235,7 +235,7 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 
 			opts.Logger.Infof("Processing stack %s", stackCopy.Name)
 
-			if err := processItem(ctx, opts, &item); err != nil {
+			if err := processComponent(ctx, opts, &item); err != nil {
 				return err
 			}
 
@@ -246,7 +246,10 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 	return nil
 }
 
-type itemToProcess struct {
+// componentToProcess represents an item of work for processing a stack or unit.
+// It contains information about the source and target directories, the name and path of the item, the source URL or path,
+// and any associated values that need to be processed.
+type componentToProcess struct {
 	sourceDir string
 	targetDir string
 	name      string
@@ -255,24 +258,31 @@ type itemToProcess struct {
 	values    *cty.Value
 }
 
-// processItem copies files from the source directory to the target destination and generates a corresponding values file.
-func processItem(ctx context.Context, opts *options.TerragruntOptions, item *itemToProcess) error {
-	destPath := filepath.Join(item.targetDir, item.path)
-	dest, err := filepath.Abs(destPath)
+// processComponent copies files from the source directory to the target destination and generates a corresponding values file.
+func processComponent(ctx context.Context, opts *options.TerragruntOptions, cmp *componentToProcess) error {
+	source := cmp.source
+	// Adjust source path using the provided source mapping configuration if available
+	source, err := adjustSourceWithMap(opts.SourceMap, source, opts.TerragruntStackConfigPath)
 
 	if err != nil {
-		return errors.Errorf("failed to get absolute path for destination '%s': %v", dest, err)
+		return errors.Errorf("failed to adjust source %s: %v", cmp.source, err)
 	}
 
-	opts.Logger.Debugf("Processing: %s (%s) to %s", item.name, item.source, dest)
+	dest := cmp.path
+	// if destination is an absolute path, use as is
+	if !filepath.IsAbs(cmp.path) {
+		dest = filepath.Join(cmp.targetDir, cmp.path)
+	}
+
+	opts.Logger.Debugf("Processing: %s (%s) to %s", cmp.name, source, dest)
 
-	if err := copyFiles(ctx, opts, item.name, item.sourceDir, item.source, dest); err != nil {
+	if err := copyFiles(ctx, opts, cmp.name, cmp.sourceDir, source, dest); err != nil {
 		return err
 	}
 
 	// generate values file
-	if err := writeValues(opts, item.values, dest); err != nil {
-		return errors.Errorf("failed to write values %v %v", item.name, err)
+	if err := writeValues(opts, cmp.values, dest); err != nil {
+		return errors.Errorf("failed to write values %v %v", cmp.name, err)
 	}
 
 	return nil
diff --git a/configstack/module.go b/configstack/module.go
index 60370f14..c37e3a8a 100644
--- a/configstack/module.go
+++ b/configstack/module.go
@@ -529,16 +529,16 @@ func (modules TerraformModules) flagExcludedUnits(opts *options.TerragruntOption
 
 // flagUnitsThatRead iterates over a module slice and flags all modules that read at least one file in the specified
 // file list in the TerragruntOptions UnitsReading attribute.
-func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOptions) (TerraformModules, error) {
+func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOptions) TerraformModules {
 	// If no UnitsThatRead is specified return the modules list instantly
 	if len(opts.UnitsReading) == 0 {
-		return modules, nil
+		return modules
 	}
 
-	for _, readPath := range opts.UnitsReading {
-		path, err := util.CanonicalPath(readPath, opts.WorkingDir)
-		if err != nil {
-			return nil, err
+	for _, path := range opts.UnitsReading {
+		if !filepath.IsAbs(path) {
+			path = filepath.Join(opts.WorkingDir, path)
+			path = filepath.Clean(path)
 		}
 
 		for _, module := range modules {
@@ -548,7 +548,7 @@ func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOption
 		}
 	}
 
-	return modules, nil
+	return modules
 }
 
 // flagExcludedDirs iterates over a module slice and flags all entries as excluded listed in the terragrunt-exclude-dir CLI flag.
diff --git a/configstack/stack.go b/configstack/stack.go
index 8175d1b2..1e888629 100644
--- a/configstack/stack.go
+++ b/configstack/stack.go
@@ -451,12 +451,7 @@ func (stack *Stack) ResolveTerraformModules(ctx context.Context, terragruntConfi
 	err = telemetry.Telemetry(ctx, stack.terragruntOptions, "flag_units_that_read", map[string]interface{}{
 		"working_dir": stack.terragruntOptions.WorkingDir,
 	}, func(childCtx context.Context) error {
-		result, err := withExcludedUnits.flagUnitsThatRead(stack.terragruntOptions)
-		if err != nil {
-			return err
-		}
-
-		withUnitsRead = result
+		withUnitsRead = withExcludedUnits.flagUnitsThatRead(stack.terragruntOptions)
 
 		return nil
 	})
diff --git a/docs/_docs/04_reference/04-config-blocks-and-attributes.md b/docs/_docs/04_reference/04-config-blocks-and-attributes.md
index 13985801..3ea8aad0 100644
--- a/docs/_docs/04_reference/04-config-blocks-and-attributes.md
+++ b/docs/_docs/04_reference/04-config-blocks-and-attributes.md
@@ -1653,7 +1653,7 @@ The `unit` block supports the following arguments:
 
 - `name` (label): A unique identifier for the unit. This is used to reference the unit elsewhere in your configuration.
 - `source` (attribute): Specifies where to find the Terragrunt configuration files for this unit. This follows the same syntax as the `source` parameter in the `terraform` block.
-- `path` (attribute): The relative path where this unit should be deployed within the stack directory (`.terragrunt-stack`).
+- `path` (attribute): The relative path where this unit should be deployed within the stack directory (`.terragrunt-stack`). If an absolute path is provided here, Terragrunt will generate the stack in that location, instead of generating it in a path relative to the `.terragrunt-stack` directory.
 - `values` (attribute, optional): A map of values that will be passed to the unit as inputs.
 
 Example:
@@ -1703,6 +1703,9 @@ inputs = {
 }
 ```
 
+**Note:**  
+The `source` value can be updated dynamically using the `--source-map` flag, just like `terraform.source`.
+
 ### stack
 
 > **Note:**
@@ -1748,6 +1751,9 @@ In this example, the `services` stack is defined with path `services`, which wil
 The stack is also provided with custom values for `project` and `cidr`, which can be used within the stack's configuration files.
 Terragrunt will recursively generate a stack using the contents of the `.terragrunt-stack/services/terragrunt.stack.hcl` file until the entire stack is fully generated.
 
+**Note:**  
+The `source` value can be updated dynamically using the `--source-map` flag, just like `terraform.source`.
+
 ## Attributes
 
 - [Blocks](#blocks)
diff --git a/options/options.go b/options/options.go
index 048a4c79..c35d4e06 100644
--- a/options/options.go
+++ b/options/options.go
@@ -6,10 +6,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"maps"
 	"math"
 	"os"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"strings"
 	"time"
 
@@ -704,10 +706,8 @@ func (opts *TerragruntOptions) AppendReadFile(file, unit string) {
 		return
 	}
 
-	for _, u := range units {
-		if u == unit {
-			return
-		}
+	if slices.Contains(units, unit) {
+		return
 	}
 
 	opts.Logger.Debugf("Tracking that file %s was read by %s.", file, unit)
@@ -739,13 +739,7 @@ func (opts *TerragruntOptions) DidReadFile(file, unit string) bool {
 		return false
 	}
 
-	for _, u := range units {
-		if u == unit {
-			return true
-		}
-	}
-
-	return false
+	return slices.Contains(units, unit)
 }
 
 // CloneReadFiles creates a copy of the ReadFiles map.
@@ -777,7 +771,7 @@ type EngineOptions struct {
 	Source  string
 	Version string
 	Type    string
-	Meta    map[string]interface{}
+	Meta    map[string]any
 }
 
 // ErrorsConfig extracted errors handling configuration.
@@ -799,7 +793,7 @@ type IgnoreConfig struct {
 	Name            string
 	IgnorableErrors []*ErrorsPattern
 	Message         string
-	Signals         map[string]interface{}
+	Signals         map[string]any
 }
 
 type ErrorsPattern struct {
@@ -870,7 +864,7 @@ func (opts *TerragruntOptions) RunWithErrorHandling(ctx context.Context, operati
 	}
 }
 
-func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]interface{}) error {
+func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]any) error {
 	workingDir := opts.WorkingDir
 	signalsFile := filepath.Join(workingDir, DefaultSignalsFile)
 	signalsJSON, err := json.MarshalIndent(signals, "", "  ")
@@ -880,12 +874,13 @@ func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]interface{
 	}
 
 	const ownerPerms = 0644
+
+	opts.Logger.Warnf("Writing error signals to %s", signalsFile)
+
 	if err := os.WriteFile(signalsFile, signalsJSON, ownerPerms); err != nil {
 		return fmt.Errorf("failed to write signals file %s: %w", signalsFile, err)
 	}
 
-	opts.Logger.Warnf("Written error signals to %s", signalsFile)
-
 	return nil
 }
 
@@ -894,7 +889,7 @@ type ErrorAction struct {
 	ShouldIgnore   bool
 	ShouldRetry    bool
 	IgnoreMessage  string
-	IgnoreSignals  map[string]interface{}
+	IgnoreSignals  map[string]any
 	RetryMessage   string
 	RetryAttempts  int
 	RetrySleepSecs int
@@ -917,12 +912,10 @@ func (c *ErrorsConfig) ProcessError(opts *TerragruntOptions, err error, currentA
 		if isIgnorable {
 			action.ShouldIgnore = true
 			action.IgnoreMessage = ignoreBlock.Message
-			action.IgnoreSignals = make(map[string]interface{})
+			action.IgnoreSignals = make(map[string]any)
 
 			// Convert cty.Value map to regular map
-			for k, v := range ignoreBlock.Signals {
-				action.IgnoreSignals[k] = v
-			}
+			maps.Copy(action.IgnoreSignals, ignoreBlock.Signals)
 
 			return action, nil
 		}



Test patch:
diff --git a/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl b/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl
new file mode 100644
index 00000000..8a2a95c2
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl
@@ -0,0 +1,24 @@
+
+unit "app1" {
+	source = "units/app"
+	path   = "${get_repo_root()}/app1"
+	values = {
+		input = "app1"
+	}
+}
+
+unit "app2" {
+	source = "units/app"
+	path   = "${get_repo_root()}/app2"
+	values = {
+		input = "app2"
+	}
+}
+
+unit "app3" {
+	source = "units/app"
+	path   = "app3"
+	values = {
+		input = "app3"
+	}
+}
diff --git a/test/fixtures/stacks/absolute-path/units/app/main.tf b/test/fixtures/stacks/absolute-path/units/app/main.tf
new file mode 100644
index 00000000..2cad5cf2
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/units/app/main.tf
@@ -0,0 +1,13 @@
+variable "input" {
+  description = "Project input"
+  type        = string
+}
+
+resource "local_file" "file" {
+  content  = var.input
+  filename = "${path.module}/data.txt"
+}
+
+output "result" {
+  value = var.input
+}
diff --git a/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl b/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl
new file mode 100644
index 00000000..a86ab87b
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl
@@ -0,0 +1,4 @@
+
+inputs = {
+  input = values.input
+}
\ No newline at end of file
diff --git a/test/fixtures/stacks/source-map/terragrunt.stack.hcl b/test/fixtures/stacks/source-map/terragrunt.stack.hcl
new file mode 100644
index 00000000..0a993ded
--- /dev/null
+++ b/test/fixtures/stacks/source-map/terragrunt.stack.hcl
@@ -0,0 +1,16 @@
+
+unit "app1" {
+	source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/units/app"
+	path   = "app1"
+	values = {
+		input = "app1"
+	}
+}
+
+unit "app2" {
+	source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/units/app"
+	path   = "app2"
+	values = {
+		input = "app2"
+	}
+}
diff --git a/test/fixtures/stacks/source-map/tf/modules/main.tf b/test/fixtures/stacks/source-map/tf/modules/main.tf
new file mode 100644
index 00000000..92435bc8
--- /dev/null
+++ b/test/fixtures/stacks/source-map/tf/modules/main.tf
@@ -0,0 +1,15 @@
+
+
+variable "input" {
+  description = "Project input"
+  type        = string
+}
+
+resource "local_file" "file" {
+  content  = var.input
+  filename = "${path.module}/data.txt"
+}
+
+output "result" {
+  value = var.input
+}
diff --git a/test/fixtures/stacks/source-map/units/app/terragrunt.hcl b/test/fixtures/stacks/source-map/units/app/terragrunt.hcl
new file mode 100644
index 00000000..933bec48
--- /dev/null
+++ b/test/fixtures/stacks/source-map/units/app/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/tf/modules"
+}
+
+inputs = {
+  input = values.input
+}
\ No newline at end of file
diff --git a/test/fixtures/units-reading/indirect/main.tf b/test/fixtures/units-reading/indirect/main.tf
new file mode 100644
index 00000000..e69de29b
diff --git a/test/fixtures/units-reading/indirect/src/test.txt b/test/fixtures/units-reading/indirect/src/test.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/test/fixtures/units-reading/indirect/terragrunt.hcl b/test/fixtures/units-reading/indirect/terragrunt.hcl
new file mode 100644
index 00000000..a0581f88
--- /dev/null
+++ b/test/fixtures/units-reading/indirect/terragrunt.hcl
@@ -0,0 +1,4 @@
+locals {
+  all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
+  all_files_marked = [for f in local.all_files : mark_as_read("src/${f}")]
+}
diff --git a/test/integration_errors_test.go b/test/integration_errors_test.go
index db473472..6914d0a4 100644
--- a/test/integration_errors_test.go
+++ b/test/integration_errors_test.go
@@ -100,7 +100,7 @@ func TestIgnoreSignal(t *testing.T) {
 	tmpEnvPath := helpers.CopyEnvironment(t, testIgnoreSignalErrors)
 	rootPath := util.JoinPath(tmpEnvPath, testIgnoreSignalErrors)
 
-	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt apply -auto-approve --non-interactive --working-dir "+rootPath)
 
 	require.NoError(t, err)
 	assert.Contains(t, stderr, "Ignoring error example1")
diff --git a/test/integration_stacks_test.go b/test/integration_stacks_test.go
index c0737a33..f2f87e0e 100644
--- a/test/integration_stacks_test.go
+++ b/test/integration_stacks_test.go
@@ -6,6 +6,8 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/gruntwork-io/terratest/modules/files"
+
 	"github.com/gruntwork-io/terragrunt/config/hclparse"
 
 	"github.com/stretchr/testify/assert"
@@ -29,6 +31,8 @@ const (
 	testFixtureNestedStacks        = "fixtures/stacks/nested"
 	testFixtureStackValues         = "fixtures/stacks/stack-values"
 	testFixtureStackDependencies   = "fixtures/stacks/dependencies"
+	testFixtureStackAbsolutePath   = "fixtures/stacks/absolute-path"
+	testFixtureStackSourceMap      = "fixtures/stacks/source-map"
 )
 
 func TestStacksGenerateBasic(t *testing.T) {
@@ -171,9 +175,11 @@ func TestStacksApplyRemote(t *testing.T) {
 	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksRemote)
 	rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksRemote)
 
-	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
 	require.NoError(t, err)
 
+	assert.Contains(t, stderr, "app1 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+	assert.Contains(t, stderr, "app2 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
 	assert.Contains(t, stdout, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed")
 	assert.Contains(t, stdout, "local_file.file: Creation complete")
 	path := util.JoinPath(rootPath, ".terragrunt-stack")
@@ -713,6 +719,96 @@ func TestStackApplyStrictInclude(t *testing.T) {
 	assert.True(t, util.FileNotExists(dataPath))
 }
 
+func TestStacksSourceMap(t *testing.T) {
+	t.Parallel()
+
+	// prepare local path to do override of source url
+	helpers.CleanupTerraformFolder(t, testFixtureStacksBasic)
+	localTmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksBasic)
+	localTmpTest := filepath.Join(localTmpEnvPath, "test", "fixtures")
+	if err := os.MkdirAll(localTmpTest, 0755); err != nil {
+		assert.NoError(t, err)
+	}
+
+	if err := files.CopyFolderContentsWithFilter(filepath.Join(localTmpEnvPath, "fixtures"), localTmpTest, func(path string) bool {
+		return true
+	}); err != nil {
+		assert.NoError(t, err)
+	}
+
+	// prepare local environment with remote to use source map to replace
+	helpers.CleanupTerraformFolder(t, testFixtureStacksRemote)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksRemote)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksRemote)
+
+	// generate path with replacement of local source with local path
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --experiment stacks --source-map git::https://github.com/gruntwork-io/terragrunt.git="+localTmpEnvPath+" --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	assert.Contains(t, stderr, "Processing unit app1")
+	assert.Contains(t, stderr, "Processing unit app2")
+
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --source-map git::https://github.com/gruntwork-io/terragrunt.git="+localTmpEnvPath+" --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	// validate that the source map was used to replace the source
+	assert.NotContains(t, stderr, "app1 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+	assert.NotContains(t, stderr, "app2 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+
+	assert.Contains(t, stderr, "Processing unit app1")
+	assert.Contains(t, stderr, "Processing unit app2")
+}
+
+func TestStacksSourceMapModule(t *testing.T) {
+	t.Parallel()
+	// prepare local environment with remote to use source map to replace
+	helpers.CleanupTerraformFolder(t, testFixtureStackSourceMap)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackSourceMap)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStackSourceMap)
+
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --experiment stacks --source-map git::https://git-host.com/not-existing-repo.git="+tmpEnvPath+" --terragrunt-log-level debug --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+	assert.NotContains(t, stderr, "git-host.com/not-existing-repo.git")
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --source-map git::https://git-host.com/not-existing-repo.git="+tmpEnvPath+"  --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	assert.NotContains(t, stderr, "git-host.com/not-existing-repo.git")
+	assert.Contains(t, stderr, "Module ./.terragrunt-stack/app1")
+	assert.Contains(t, stderr, "Module ./.terragrunt-stack/app2")
+}
+
+func TestStacksGenerateAbsolutePath(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureStackAbsolutePath)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackAbsolutePath)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStackAbsolutePath)
+	helpers.CreateGitRepo(t, rootPath)
+	helpers.RunTerragrunt(t, "terragrunt stack generate --terragrunt-log-level debug  --experiment stacks --terragrunt-working-dir "+rootPath)
+
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	// check that apps directories are generated
+	app3 := util.JoinPath(rootPath, ".terragrunt-stack", "app3")
+	assert.DirExists(t, app3)
+
+	app1 := util.JoinPath(rootPath, "app1")
+	assert.DirExists(t, app1)
+
+	app2 := util.JoinPath(rootPath, "app2")
+	assert.DirExists(t, app2)
+
+	app1 = util.JoinPath(rootPath, ".terragrunt-stack", "app1")
+	assert.NoDirExists(t, app1)
+}
+
 // check if the stack directory is created and contains files.
 func validateStackDir(t *testing.T, path string) {
 	t.Helper()
diff --git a/test/integration_units_reading_test.go b/test/integration_units_reading_test.go
index 8170f23e..68d91e46 100644
--- a/test/integration_units_reading_test.go
+++ b/test/integration_units_reading_test.go
@@ -1,6 +1,7 @@
 package test_test
 
 import (
+	"path/filepath"
 	"regexp"
 	"strings"
 	"testing"
@@ -31,6 +32,7 @@ func TestUnitsReading(t *testing.T) {
 			name:         "empty",
 			unitsReading: []string{},
 			expectedUnits: []string{
+				"indirect",
 				"reading-from-tf",
 				"reading-hcl",
 				"reading-hcl-and-tfvars",
@@ -121,6 +123,15 @@ func TestUnitsReading(t *testing.T) {
 				"reading-tfvars",
 			},
 		},
+		{
+			name: "indirect",
+			unitsReading: []string{
+				filepath.Join("indirect", "src", "test.txt"),
+			},
+			expectedUnits: []string{
+				"indirect",
+			},
+		},
 	}
 
 	includedLogEntryRegex := regexp.MustCompile(`=> Module ./([^ ]+) \(excluded: false`)
@@ -132,18 +143,18 @@ func TestUnitsReading(t *testing.T) {
 			tmpEnvPath := helpers.CopyEnvironment(t, testFixtureUnitsReading)
 			rootPath := util.JoinPath(tmpEnvPath, testFixtureUnitsReading)
 
-			cmd := "terragrunt run-all plan --terragrunt-non-interactive --terragrunt-log-level trace --terragrunt-working-dir " + rootPath
+			cmd := "terragrunt run-all plan --non-interactive --log-level trace --working-dir " + rootPath
 
-			for _, unit := range tt.unitsReading {
-				cmd = cmd + " --terragrunt-queue-include-units-reading " + unit
+			for _, f := range tt.unitsReading {
+				cmd = cmd + " --queue-include-units-reading " + f
 			}
 
 			for _, unit := range tt.unitsIncluding {
-				cmd = cmd + " --terragrunt-include-dir " + unit
+				cmd = cmd + " --queue-include-dir " + unit
 			}
 
 			for _, unit := range tt.unitsExcluding {
-				cmd = cmd + " --terragrunt-exclude-dir " + unit
+				cmd = cmd + " --queue-exclude-dir " + unit
 			}
 
 			_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, cmd)