diff --git a/cmd/cmd_utils.go b/cmd/cmd_utils.go
index 349732c007..6af53061dc 100644
--- a/cmd/cmd_utils.go
+++ b/cmd/cmd_utils.go
@@ -473,6 +473,15 @@ func executeCustomCommand(
log.Debug("Authenticated with identity for custom command", "identity", commandIdentity, "command", commandConfig.Name)
}
+ // Determine working directory for command execution.
+ workDir, err := resolveWorkingDirectory(commandConfig.WorkingDirectory, atmosConfig.BasePath, currentDirPath)
+ if err != nil {
+ errUtils.CheckErrorPrintAndExit(err, "Invalid working_directory", "https://atmos.tools/cli/configuration/commands#working-directory")
+ }
+ if commandConfig.WorkingDirectory != "" {
+ log.Debug("Using working directory for custom command", "command", commandConfig.Name, "working_directory", workDir)
+ }
+
// Execute custom command's steps
for i, step := range commandConfig.Steps {
// Prepare template data for arguments
@@ -554,7 +563,7 @@ func executeCustomCommand(
// If the command to get the value for the ENV var is provided, execute it
if valCommand != "" {
valCommandName := fmt.Sprintf("env-var-%s-valcommand", key)
- res, err := u.ExecuteShellAndReturnOutput(valCommand, valCommandName, currentDirPath, env, false)
+ res, err := u.ExecuteShellAndReturnOutput(valCommand, valCommandName, workDir, env, false)
errUtils.CheckErrorPrintAndExit(err, "", "")
value = strings.TrimRight(res, "\r\n")
} else {
@@ -595,7 +604,7 @@ func executeCustomCommand(
commandName := fmt.Sprintf("%s-step-%d", commandConfig.Name, i)
// Pass the prepared environment with custom variables to the subprocess
- err = e.ExecuteShell(commandToRun, commandName, currentDirPath, env, false)
+ err = e.ExecuteShell(commandToRun, commandName, workDir, env, false)
errUtils.CheckErrorPrintAndExit(err, "", "")
}
}
diff --git a/cmd/working_directory.go b/cmd/working_directory.go
new file mode 100644
index 0000000000..15bf94f516
--- /dev/null
+++ b/cmd/working_directory.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+)
+
+// resolveWorkingDirectory resolves and validates a working directory path.
+// If workDir is empty, returns defaultDir. If workDir is relative, resolves against basePath.
+// Returns error if the directory doesn't exist or isn't a directory.
+func resolveWorkingDirectory(workDir, basePath, defaultDir string) (string, error) {
+ if workDir == "" {
+ return defaultDir, nil
+ }
+
+ // Clean and resolve paths. filepath.Clean normalizes paths like /tmp/foo/.. to /tmp.
+ // For relative paths, filepath.Join already cleans the result.
+ resolvedDir := filepath.Clean(workDir)
+ if !filepath.IsAbs(workDir) {
+ resolvedDir = filepath.Join(basePath, workDir)
+ }
+
+ // Validate directory exists and is a directory.
+ info, err := os.Stat(resolvedDir)
+ if os.IsNotExist(err) {
+ return "", errUtils.Build(errUtils.ErrWorkingDirNotFound).
+ WithCause(err).
+ WithContext("path", resolvedDir).
+ WithHint("Check that the working_directory path exists").
+ Err()
+ }
+ if err != nil {
+ return "", errUtils.Build(errUtils.ErrWorkingDirAccessFailed).
+ WithCause(err).
+ WithContext("path", resolvedDir).
+ Err()
+ }
+ if !info.IsDir() {
+ return "", errUtils.Build(errUtils.ErrWorkingDirNotDirectory).
+ WithContext("path", resolvedDir).
+ WithHint("The working_directory must be a directory, not a file").
+ Err()
+ }
+
+ return resolvedDir, nil
+}
diff --git a/cmd/working_directory_test.go b/cmd/working_directory_test.go
new file mode 100644
index 0000000000..aa357d7885
--- /dev/null
+++ b/cmd/working_directory_test.go
@@ -0,0 +1,279 @@
+package cmd
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+)
+
+// TestResolveWorkingDirectory tests the resolveWorkingDirectory function.
+func TestResolveWorkingDirectory(t *testing.T) {
+ tests := []struct {
+ name string
+ workDir string
+ basePath string
+ defaultDir string
+ setup func(t *testing.T) (workDir, basePath, defaultDir string)
+ expected func(t *testing.T, workDir, basePath, defaultDir string) string
+ expectedErr error
+ }{
+ {
+ name: "empty working directory returns default",
+ setup: func(t *testing.T) (string, string, string) {
+ defaultDir := t.TempDir()
+ return "", "/some/base", defaultDir
+ },
+ expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
+ return defaultDir
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "absolute path to valid directory",
+ setup: func(t *testing.T) (string, string, string) {
+ absDir := t.TempDir()
+ return absDir, "/some/base", "/default"
+ },
+ expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
+ return workDir
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "relative path resolved against basePath",
+ setup: func(t *testing.T) (string, string, string) {
+ baseDir := t.TempDir()
+ relDir := "subdir"
+ // Create the subdirectory.
+ fullPath := filepath.Join(baseDir, relDir)
+ err := os.MkdirAll(fullPath, 0o755)
+ require.NoError(t, err)
+ return relDir, baseDir, "/default"
+ },
+ expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
+ return filepath.Join(basePath, workDir)
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "nested relative path resolved against basePath",
+ setup: func(t *testing.T) (string, string, string) {
+ baseDir := t.TempDir()
+ relDir := filepath.Join("nested", "subdir")
+ // Create the nested subdirectory.
+ fullPath := filepath.Join(baseDir, relDir)
+ err := os.MkdirAll(fullPath, 0o755)
+ require.NoError(t, err)
+ return relDir, baseDir, "/default"
+ },
+ expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
+ return filepath.Join(basePath, workDir)
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "directory does not exist",
+ setup: func(t *testing.T) (string, string, string) {
+ return "/nonexistent/path/that/does/not/exist", "/base", "/default"
+ },
+ expected: nil,
+ expectedErr: errUtils.ErrWorkingDirNotFound,
+ },
+ {
+ name: "relative path to nonexistent directory",
+ setup: func(t *testing.T) (string, string, string) {
+ baseDir := t.TempDir()
+ return "nonexistent_subdir", baseDir, "/default"
+ },
+ expected: nil,
+ expectedErr: errUtils.ErrWorkingDirNotFound,
+ },
+ {
+ name: "path is a file not a directory",
+ setup: func(t *testing.T) (string, string, string) {
+ tmpDir := t.TempDir()
+ filePath := filepath.Join(tmpDir, "testfile.txt")
+ err := os.WriteFile(filePath, []byte("test content"), 0o644)
+ require.NoError(t, err)
+ return filePath, "/base", "/default"
+ },
+ expected: nil,
+ expectedErr: errUtils.ErrWorkingDirNotDirectory,
+ },
+ {
+ name: "relative path to file not directory",
+ setup: func(t *testing.T) (string, string, string) {
+ baseDir := t.TempDir()
+ fileName := "testfile.txt"
+ filePath := filepath.Join(baseDir, fileName)
+ err := os.WriteFile(filePath, []byte("test content"), 0o644)
+ require.NoError(t, err)
+ return fileName, baseDir, "/default"
+ },
+ expected: nil,
+ expectedErr: errUtils.ErrWorkingDirNotDirectory,
+ },
+ {
+ name: "empty basePath with relative workDir",
+ setup: func(t *testing.T) (string, string, string) {
+ // When basePath is empty, relative paths are resolved against current directory.
+ // Create a temp dir and change to it.
+ tmpDir := t.TempDir()
+ subDir := "subdir"
+ fullPath := filepath.Join(tmpDir, subDir)
+ err := os.MkdirAll(fullPath, 0o755)
+ require.NoError(t, err)
+ // Change to tmpDir so relative path works.
+ t.Chdir(tmpDir)
+ return subDir, "", "/default"
+ },
+ expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
+ // With empty basePath, filepath.Join("", "subdir") = "subdir".
+ return workDir
+ },
+ expectedErr: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ workDir, basePath, defaultDir := tt.setup(t)
+
+ result, err := resolveWorkingDirectory(workDir, basePath, defaultDir)
+
+ if tt.expectedErr != nil {
+ require.Error(t, err)
+ assert.ErrorIs(t, err, tt.expectedErr)
+ assert.Empty(t, result)
+ } else {
+ require.NoError(t, err)
+ expectedPath := tt.expected(t, workDir, basePath, defaultDir)
+ assert.Equal(t, expectedPath, result)
+ }
+ })
+ }
+}
+
+// TestResolveWorkingDirectory_EdgeCases tests edge cases for resolveWorkingDirectory.
+func TestResolveWorkingDirectory_EdgeCases(t *testing.T) {
+ t.Run("dot path resolves to basePath", func(t *testing.T) {
+ baseDir := t.TempDir()
+
+ result, err := resolveWorkingDirectory(".", baseDir, "/default")
+
+ require.NoError(t, err)
+ assert.Equal(t, baseDir, result)
+ })
+
+ t.Run("double dot path resolves to parent of basePath", func(t *testing.T) {
+ // Create nested structure.
+ tmpDir := t.TempDir()
+ childDir := filepath.Join(tmpDir, "child")
+ err := os.MkdirAll(childDir, 0o755)
+ require.NoError(t, err)
+
+ result, err := resolveWorkingDirectory("..", childDir, "/default")
+
+ require.NoError(t, err)
+ assert.Equal(t, tmpDir, result)
+ })
+
+ t.Run("whitespace in path is preserved", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ dirWithSpaces := filepath.Join(tmpDir, "dir with spaces")
+ err := os.MkdirAll(dirWithSpaces, 0o755)
+ require.NoError(t, err)
+
+ result, err := resolveWorkingDirectory(dirWithSpaces, "/base", "/default")
+
+ require.NoError(t, err)
+ assert.Equal(t, dirWithSpaces, result)
+ })
+
+ t.Run("symlink to directory is valid", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ realDir := filepath.Join(tmpDir, "realdir")
+ err := os.MkdirAll(realDir, 0o755)
+ require.NoError(t, err)
+
+ symlinkPath := filepath.Join(tmpDir, "symlink")
+ err = os.Symlink(realDir, symlinkPath)
+ if err != nil {
+ t.Skipf("Skipping symlink test: %v", err)
+ }
+
+ result, err := resolveWorkingDirectory(symlinkPath, "/base", "/default")
+
+ require.NoError(t, err)
+ assert.Equal(t, symlinkPath, result)
+ })
+
+ t.Run("symlink to file is invalid", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ realFile := filepath.Join(tmpDir, "realfile.txt")
+ err := os.WriteFile(realFile, []byte("content"), 0o644)
+ require.NoError(t, err)
+
+ symlinkPath := filepath.Join(tmpDir, "symlink")
+ err = os.Symlink(realFile, symlinkPath)
+ if err != nil {
+ t.Skipf("Skipping symlink test: %v", err)
+ }
+
+ _, err = resolveWorkingDirectory(symlinkPath, "/base", "/default")
+
+ require.Error(t, err)
+ assert.ErrorIs(t, err, errUtils.ErrWorkingDirNotDirectory)
+ })
+}
+
+// TestResolveWorkingDirectory_AccessFailed tests the ErrWorkingDirAccessFailed error path.
+// This test creates a directory with no permissions on Unix-like systems.
+func TestResolveWorkingDirectory_AccessFailed(t *testing.T) {
+ // Skip on Windows where permission model differs significantly.
+ if os.Getenv("GOOS") == "windows" || filepath.Separator == '\\' {
+ t.Skip("Skipping permission test on Windows")
+ }
+
+ // Skip if running as root (root can access anything).
+ if os.Geteuid() == 0 {
+ t.Skip("Skipping permission test when running as root")
+ }
+
+ t.Run("directory with no permissions triggers access error", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ noAccessDir := filepath.Join(tmpDir, "noaccess")
+
+ // Create directory with no permissions.
+ err := os.Mkdir(noAccessDir, 0o000)
+ require.NoError(t, err)
+
+ // Ensure cleanup even if test fails - restore permissions first.
+ t.Cleanup(func() {
+ _ = os.Chmod(noAccessDir, 0o755)
+ })
+
+ // Try to access the directory - should fail with permission denied.
+ // Note: os.Stat on the directory itself may succeed (just returns metadata),
+ // but accessing contents would fail. We test a subdirectory inside it
+ // which should fail to stat.
+ inaccessibleSubdir := filepath.Join(noAccessDir, "subdir")
+
+ _, err = resolveWorkingDirectory(inaccessibleSubdir, "/base", "/default")
+
+ require.Error(t, err)
+ // The error could be ErrWorkingDirNotFound (if stat fails with permission denied
+ // disguised as not exist) or ErrWorkingDirAccessFailed depending on OS behavior.
+ // We check that we get one of our sentinel errors.
+ isNotFound := errors.Is(err, errUtils.ErrWorkingDirNotFound)
+ isAccessFailed := errors.Is(err, errUtils.ErrWorkingDirAccessFailed)
+ assert.True(t, isNotFound || isAccessFailed,
+ "Expected ErrWorkingDirNotFound or ErrWorkingDirAccessFailed, got: %v", err)
+ })
+}
diff --git a/errors/errors.go b/errors/errors.go
index 90682f8c65..c49cf10c39 100644
--- a/errors/errors.go
+++ b/errors/errors.go
@@ -437,6 +437,9 @@ var (
ErrWorkflowNoWorkflow = errors.New("no workflow found")
ErrWorkflowFileNotFound = errors.New("workflow file not found")
ErrInvalidWorkflowManifest = errors.New("invalid workflow manifest")
+ ErrWorkingDirNotFound = errors.New("working directory does not exist")
+ ErrWorkingDirNotDirectory = errors.New("working directory path is not a directory")
+ ErrWorkingDirAccessFailed = errors.New("failed to access working directory")
ErrAuthProviderNotAvailable = errors.New("auth provider is not available")
ErrInvalidComponentArgument = errors.New("invalid arguments. The command requires one argument 'componentName'")
ErrValidation = errors.New("validation failed")
diff --git a/pkg/config/config_import_test.go b/pkg/config/config_import_test.go
index 9cee94df86..0235fd7064 100644
--- a/pkg/config/config_import_test.go
+++ b/pkg/config/config_import_test.go
@@ -14,6 +14,9 @@ func TestMergeConfig_ImportOverrideBehavior(t *testing.T) {
// Test that the main config file's settings override imported settings.
tempDir := t.TempDir()
+ // Isolate from real git root's .atmos.d to prevent interference.
+ t.Setenv("TEST_GIT_ROOT", tempDir)
+
// Create an import file with a command.
importDir := filepath.Join(tempDir, "imports")
err := os.Mkdir(importDir, 0o755)
diff --git a/pkg/config/load.go b/pkg/config/load.go
index 07fc46622c..6282b245e7 100644
--- a/pkg/config/load.go
+++ b/pkg/config/load.go
@@ -19,6 +19,7 @@ import (
"github.com/cloudposse/atmos/pkg/filesystem"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
+ u "github.com/cloudposse/atmos/pkg/utils"
"github.com/cloudposse/atmos/pkg/version"
"github.com/cloudposse/atmos/pkg/xdg"
)
@@ -211,13 +212,25 @@ func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosCo
}
// If no config file is used, fall back to the default CLI config.
if v.ConfigFileUsed() == "" {
- log.Debug("'atmos.yaml' CLI config was not found", "paths", "system dir, home dir, current dir, ENV vars")
+ log.Debug("'atmos.yaml' CLI config was not found", "paths", "system dir, home dir, current dir, parent dirs, ENV vars")
log.Debug("Refer to https://atmos.tools/cli/configuration for details on how to configure 'atmos.yaml'")
log.Debug("Using the default CLI config")
if err := mergeDefaultConfig(v); err != nil {
return atmosConfig, err
}
+
+ // Also search git root for .atmos.d even with default config.
+ // This enables custom commands defined in .atmos.d at the repo root
+ // to work when running from any subdirectory.
+ gitRoot, err := u.ProcessTagGitRoot("!repo-root .")
+ if err == nil && gitRoot != "" && gitRoot != "." {
+ log.Debug("Loading .atmos.d from git root", "path", gitRoot)
+ if err := mergeDefaultImports(gitRoot, v); err != nil {
+ log.Trace("Failed to load .atmos.d from git root", "path", gitRoot, "error", err)
+ // Non-fatal: continue with default config.
+ }
+ }
}
if v.ConfigFileUsed() != "" {
// get dir of atmosConfigFilePath
@@ -412,6 +425,7 @@ func readSystemConfig(v *viper.Viper) error {
}
if len(configFilePath) > 0 {
+ log.Trace("Checking for atmos.yaml in system config", "path", configFilePath)
err := mergeConfig(v, configFilePath, CliConfigFileName, false)
switch err.(type) {
case viper.ConfigFileNotFoundError:
@@ -435,6 +449,7 @@ func readHomeConfigWithProvider(v *viper.Viper, homeProvider filesystem.HomeDirP
return err
}
configFilePath := filepath.Join(home, ".atmos")
+ log.Trace("Checking for atmos.yaml in home directory", "path", configFilePath)
err = mergeConfig(v, configFilePath, CliConfigFileName, true)
if err != nil {
switch err.(type) {
@@ -461,6 +476,7 @@ func readWorkDirConfig(v *viper.Viper) error {
}
// First try the current directory.
+ log.Trace("Checking for atmos.yaml in working directory", "path", wd)
err = mergeConfig(v, wd, CliConfigFileName, true)
if err == nil {
return nil
@@ -521,6 +537,7 @@ func findAtmosConfigInParentDirs(startDir string) string {
}
dir = parent
+ log.Trace("Checking for atmos.yaml in parent directory", "path", dir)
// Check for atmos.yaml or .atmos.yaml in this directory.
for _, configName := range []string{AtmosConfigFileName, DotAtmosConfigFileName} {
@@ -537,6 +554,7 @@ func readEnvAmosConfigPath(v *viper.Viper) error {
if atmosPath == "" {
return nil
}
+ log.Trace("Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH", "path", atmosPath)
err := mergeConfig(v, atmosPath, CliConfigFileName, true)
if err != nil {
switch err.(type) {
@@ -823,6 +841,7 @@ func loadAtmosConfigsFromDirectory(searchPattern string, dst *viper.Viper, sourc
// mergeDefaultImports merges default imports (`atmos.d/`,`.atmos.d/`)
// from a specified directory into the destination configuration.
+// It also searches the git/worktree root for .atmos.d with lower priority.
func mergeDefaultImports(dirPath string, dst *viper.Viper) error {
isDir := false
if stat, err := os.Stat(dirPath); err == nil && stat.IsDir() {
@@ -838,10 +857,57 @@ func mergeDefaultImports(dirPath string, dst *viper.Viper) error {
return nil
}
+ // Search git/worktree root FIRST (lower priority - gets overridden by config dir).
+ // This enables .atmos.d to be discovered at the repo root even when running from subdirectories.
+ loadAtmosDFromGitRoot(dirPath, dst)
+
+ // Search the config directory (higher priority - loaded second, overrides git root).
+ log.Trace("Checking for .atmos.d in config directory", "path", dirPath)
+ loadAtmosDFromDirectory(dirPath, dst)
+
+ return nil
+}
+
+// loadAtmosDFromGitRoot searches for .atmos.d/ at the git repository root
+// and loads its configuration if different from the config directory.
+func loadAtmosDFromGitRoot(dirPath string, dst *viper.Viper) {
+ gitRoot, err := u.ProcessTagGitRoot("!repo-root .")
+ if err != nil || gitRoot == "" || gitRoot == "." {
+ return
+ }
+
+ absGitRoot, absErr := filepath.Abs(gitRoot)
+ absDirPath, dirErr := filepath.Abs(dirPath)
+ if absErr != nil || dirErr != nil {
+ return
+ }
+
+ // Check if git root is the same as config directory.
+ // Use case-insensitive comparison on Windows where paths may differ only in casing.
+ pathsEqual := absGitRoot == absDirPath
+ if runtime.GOOS == "windows" {
+ pathsEqual = strings.EqualFold(absGitRoot, absDirPath)
+ }
+ if pathsEqual {
+ return
+ }
+
+ // Skip if excluded for testing.
+ if shouldExcludePathForTesting(absGitRoot) {
+ return
+ }
+
+ log.Trace("Checking for .atmos.d in git root", "path", absGitRoot)
+ loadAtmosDFromDirectory(absGitRoot, dst)
+}
+
+// loadAtmosDFromDirectory searches for atmos.d/ and .atmos.d/ in the given directory
+// and loads their configurations into the destination viper instance.
+func loadAtmosDFromDirectory(dirPath string, dst *viper.Viper) {
// Search for `atmos.d/` configurations.
searchPattern := filepath.Join(filepath.FromSlash(dirPath), filepath.Join("atmos.d", "**", "*"))
if err := loadAtmosConfigsFromDirectory(searchPattern, dst, "atmos.d"); err != nil {
- log.Trace("Failed to load atmos.d configs", "error", err)
+ log.Trace("Failed to load atmos.d configs", "error", err, "path", dirPath)
// Don't return error - just log and continue.
// This maintains existing behavior where .atmos.d loading is optional.
}
@@ -849,12 +915,10 @@ func mergeDefaultImports(dirPath string, dst *viper.Viper) error {
// Search for `.atmos.d` configurations.
searchPattern = filepath.Join(filepath.FromSlash(dirPath), filepath.Join(".atmos.d", "**", "*"))
if err := loadAtmosConfigsFromDirectory(searchPattern, dst, ".atmos.d"); err != nil {
- log.Trace("Failed to load .atmos.d configs", "error", err)
+ log.Trace("Failed to load .atmos.d configs", "error", err, "path", dirPath)
// Don't return error - just log and continue.
// This maintains existing behavior where .atmos.d loading is optional.
}
-
- return nil
}
// mergeImports processes imports from the atmos configuration and merges them into the destination configuration.
diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go
index f9ec38822a..d9473be536 100644
--- a/pkg/config/load_test.go
+++ b/pkg/config/load_test.go
@@ -1035,3 +1035,155 @@ base_path: /test/parent-should-not-find
})
}
}
+
+// TestLoadConfig_DefaultConfigWithGitRootAtmosD tests that .atmos.d at git root is loaded
+// even when no atmos.yaml config file is found (using default config).
+func TestLoadConfig_DefaultConfigWithGitRootAtmosD(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create .atmos.d at "git root" with custom commands.
+ atmosDDir := filepath.Join(tempDir, ".atmos.d")
+ require.NoError(t, os.MkdirAll(atmosDDir, 0o755))
+
+ commandsContent := `commands:
+ - name: test-default-cmd
+ description: Test command from git root with default config
+ steps:
+ - echo "hello from git root default config"
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(atmosDDir, "commands.yaml"),
+ []byte(commandsContent),
+ 0o644,
+ ))
+
+ // Create a subdirectory with NO atmos.yaml - this will force default config.
+ subDir := filepath.Join(tempDir, "no-config-subdir")
+ require.NoError(t, os.MkdirAll(subDir, 0o755))
+
+ // Mock git root to be tempDir.
+ t.Setenv("TEST_GIT_ROOT", tempDir)
+
+ // Change to subdirectory.
+ t.Chdir(subDir)
+
+ // Load config - should use default config but still find .atmos.d from git root.
+ config, err := LoadConfig(&schema.ConfigAndStacksInfo{})
+ require.NoError(t, err)
+
+ // Verify that the custom command from .atmos.d was loaded.
+ require.NotNil(t, config.Commands, "Commands should be loaded from .atmos.d at git root")
+ require.Len(t, config.Commands, 1, "Should have one custom command")
+ assert.Equal(t, "test-default-cmd", config.Commands[0].Name)
+}
+
+// TestMergeDefaultImports_GitRoot tests that .atmos.d at git root is discovered
+// even when running from a subdirectory.
+func TestMergeDefaultImports_GitRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ setupDirs func(t *testing.T, tempDir string) string
+ expectedKey string
+ expectedValue string
+ description string
+ }{
+ {
+ name: "loads_atmos_d_from_git_root_when_in_subdirectory",
+ setupDirs: func(t *testing.T, tempDir string) string {
+ // Create .atmos.d at "git root" (tempDir).
+ atmosDDir := filepath.Join(tempDir, ".atmos.d")
+ require.NoError(t, os.MkdirAll(atmosDDir, 0o755))
+
+ commandsContent := `commands:
+ - name: test-cmd
+ description: Test command from git root
+ steps:
+ - echo "hello from git root"
+`
+ require.NoError(t, os.WriteFile(
+ filepath.Join(atmosDDir, "commands.yaml"),
+ []byte(commandsContent),
+ 0o644,
+ ))
+
+ // Create a subdirectory to run from.
+ subDir := filepath.Join(tempDir, "test")
+ require.NoError(t, os.MkdirAll(subDir, 0o755))
+
+ return subDir
+ },
+ expectedKey: "commands",
+ expectedValue: "", // Just check that commands key exists
+ description: "Should load .atmos.d from git root when running from subdirectory",
+ },
+ {
+ name: "git_root_atmos_d_has_lower_priority_than_config_dir",
+ setupDirs: func(t *testing.T, tempDir string) string {
+ // Create .atmos.d at "git root" (tempDir) with lower priority setting.
+ gitRootAtmosDDir := filepath.Join(tempDir, ".atmos.d")
+ require.NoError(t, os.MkdirAll(gitRootAtmosDDir, 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(gitRootAtmosDDir, "settings.yaml"),
+ []byte(`settings:
+ test_priority: from_git_root
+`),
+ 0o644,
+ ))
+
+ // Create config directory with atmos.yaml.
+ configDir := filepath.Join(tempDir, "config")
+ require.NoError(t, os.MkdirAll(configDir, 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(configDir, "atmos.yaml"),
+ []byte(`base_path: ./`),
+ 0o644,
+ ))
+
+ // Create .atmos.d in config dir with higher priority setting.
+ configAtmosDDir := filepath.Join(configDir, ".atmos.d")
+ require.NoError(t, os.MkdirAll(configAtmosDDir, 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(configAtmosDDir, "settings.yaml"),
+ []byte(`settings:
+ test_priority: from_config_dir
+`),
+ 0o644,
+ ))
+
+ return configDir
+ },
+ expectedKey: "settings.test_priority",
+ expectedValue: "from_config_dir",
+ description: "Config dir .atmos.d should override git root .atmos.d",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ workDir := tt.setupDirs(t, tempDir)
+
+ // Mock git root to be tempDir.
+ t.Setenv("TEST_GIT_ROOT", tempDir)
+
+ // Change to working directory.
+ t.Chdir(workDir)
+
+ v := viper.New()
+ v.SetConfigType("yaml")
+
+ // Call mergeDefaultImports with the working directory.
+ err := mergeDefaultImports(workDir, v)
+ require.NoError(t, err, tt.description)
+
+ // Verify the expected key is set.
+ assert.True(t, v.IsSet(tt.expectedKey),
+ "Expected key %q to be set. %s", tt.expectedKey, tt.description)
+
+ if tt.expectedValue != "" {
+ assert.Equal(t, tt.expectedValue, v.GetString(tt.expectedKey),
+ "Expected value mismatch. %s", tt.description)
+ }
+ })
+ }
+}
diff --git a/pkg/schema/command.go b/pkg/schema/command.go
index 5d9eafb98a..b00be4a8fd 100644
--- a/pkg/schema/command.go
+++ b/pkg/schema/command.go
@@ -3,16 +3,17 @@ package schema
// Custom CLI commands
type Command struct {
- Name string `yaml:"name" json:"name" mapstructure:"name"`
- Description string `yaml:"description" json:"description" mapstructure:"description"`
- Env []CommandEnv `yaml:"env" json:"env" mapstructure:"env"`
- Arguments []CommandArgument `yaml:"arguments" json:"arguments" mapstructure:"arguments"`
- Flags []CommandFlag `yaml:"flags" json:"flags" mapstructure:"flags"`
- ComponentConfig CommandComponentConfig `yaml:"component_config" json:"component_config" mapstructure:"component_config"`
- Steps []string `yaml:"steps" json:"steps" mapstructure:"steps"`
- Commands []Command `yaml:"commands" json:"commands" mapstructure:"commands"`
- Verbose bool `yaml:"verbose" json:"verbose" mapstructure:"verbose"`
- Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"`
+ Name string `yaml:"name" json:"name" mapstructure:"name"`
+ Description string `yaml:"description" json:"description" mapstructure:"description"`
+ WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"`
+ Env []CommandEnv `yaml:"env" json:"env" mapstructure:"env"`
+ Arguments []CommandArgument `yaml:"arguments" json:"arguments" mapstructure:"arguments"`
+ Flags []CommandFlag `yaml:"flags" json:"flags" mapstructure:"flags"`
+ ComponentConfig CommandComponentConfig `yaml:"component_config" json:"component_config" mapstructure:"component_config"`
+ Steps []string `yaml:"steps" json:"steps" mapstructure:"steps"`
+ Commands []Command `yaml:"commands" json:"commands" mapstructure:"commands"`
+ Verbose bool `yaml:"verbose" json:"verbose" mapstructure:"verbose"`
+ Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"`
}
type CommandArgument struct {
diff --git a/pkg/schema/workflow.go b/pkg/schema/workflow.go
index b75b6165b1..8416e13aba 100644
--- a/pkg/schema/workflow.go
+++ b/pkg/schema/workflow.go
@@ -5,18 +5,20 @@ type DescribeWorkflowsItem struct {
Workflow string `yaml:"workflow" json:"workflow" mapstructure:"workflow"`
}
type WorkflowStep struct {
- Name string `yaml:"name,omitempty" json:"name,omitempty" mapstructure:"name"`
- Command string `yaml:"command" json:"command" mapstructure:"command"`
- Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"`
- Type string `yaml:"type,omitempty" json:"type,omitempty" mapstructure:"type"`
- Retry *RetryConfig `yaml:"retry,omitempty" json:"retry,omitempty" mapstructure:"retry"`
- Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"`
+ Name string `yaml:"name,omitempty" json:"name,omitempty" mapstructure:"name"`
+ Command string `yaml:"command" json:"command" mapstructure:"command"`
+ Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"`
+ Type string `yaml:"type,omitempty" json:"type,omitempty" mapstructure:"type"`
+ WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"`
+ Retry *RetryConfig `yaml:"retry,omitempty" json:"retry,omitempty" mapstructure:"retry"`
+ Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"`
}
type WorkflowDefinition struct {
- Description string `yaml:"description,omitempty" json:"description,omitempty" mapstructure:"description"`
- Steps []WorkflowStep `yaml:"steps" json:"steps" mapstructure:"steps"`
- Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"`
+ Description string `yaml:"description,omitempty" json:"description,omitempty" mapstructure:"description"`
+ WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"`
+ Steps []WorkflowStep `yaml:"steps" json:"steps" mapstructure:"steps"`
+ Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"`
}
type WorkflowConfig map[string]WorkflowDefinition
diff --git a/pkg/utils/git.go b/pkg/utils/git.go
index 48e4590c13..d631ea4e3b 100644
--- a/pkg/utils/git.go
+++ b/pkg/utils/git.go
@@ -19,10 +19,10 @@ func ProcessTagGitRoot(input string) (string, error) {
str := strings.TrimPrefix(input, AtmosYamlFuncGitRoot)
defaultValue := strings.TrimSpace(str)
- // Check if we're in test mode and should use a mock Git root
+ // Check if we're in test mode and should use a mock Git root.
//nolint:forbidigo // TEST_GIT_ROOT is specifically for test isolation, not application configuration
if testGitRoot := os.Getenv("TEST_GIT_ROOT"); testGitRoot != "" {
- log.Debug("Using test Git root override", "path", testGitRoot)
+ log.Trace("Using test Git root override", "path", testGitRoot)
return testGitRoot, nil
}
diff --git a/pkg/workflow/executor.go b/pkg/workflow/executor.go
index 41b07ae37b..cff20c5b3e 100644
--- a/pkg/workflow/executor.go
+++ b/pkg/workflow/executor.go
@@ -177,13 +177,17 @@ func (e *Executor) executeStep(params *WorkflowParams, step *schema.WorkflowStep
// Calculate final stack.
finalStack := e.calculateFinalStack(params.WorkflowDefinition, step, params.Opts.CommandLineStack)
+ // Calculate working directory with inheritance from workflow-level.
+ workDir := e.calculateWorkingDirectory(params.WorkflowDefinition, step, params.AtmosConfig.BasePath)
+
// Execute the command based on type.
cmdParams := &runCommandParams{
- command: command,
- commandType: commandType,
- stepIdx: stepIdx,
- finalStack: finalStack,
- stepEnv: stepEnv,
+ command: command,
+ commandType: commandType,
+ stepIdx: stepIdx,
+ finalStack: finalStack,
+ stepEnv: stepEnv,
+ workingDirectory: workDir,
}
err = e.runCommand(params, cmdParams)
if err != nil {
@@ -210,21 +214,28 @@ func (e *Executor) prepareStepEnvironment(ctx context.Context, stepIdentity, ste
// runCommandParams holds parameters for command execution.
type runCommandParams struct {
- command string
- commandType string
- stepIdx int
- finalStack string
- stepEnv []string
+ command string
+ commandType string
+ stepIdx int
+ finalStack string
+ stepEnv []string
+ workingDirectory string
}
// runCommand executes the appropriate command type.
func (e *Executor) runCommand(params *WorkflowParams, cmdParams *runCommandParams) error {
+ // Use working directory if set, otherwise default to current directory.
+ workDir := cmdParams.workingDirectory
+ if workDir == "" {
+ workDir = "."
+ }
+
switch cmdParams.commandType {
case "shell":
commandName := fmt.Sprintf("%s-step-%d", params.Workflow, cmdParams.stepIdx)
- return e.runner.RunShell(cmdParams.command, commandName, ".", cmdParams.stepEnv, params.Opts.DryRun)
+ return e.runner.RunShell(cmdParams.command, commandName, workDir, cmdParams.stepEnv, params.Opts.DryRun)
case "atmos":
- return e.executeAtmosCommand(params, cmdParams.command, cmdParams.finalStack, cmdParams.stepEnv)
+ return e.executeAtmosCommand(params, cmdParams.command, cmdParams.finalStack, cmdParams.stepEnv, workDir)
default:
// Return error without printing - handleStepError will print it with resume context.
return errUtils.Build(errUtils.ErrInvalidWorkflowStepType).
@@ -317,7 +328,7 @@ func (e *Executor) prepareAuthenticatedEnvironment(ctx context.Context, identity
}
// executeAtmosCommand executes an atmos command with the given parameters.
-func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalStack string, stepEnv []string) error {
+func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalStack string, stepEnv []string, workDir string) error {
// Parse command using shell.Fields for proper quote handling.
args, parseErr := shell.Fields(command, nil)
if parseErr != nil {
@@ -340,7 +351,7 @@ func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalSta
Ctx: params.Ctx,
AtmosConfig: params.AtmosConfig,
Args: args,
- Dir: ".",
+ Dir: workDir,
Env: stepEnv,
DryRun: params.Opts.DryRun,
}
@@ -367,6 +378,37 @@ func (e *Executor) calculateFinalStack(workflowDef *schema.WorkflowDefinition, s
return finalStack
}
+// calculateWorkingDirectory determines the working directory for a workflow step.
+// Step-level working_directory overrides workflow-level.
+// Relative paths are resolved against base_path.
+func (e *Executor) calculateWorkingDirectory(workflowDef *schema.WorkflowDefinition, step *schema.WorkflowStep, basePath string) string {
+ // Step-level overrides workflow-level.
+ workDir := strings.TrimSpace(workflowDef.WorkingDirectory)
+ if stepWorkDir := strings.TrimSpace(step.WorkingDirectory); stepWorkDir != "" {
+ workDir = stepWorkDir
+ }
+
+ if workDir == "" {
+ return ""
+ }
+
+ // Resolve relative paths against base_path.
+ // Guard against empty basePath to avoid accidentally relative paths.
+ if !filepath.IsAbs(workDir) {
+ resolvedBasePath := basePath
+ if strings.TrimSpace(resolvedBasePath) == "" {
+ resolvedBasePath = "."
+ }
+ workDir = filepath.Join(resolvedBasePath, workDir)
+ }
+
+ // Note: Directory validation happens at execution time in the adapters.
+ // This allows YAML functions like !repo-root to be resolved first during config loading.
+ log.Debug("Using working directory for workflow step", "working_directory", workDir)
+
+ return workDir
+}
+
// buildResumeCommand builds a command to resume the workflow from a specific step.
func (e *Executor) buildResumeCommand(workflow, workflowPath, stepName, finalStack string, atmosConfig *schema.AtmosConfiguration) string {
workflowFileName := strings.TrimPrefix(filepath.ToSlash(workflowPath), filepath.ToSlash(atmosConfig.Workflows.BasePath))
diff --git a/pkg/workflow/executor_test.go b/pkg/workflow/executor_test.go
index 5750a3de7c..cd085b4cbe 100644
--- a/pkg/workflow/executor_test.go
+++ b/pkg/workflow/executor_test.go
@@ -3,6 +3,8 @@ package workflow
import (
"context"
"errors"
+ "path/filepath"
+ "runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -932,3 +934,223 @@ func TestExecutor_Execute_NilAtmosConfig(t *testing.T) {
assert.Nil(t, result)
assert.ErrorIs(t, err, errUtils.ErrNilParam)
}
+
+// TestExecutor_Execute_WorkingDirectory tests working_directory support.
+func TestExecutor_Execute_WorkingDirectory(t *testing.T) {
+ // Use OS-portable paths for cross-platform compatibility.
+ // On Windows, paths like "/base" become "\base" which is NOT absolute.
+ // We need to use proper absolute paths for each platform.
+ var base, tmp, workflowDir, stepDir string
+ if runtime.GOOS == "windows" {
+ base = `C:\base`
+ tmp = `C:\tmp`
+ workflowDir = `C:\workflow-dir`
+ stepDir = `C:\step-dir`
+ } else {
+ base = "/base"
+ tmp = "/tmp"
+ workflowDir = "/workflow-dir"
+ stepDir = "/step-dir"
+ }
+
+ tests := []struct {
+ name string
+ workflowWorkDir string
+ stepWorkDir string
+ basePath string
+ expectedShellWorkDir string
+ expectedAtmosWorkDir string
+ }{
+ {
+ name: "no working directory",
+ workflowWorkDir: "",
+ stepWorkDir: "",
+ basePath: base,
+ expectedShellWorkDir: ".",
+ expectedAtmosWorkDir: ".",
+ },
+ {
+ name: "workflow level absolute path",
+ workflowWorkDir: tmp,
+ stepWorkDir: "",
+ basePath: base,
+ expectedShellWorkDir: tmp,
+ expectedAtmosWorkDir: tmp,
+ },
+ {
+ name: "workflow level relative path",
+ workflowWorkDir: "subdir",
+ stepWorkDir: "",
+ basePath: base,
+ expectedShellWorkDir: filepath.Join(base, "subdir"),
+ expectedAtmosWorkDir: filepath.Join(base, "subdir"),
+ },
+ {
+ name: "step overrides workflow",
+ workflowWorkDir: workflowDir,
+ stepWorkDir: stepDir,
+ basePath: base,
+ expectedShellWorkDir: stepDir,
+ expectedAtmosWorkDir: stepDir,
+ },
+ {
+ name: "step relative overrides workflow",
+ workflowWorkDir: workflowDir,
+ stepWorkDir: "step-subdir",
+ basePath: base,
+ expectedShellWorkDir: filepath.Join(base, "step-subdir"),
+ expectedAtmosWorkDir: filepath.Join(base, "step-subdir"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ // Create mocks.
+ mockRunner := NewMockCommandRunner(ctrl)
+ mockUI := NewMockUIProvider(ctrl)
+
+ // Expect shell command with correct working directory.
+ mockRunner.EXPECT().
+ RunShell("echo shell", "test-workflow-step-0", tt.expectedShellWorkDir, []string{}, false).
+ Return(nil)
+
+ // Expect atmos command with correct working directory.
+ mockRunner.EXPECT().
+ RunAtmos(gomock.Any()).
+ DoAndReturn(func(params *AtmosExecParams) error {
+ assert.Equal(t, tt.expectedAtmosWorkDir, params.Dir, "atmos command working directory mismatch")
+ return nil
+ })
+
+ mockUI.EXPECT().PrintMessage(gomock.Any(), gomock.Any()).AnyTimes()
+
+ // Create executor.
+ executor := NewExecutor(mockRunner, nil, mockUI)
+
+ // Define workflow with working_directory.
+ workflowDef := &schema.WorkflowDefinition{
+ WorkingDirectory: tt.workflowWorkDir,
+ Steps: []schema.WorkflowStep{
+ {
+ Name: "shell-step",
+ Command: "echo shell",
+ Type: "shell",
+ WorkingDirectory: tt.stepWorkDir,
+ },
+ {
+ Name: "atmos-step",
+ Command: "version",
+ Type: "atmos",
+ WorkingDirectory: tt.stepWorkDir,
+ },
+ },
+ }
+
+ params := &WorkflowParams{
+ Ctx: context.Background(),
+ AtmosConfig: &schema.AtmosConfiguration{BasePath: tt.basePath},
+ Workflow: "test-workflow",
+ WorkflowPath: "test.yaml",
+ WorkflowDefinition: workflowDef,
+ Opts: ExecuteOptions{},
+ }
+
+ // Execute.
+ result, err := executor.Execute(params)
+
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+ assert.Len(t, result.Steps, 2)
+ })
+ }
+}
+
+// TestCalculateWorkingDirectory tests the calculateWorkingDirectory function directly.
+func TestCalculateWorkingDirectory(t *testing.T) {
+ // Use OS-portable paths for cross-platform compatibility.
+ // On Windows, paths like "/base" become "\base" which is NOT absolute.
+ // We need to use proper absolute paths for each platform.
+ var base, absolutePath, workflowDirPath, stepDirPath string
+ if runtime.GOOS == "windows" {
+ base = `C:\base`
+ absolutePath = `C:\absolute\path`
+ workflowDirPath = `C:\workflow\dir`
+ stepDirPath = `C:\step\dir`
+ } else {
+ base = "/base"
+ absolutePath = "/absolute/path"
+ workflowDirPath = "/workflow/dir"
+ stepDirPath = "/step/dir"
+ }
+
+ tests := []struct {
+ name string
+ workflowDir string
+ stepDir string
+ basePath string
+ expected string
+ }{
+ {
+ name: "empty returns empty",
+ workflowDir: "",
+ stepDir: "",
+ basePath: base,
+ expected: "",
+ },
+ {
+ name: "workflow absolute path",
+ workflowDir: absolutePath,
+ stepDir: "",
+ basePath: base,
+ expected: absolutePath,
+ },
+ {
+ name: "workflow relative path resolved against base",
+ workflowDir: filepath.FromSlash("relative/path"),
+ stepDir: "",
+ basePath: base,
+ expected: filepath.Join(base, filepath.FromSlash("relative/path")),
+ },
+ {
+ name: "step overrides workflow",
+ workflowDir: workflowDirPath,
+ stepDir: stepDirPath,
+ basePath: base,
+ expected: stepDirPath,
+ },
+ {
+ name: "step relative resolved against base",
+ workflowDir: "",
+ stepDir: filepath.FromSlash("step/relative"),
+ basePath: base,
+ expected: filepath.Join(base, filepath.FromSlash("step/relative")),
+ },
+ {
+ name: "whitespace trimmed from absolute path",
+ workflowDir: " " + absolutePath + " ",
+ stepDir: "",
+ basePath: base,
+ expected: absolutePath,
+ },
+ }
+
+ executor := NewExecutor(nil, nil, nil)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ workflowDef := &schema.WorkflowDefinition{
+ WorkingDirectory: tt.workflowDir,
+ }
+ step := &schema.WorkflowStep{
+ WorkingDirectory: tt.stepDir,
+ }
+
+ result := executor.calculateWorkingDirectory(workflowDef, step, tt.basePath)
+
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/tests/cli_sanitize_test.go b/tests/cli_sanitize_test.go
index a84abfa16d..43bba58829 100644
--- a/tests/cli_sanitize_test.go
+++ b/tests/cli_sanitize_test.go
@@ -67,6 +67,66 @@ func TestSanitizeOutput(t *testing.T) {
input: "token=phc_ABC123def456GHI789jkl012MNO345pqr678",
expected: "token=phc_TEST_TOKEN_PLACEHOLDER",
},
+ {
+ name: "macOS temp directory path should be normalized",
+ input: "TRCE Using test Git root override path=/var/folders/_l/91ns3hs96sd11p4_lzxh1l140000gn/T/TestCLICommands2870082120/001/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "Linux temp directory path should be normalized",
+ input: "TRCE Using test Git root override path=/tmp/TestCLICommands2870082120/001/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "macOS temp home directory path should be normalized",
+ input: "TRCE Checking for atmos.yaml in home directory path=/var/folders/ly/sz00b8054kv_85k1m9_0dmfc0000gn/T/TestCLICommands123456789/001/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
+ {
+ name: "Linux temp home directory path should be normalized",
+ input: "TRCE Checking for atmos.yaml in home directory path=/tmp/TestCLICommands123456789/001/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
+ {
+ name: "GitHub Actions macOS runner path should be normalized for mock-git-root",
+ input: "TRCE Using test Git root override path=/Users/runner/work/atmos/atmos/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "GitHub Actions macOS runner path should be normalized for .atmos",
+ input: "TRCE Checking for atmos.yaml in home directory path=/Users/runner/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
+ {
+ name: "GitHub Actions Linux runner path should be normalized for mock-git-root",
+ input: "TRCE Using test Git root override path=/home/runner/work/atmos/atmos/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "GitHub Actions Linux runner path should be normalized for .atmos",
+ input: "TRCE Checking for atmos.yaml in home directory path=/home/runner/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
+ {
+ name: "Windows path should be normalized for mock-git-root",
+ input: "TRCE Using test Git root override path=D:/a/atmos/atmos/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "Windows path should be normalized for .atmos",
+ input: "TRCE Checking for atmos.yaml in home directory path=C:/Users/runner/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
+ {
+ name: "Already sanitized repo path with mock-git-root should be normalized",
+ input: "TRCE Using test Git root override path=/absolute/path/to/repo/mock-git-root",
+ expected: "TRCE Using test Git root override path=/mock-git-root",
+ },
+ {
+ name: "Already sanitized repo path with .atmos should be normalized",
+ input: "TRCE Checking for atmos.yaml in home directory path=/absolute/path/to/repo/some/subdir/.atmos",
+ expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos",
+ },
}
for _, tt := range tests {
diff --git a/tests/cli_test.go b/tests/cli_test.go
index 68beb5cff0..c24f434b45 100644
--- a/tests/cli_test.go
+++ b/tests/cli_test.go
@@ -446,7 +446,20 @@ func sanitizeOutput(output string, opts ...sanitizeOption) (string, error) {
result = customRegex.ReplaceAllString(result, replacement)
}
- // 12. Normalize external absolute paths to avoid environment-specific paths in snapshots.
+ // 12a. Normalize temporary directory paths from TEST_GIT_ROOT and other test fixtures.
+ // These appear in trace logs as path=/var/folders/.../mock-git-root or path=/absolute/path/to/repo/mock-git-root.
+ // Replace with a stable placeholder since these are test-specific paths.
+ // Matches both raw paths and already-sanitized repo paths.
+ tempGitRootRegex := regexp.MustCompile(`path=(/var/folders/[^\s]+/mock-git-root|/tmp/[^\s]+/mock-git-root|/Users/[^\s]+/mock-git-root|/home/[^\s]+/mock-git-root|[A-Z]:/[^\s]+/mock-git-root|/absolute/path/to/repo/mock-git-root)`)
+ result = tempGitRootRegex.ReplaceAllString(result, "path=/mock-git-root")
+
+ // 12b. Normalize temp home directory paths in trace logs (e.g., path=/var/folders/.../T/TestCLI.../.atmos).
+ // These are used for home directory mocking in tests.
+ // Matches both raw paths and already-sanitized repo paths.
+ tempHomeDirRegex := regexp.MustCompile(`path=(/var/folders/[^\s]+/\.atmos|/tmp/[^\s]+/\.atmos|/Users/[^\s]+/\.atmos|/home/[^\s]+/\.atmos|[A-Z]:/[^\s]+/\.atmos|/absolute/path/to/repo/[^\s]+/\.atmos)`)
+ result = tempHomeDirRegex.ReplaceAllString(result, "path=/mock-home/.atmos")
+
+ // 12c. Normalize external absolute paths to avoid environment-specific paths in snapshots.
// Replace common absolute path prefixes with generic placeholders.
// This handles paths outside the repo (e.g., /Users/username/other-projects/).
// Match Unix-style absolute paths (/Users/, /home/, /opt/, etc.) and Windows paths (C:/Users/, etc.).
diff --git a/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden
index 199efdaa5c..72b0253541 100644
--- a/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden
+++ b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden
@@ -1,22 +1,64 @@
DEBU Set logs-level=trace logs-file=/dev/stderr
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
+ TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos
+ TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos
+ TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false
DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
+ TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos
+ TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos
+ TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false
DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found"
- TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found"
+ TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos
+ TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos
+ TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Using test Git root override path=/mock-git-root
+ TRCE Checking for .atmos.d in git root path=/mock-git-root
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root
+ TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
+ TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level
TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false
DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false
diff --git a/tests/test-cases/log-level-validation.yaml b/tests/test-cases/log-level-validation.yaml
index 7c9a2905ef..631b415cd6 100644
--- a/tests/test-cases/log-level-validation.yaml
+++ b/tests/test-cases/log-level-validation.yaml
@@ -54,6 +54,8 @@ tests:
expect:
diff:
- 'š½ Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9]+'
+ - 'TRCE.*Checking for atmos.yaml in system config'
+ - 'TRCE.*Checking for atmos.yaml in home directory'
stdout:
- '^\nš½ Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9_]+\n\n$'
exit_code: 0
diff --git a/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx b/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx
new file mode 100644
index 0000000000..ad10f61c17
--- /dev/null
+++ b/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx
@@ -0,0 +1,38 @@
+---
+slug: parent-directory-search-and-git-root-discovery
+title: "Parent Directory Search and Git Root Discovery"
+authors: [aknysh]
+tags: [feature, dx]
+date: 2025-11-11
+release: v1.198.0
+---
+
+Atmos now searches parent directories for `atmos.yaml` and discovers `.atmos.d/` at the git repository root, making it easier to run commands from anywhere in your project.
+
+
+
+## Parent Directory Search
+
+Atmos now automatically searches parent directories for `atmos.yaml` when not found in the current directory. Run Atmos from any subdirectory without specifying `--config-path`:
+
+```bash
+cd /repo/components/terraform/vpc
+atmos terraform plan vpc -s prod # Finds /repo/atmos.yaml automatically
+```
+
+## Git Root Discovery for `.atmos.d/`
+
+Atmos automatically discovers `.atmos.d/` at your git repository root, even when running from subdirectories. Define shared custom commands once at the repo root and use them from anywhere.
+
+```
+repo/
+āāā .atmos.d/
+ā āāā commands.yaml # Available repo-wide
+āāā atmos.yaml
+āāā components/terraform/vpc/
+ āāā main.tf # Run atmos from here
+```
+
+## Documentation
+
+- [CLI Configuration](/cli/configuration) ā Parent directory search, git root discovery, and `.atmos.d/` auto-imports
diff --git a/website/blog/2025-12-09-working-directory-support.mdx b/website/blog/2025-12-09-working-directory-support.mdx
new file mode 100644
index 0000000000..20de9fc464
--- /dev/null
+++ b/website/blog/2025-12-09-working-directory-support.mdx
@@ -0,0 +1,47 @@
+---
+slug: working-directory-support
+title: "Working Directory Support for Commands and Workflows"
+authors: [osterman]
+tags: [feature, dx]
+date: 2025-12-09
+---
+
+Custom commands and workflow steps can now specify a `working_directory` to control where they execute.
+
+
+
+## Working Directory for Commands and Workflows
+
+Custom commands and workflow steps can now specify a `working_directory` to control where they execute:
+
+```yaml
+# .atmos.d/commands.yaml
+commands:
+ - name: localstack
+ description: Start LocalStack for local development
+ working_directory: docker/localstack
+ steps:
+ - docker compose up -d
+```
+
+```yaml
+# stacks/workflows/deploy.yaml
+workflows:
+ build-and-deploy:
+ steps:
+ - command: make build
+ working_directory: !repo-root
+ type: shell
+ - command: docker compose up -d
+ working_directory: docker/app
+ type: shell
+```
+
+- **Absolute paths** are used as-is
+- **Relative paths** resolve against `base_path`
+- **Step-level** overrides workflow-level settings
+
+## Documentation
+
+- [Custom Commands](/cli/configuration/commands#working-directory)
+- [Workflows](/cli/configuration/workflows#working-directory)
diff --git a/website/docs/cli/configuration/commands.mdx b/website/docs/cli/configuration/commands.mdx
index 825d1f5065..9a4ddb8fef 100644
--- a/website/docs/cli/configuration/commands.mdx
+++ b/website/docs/cli/configuration/commands.mdx
@@ -454,6 +454,60 @@ commands:
{{ end }}
```
+## Working Directory
+
+Custom commands can specify a `working_directory` field to control where the command steps execute. This is useful when commands need to run from a specific location, regardless of where `atmos` was invoked.
+
+### Path Resolution
+
+- **Absolute paths** are used as-is (e.g., `/tmp`, `/home/user/scripts`)
+- **Relative paths** are resolved against the Atmos `base_path`
+- The `!repo-root` YAML function can be used to reference the git repository root
+
+### Example: Run from Repository Root
+
+```yaml
+commands:
+ - name: build
+ description: Build the project from repository root
+ working_directory: !repo-root .
+ steps:
+ - make build
+ - make test
+```
+
+This ensures the build commands run from the repository root, even if you invoke `atmos build` from a subdirectory.
+
+### Example: Run in Temp Directory
+
+```yaml
+commands:
+ - name: download-tools
+ description: Download and extract tools in /tmp
+ working_directory: /tmp
+ steps:
+ - wget https://example.com/tools.tar.gz
+ - tar -xzf tools.tar.gz
+```
+
+### Example: Run in Component Directory
+
+```yaml
+commands:
+ - name: component-init
+ description: Initialize a component
+ working_directory: components/terraform/vpc
+ steps:
+ - terraform init
+ - terraform validate
+```
+
+Since `components/terraform/vpc` is a relative path, it will be resolved against `base_path`.
+
+:::tip
+Use `working_directory: !repo-root .` when defining commands in `.atmos.d/` at the repository root. This ensures commands work correctly when invoked from any subdirectory in your project.
+:::
+
## Using Authentication with Custom Commands
Custom commands can specify an `identity` field to authenticate before execution. This is useful when commands need to interact with cloud resources that require specific credentials or elevated permissions.
diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx
index ba322dee14..285b8c4c23 100644
--- a/website/docs/cli/configuration/configuration.mdx
+++ b/website/docs/cli/configuration/configuration.mdx
@@ -31,11 +31,37 @@ Atmos discovers configuration from multiple sources in the following precedence
1. **Command-line flags** (`--config`, `--config-path`)
2. **Environment variable** (`ATMOS_CLI_CONFIG_PATH`)
-3. **Current directory** (`./atmos.yaml`)
-4. **Home directory** (`~/.atmos/atmos.yaml`)
-5. **System directory** (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows)
+3. **Profiles** (`--profile` or `ATMOS_PROFILE`) ā Named configuration overrides applied on top of base config
+4. **Current directory** (`./atmos.yaml`)
+5. **Parent directories** (walks up to the filesystem root)
+6. **Git repository root** (`.atmos.d/` auto-imports)
+7. **Home directory** (`~/.atmos/atmos.yaml`)
+8. **System directory** (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows)
-Each configuration file discovered is deep-merged with the preceding configurations.
+Each configuration file discovered is deep-merged with the preceding configurations. Separately, Atmos may also auto-import configuration fragments from `.atmos.d/`. The git repository root is special: even when `atmos.yaml` is found elsewhere, Atmos always checks for `.atmos.d/` at the repo root to load shared fragments like custom commands.
+
+Profiles are a first-class concept that allow environment-specific configuration overrides. When activated via `--profile` flag or `ATMOS_PROFILE` environment variable, profile settings are merged on top of the base configuration. Multiple profiles can be activated and merged left-to-right. See [Profiles](/cli/configuration/profiles) for complete documentation.
+
+### Parent Directory Search
+
+When no `atmos.yaml` is found in the current directory, Atmos automatically searches parent directories up to the filesystem root. This enables running Atmos from any subdirectory without specifying `--config-path`.
+
+```bash
+# Repository structure:
+# /repo/
+# atmos.yaml # ā Found via parent search
+# components/
+# terraform/
+# vpc/ # ā Running from here
+# main.tf
+
+cd /repo/components/terraform/vpc
+atmos terraform plan vpc -s prod # Automatically finds /repo/atmos.yaml
+```
+
+**Disabling parent directory search:**
+
+Set `ATMOS_CLI_CONFIG_PATH` to any value (including `.`) to disable parent directory searching and use only the specified path.
### Loading Multiple Configurations
@@ -48,6 +74,32 @@ atmos --config /path/to/config1.yaml --config /path/to/config2.yaml \
Configurations are deep-merged in the order provided, with later configurations overriding earlier ones.
+### Profiles
+
+Profiles provide named sets of configuration overrides that can be activated at runtime. This is the recommended way to manage environment-specific settings (development, CI/CD, production) without modifying your base configuration.
+
+```bash
+# Activate a profile via flag
+atmos --profile developer terraform plan vpc -s prod
+
+# Activate via environment variable
+export ATMOS_PROFILE=ci
+atmos terraform apply --auto-approve
+
+# Activate multiple profiles (merged left-to-right)
+atmos --profile base,developer,debug describe config
+```
+
+Profiles are discovered from multiple locations:
+1. Custom path configured in `profiles.base_path`
+2. `.atmos/profiles/` (project-local)
+3. `~/.config/atmos/profiles/` (XDG user profiles)
+4. `profiles/` (project-local)
+
+When multiple profiles are activated, they are merged in order. Later profiles override earlier ones.
+
+For complete documentation on creating and using profiles, see [Profiles](/cli/configuration/profiles).
+
### Glob Patterns
Atmos supports [POSIX-style Glob patterns](https://en.wikipedia.org/wiki/Glob_(programming)) for file discovery:
@@ -194,34 +246,43 @@ Atmos automatically imports configuration fragments from special directories, en
- `.atmos.d/` (preferred)
- `atmos.d/` (alternate)
+**Discovery locations (in precedence order, lowest to highest):**
+1. **Git repository root** ā `.atmos.d/` at the repo root is always discovered, even when running from subdirectories
+2. **Configuration directory** ā `.atmos.d/` next to `atmos.yaml` (overrides git root)
+
+This means you can place shared custom commands at the repository root and they'll be available from any subdirectory, while still allowing project-specific overrides.
+
**How it works:**
1. Atmos recursively discovers all `.yaml` and `.yml` files in these directories
2. Files are sorted by directory depth (shallower first), then alphabetically
3. Each file is automatically merged into the configuration
-4. Processing occurs after main config merges but before explicit `import:` entries
+4. Git root `.atmos.d` is loaded first (lower priority), then config directory `.atmos.d` (higher priority)
+5. Processing occurs after main config merges but before explicit `import:` entries
**Example structure:**
```
-project/
-āāā atmos.yaml # Main configuration
-āāā .atmos.d/
- āāā commands/
- ā āāā custom.yaml # Custom commands
- āāā profiles/
- ā āāā ci.yaml # CI profile
- āāā settings/
- āāā team.yaml # Team-specific settings
+repo/ # Git repository root
+āāā .atmos.d/
+ā āāā commands.yaml # Shared commands available repo-wide
+āāā atmos.yaml # Main configuration
+āāā components/
+ā āāā terraform/
+ā āāā vpc/
+ā āāā main.tf # ā Can run atmos from here
```
+Running from `repo/components/terraform/vpc/`:
+- Commands from `repo/.atmos.d/commands.yaml` are automatically discovered
+- No need to specify `--config-path` or change directories
+
**Use cases:**
- **Custom commands** ā Drop in command definitions without modifying `atmos.yaml`
-- **Profiles** ā Add environment-specific profiles for CI/CD
-- **Team settings** ā Share team-specific defaults across projects
-- **Local overrides** ā Git-ignored local configuration fragments
+- **Repo-wide commands** ā Define commands at repo root, use from any subdirectory
+- **Local overrides** ā Git-ignored local configuration fragments (add `.atmos.d/local/` to `.gitignore`)
:::tip
-The `.atmos.d/` directory is ideal for configuration that shouldn't be in the main `atmos.yaml`, such as local developer settings (add `.atmos.d/local/` to `.gitignore`) or dynamically generated configuration.
+The `.atmos.d/` directory is ideal for configuration that shouldn't be in the main `atmos.yaml`, such as local developer settings or dynamically generated configuration. For environment-specific configuration overrides, use [Profiles](/cli/configuration/profiles) instead.
:::
### Windows Path Handling
diff --git a/website/docs/cli/configuration/workflows.mdx b/website/docs/cli/configuration/workflows.mdx
index 90f19b2cf9..f46dc15a6a 100644
--- a/website/docs/cli/configuration/workflows.mdx
+++ b/website/docs/cli/configuration/workflows.mdx
@@ -72,6 +72,75 @@ workflows:
```
+## Working Directory
+
+Workflows support a `working_directory` setting that controls where steps execute. This can be set at both the workflow level (applies to all steps) and individual step level (overrides workflow setting).
+
+### Path Resolution
+
+- **Absolute paths** are used as-is (e.g., `/tmp`)
+- **Relative paths** are resolved against the Atmos `base_path`
+- The `!repo-root` YAML function can dynamically resolve to the git repository root
+
+### Example: Workflow-Level Working Directory
+
+