diff --git a/README.md b/README.md index 5c9056defa1cd0f5f3b5c9de26d1cd7db66318fa..9eb653b6ed61f6a109925192193a43eedca643bd 100644 --- a/README.md +++ b/README.md @@ -263,16 +263,69 @@ Configure `glab` at different levels: system-wide, globally (per-user), locally the `--host` parameter to meet your needs. - Per-host configuration info is always stored in the global configuration file, with or without the `global` flag. -### Configuration Search Order +### Configuration Search Order and Merging -When `glab` looks for configuration files, it searches in this order (highest priority first): +`glab` supports two configuration modes: -1. `$GLAB_CONFIG_DIR/config.yml` (if `GLAB_CONFIG_DIR` is set) -2. `$XDG_CONFIG_HOME/glab-cli/config.yml` (default: `~/.config/glab-cli/config.yml`) -3. `$XDG_CONFIG_DIRS/glab-cli/config.yml` (default: `/etc/xdg/glab-cli/config.yml`) +#### GLAB_CONFIG_DIR Mode (No Merging) -The first configuration file found is used. This allows system administrators to provide -site-wide defaults while allowing individual users to override them. +When `GLAB_CONFIG_DIR` is set, `glab` uses only that directory for configuration: + +- `$GLAB_CONFIG_DIR/config.yml` (if found) +- XDG paths are completely ignored +- No merging occurs + +#### XDG Mode (With Merging) + +When `GLAB_CONFIG_DIR` is not set, `glab` follows the XDG Base Directory Spec and merges +configurations from multiple locations: + +1. System configs from `$XDG_CONFIG_DIRS/glab-cli/config.yml` (default: `/etc/xdg/glab-cli/config.yml`) +2. User config from `$XDG_CONFIG_HOME/glab-cli/config.yml` (default: `~/.config/glab-cli/config.yml`) + +**Merging behavior:** + +- Global settings (like `editor`, `pager`, `browser`): User values override system values +- Host-specific settings: Configurations are merged per-host, with user settings overriding system settings +- Settings unique to either file are preserved + +**Example:** + +System config (`/etc/xdg/glab-cli/config.yml`): + +```yaml +editor: vim +git_protocol: ssh +hosts: + gitlab.com: + token: system_token + api_protocol: https +``` + +User config (`~/.config/glab-cli/config.yml`): + +```yaml +editor: nano +browser: firefox +hosts: + gitlab.com: + token: user_token +``` + +**Effective merged configuration:** + +```yaml +editor: nano # user override +browser: firefox # from user +git_protocol: ssh # from system +hosts: + gitlab.com: + token: user_token # user override + api_protocol: https # from system +``` + +This allows system administrators to provide site-wide defaults while allowing individual +users to customize or override specific settings. ### Configure `glab` to use your GitLab Self-Managed or GitLab Dedicated instance @@ -340,39 +393,39 @@ self-signed certificates, either: ### GitLab access variables -| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | -|--------------------|----------------------------------|--------------------------------------------|-------------| -| `GITLAB_API_HOST` | `hosts..api_host`, or `hosts.` if empty | Hostname found in the Git URL | Specify the host where the API endpoint is found. Useful when there are separate (sub)domains or hosts for Git and the API endpoint. | -| `GITLAB_CLIENT_ID` | `hosts..client_id` | Client-ID for GitLab.com. | A custom Client-ID generated by the GitLab OAuth 2.0 application. | -| `GITLAB_GROUP` | - | - | Default GitLab group used for listing merge requests, issues and variables. Only used if no `--group` option is given. | -| `GITLAB_HOST` | `host` (this is the default host `glab` will use when the current directory is not a `git` directory) | `https://gitlab.com` | Alias of `GITLAB_URI`. | -| `GITLAB_REPO` | - | - | Default GitLab repository used for commands accepting the `--repo` option. Only used if no `--repo` option is given. | -| `GITLAB_TOKEN` | `hosts..token` | - | an authentication token for API requests. Setting this avoids being prompted to authenticate and overrides any previously stored credentials. Can be set in the config with `glab config set token xxxxxx`. | -| `GITLAB_URI` | not applicable | not applicable | Alias of `GITLAB_HOST`. | +| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | +|--------------------|-------------------------------------------------------------------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `GITLAB_API_HOST` | `hosts..api_host`, or `hosts.` if empty | Hostname found in the Git URL | Specify the host where the API endpoint is found. Useful when there are separate (sub)domains or hosts for Git and the API endpoint. | +| `GITLAB_CLIENT_ID` | `hosts..client_id` | Client-ID for GitLab.com. | A custom Client-ID generated by the GitLab OAuth 2.0 application. | +| `GITLAB_GROUP` | - | - | Default GitLab group used for listing merge requests, issues and variables. Only used if no `--group` option is given. | +| `GITLAB_HOST` | `host` (this is the default host `glab` will use when the current directory is not a `git` directory) | `https://gitlab.com` | Alias of `GITLAB_URI`. | +| `GITLAB_REPO` | - | - | Default GitLab repository used for commands accepting the `--repo` option. Only used if no `--repo` option is given. | +| `GITLAB_TOKEN` | `hosts..token` | - | an authentication token for API requests. Setting this avoids being prompted to authenticate and overrides any previously stored credentials. Can be set in the config with `glab config set token xxxxxx`. | +| `GITLAB_URI` | not applicable | not applicable | Alias of `GITLAB_HOST`. | ### `glab` configuration variables -| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | -|--------------------|-----------------|--------------------------------------------|-------------| -| `BROWSER` | `browser` | system default | The web browser to use for opening links. Can be set in the configuration with `glab config set browser mybrowser`. | -| `FORCE_HYPERLINKS` | `display_hyperlinks` | `false` | Set to `true` to force hyperlinks to be output, even when not outputting to a TTY. | -| `GITLAB_RELEASE_ASSETS_USE_PACKAGE_REGISTRY` | - | - | When `true` or `1`, the `glab release create` command uploads release assets to the generic package registry of the project. Can be overridden with the `--use-package-registry` flag. | -| `GLAB_CHECK_UPDATE` | - | - | Set to `true` to force an update check. | -| `GLAB_CONFIG_DIR` | - | `~/.config/glab-cli/` | Directory where the `glab` global configuration file is located. Can be set in the config with `glab config set remote_alias origin`. | -| `GLAB_DEBUG_HTTP` | - | `false` | Set to true to output HTTP transport information (request / response). | -| `GLAB_SEND_TELEMETRY` | `telemetry` | `true` | Set to `false` to prevent command usage data from being sent to your GitLab instance. | -| `GLAMOUR_STYLE` | `glamour_style` | `dark` | Environment variable to set your desired Markdown renderer style. Available options are (`dark`, `light`, `notty`) or set a [custom style](https://github.com/charmbracelet/glamour#styles). | -| `NO_COLOR` | - | `true` | Set to any value to avoid printing ANSI escape sequences for color output. | -| `NO_PROMPT` | `no_prompt` | `false` | Set to `true` to disable prompts. | -| `VISUAL`, `EDITOR` | `editor` | `nano` | (in order of precedence) The editor tool to use for authoring text. Can be set in the config with `glab config set editor vim`. | +| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | +|----------------------------------------------|----------------------|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `BROWSER` | `browser` | system default | The web browser to use for opening links. Can be set in the configuration with `glab config set browser mybrowser`. | +| `FORCE_HYPERLINKS` | `display_hyperlinks` | `false` | Set to `true` to force hyperlinks to be output, even when not outputting to a TTY. | +| `GITLAB_RELEASE_ASSETS_USE_PACKAGE_REGISTRY` | - | - | When `true` or `1`, the `glab release create` command uploads release assets to the generic package registry of the project. Can be overridden with the `--use-package-registry` flag. | +| `GLAB_CHECK_UPDATE` | - | - | Set to `true` to force an update check. | +| `GLAB_CONFIG_DIR` | - | `~/.config/glab-cli/` | Directory where the `glab` global configuration file is located. Can be set in the config with `glab config set remote_alias origin`. | +| `GLAB_DEBUG_HTTP` | - | `false` | Set to true to output HTTP transport information (request / response). | +| `GLAB_SEND_TELEMETRY` | `telemetry` | `true` | Set to `false` to prevent command usage data from being sent to your GitLab instance. | +| `GLAMOUR_STYLE` | `glamour_style` | `dark` | Environment variable to set your desired Markdown renderer style. Available options are (`dark`, `light`, `notty`) or set a [custom style](https://github.com/charmbracelet/glamour#styles). | +| `NO_COLOR` | - | `true` | Set to any value to avoid printing ANSI escape sequences for color output. | +| `NO_PROMPT` | `no_prompt` | `false` | Set to `true` to disable prompts. | +| `VISUAL`, `EDITOR` | `editor` | `nano` | (in order of precedence) The editor tool to use for authoring text. Can be set in the config with `glab config set editor vim`. | ### Other variables -| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | -|----------------------|-----------------|--------------------------------------------|-------------| -| `DEBUG` | `debug` | `false` | Set to `true` to output more information for each command, like Git commands, expanded aliases, and DNS error details. | -| `GIT_REMOTE_URL_VAR` | not applicable | not applicable | Alias of `REMOTE_ALIAS`. | -| `REMOTE_ALIAS` | `remote_alias` | - | `git remote` variable or alias that contains the GitLab URL. Alias: `GIT_REMOTE_URL_VAR` | +| Token name | In `config.yml` | Default value if [not set](#configuration) | Description | +|----------------------|-----------------|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `DEBUG` | `debug` | `false` | Set to `true` to output more information for each command, like Git commands, expanded aliases, and DNS error details. | +| `GIT_REMOTE_URL_VAR` | not applicable | not applicable | Alias of `REMOTE_ALIAS`. | +| `REMOTE_ALIAS` | `remote_alias` | - | `git remote` variable or alias that contains the GitLab URL. Alias: `GIT_REMOTE_URL_VAR` | #### Variable deprecation diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 9367353779348f9ab0fe70cd536cfaecdb5e1e37..bc672b20558731909dfb3d59b16676ced794f696 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -64,6 +64,45 @@ func SearchConfigFile() (string, error) { return configPath, nil } +// SearchAllConfigFiles searches for all existing config files across XDG config paths. +// It returns config files in order from lowest to highest priority (system → user), +// so later configs should override earlier ones during merging. +// Returns nil if GLAB_CONFIG_DIR is set (no merging in that mode). +// +// Search order: +// 1. System configs from XDG_CONFIG_DIRS (e.g., /etc/xdg/glab-cli/config.yml) +// 2. User config from XDG_CONFIG_HOME (e.g., ~/.config/glab-cli/config.yml) +func SearchAllConfigFiles() []string { + // When GLAB_CONFIG_DIR is set, we don't merge - return nil + if os.Getenv("GLAB_CONFIG_DIR") != "" { + return nil + } + + var configFiles []string + + // First, check system-wide XDG config directories (lowest priority) + // xdg.ConfigDirs includes both XDG_CONFIG_DIRS and XDG_CONFIG_HOME + // We need to iterate through them and exclude the user config home + for _, dir := range xdg.ConfigDirs { + // Skip if this is the user config home directory + if dir == xdg.ConfigHome { + continue + } + configPath := filepath.Join(dir, "glab-cli", "config.yml") + if _, err := os.Stat(configPath); err == nil { + configFiles = append(configFiles, configPath) + } + } + + // Finally, check user config (highest priority) + userConfigPath := filepath.Join(xdg.ConfigHome, "glab-cli", "config.yml") + if _, err := os.Stat(userConfigPath); err == nil { + configFiles = append(configFiles, userConfigPath) + } + + return configFiles +} + // Init initialises and returns the cached configuration func Init() (Config, error) { if cachedConfig != nil || configError != nil { @@ -81,13 +120,234 @@ func Init() (Config, error) { } func ParseDefaultConfig() (Config, error) { - // Try to find existing config first (searches all XDG paths) - configPath, err := SearchConfigFile() + // When GLAB_CONFIG_DIR is set, use single-file logic (no merging) + if os.Getenv("GLAB_CONFIG_DIR") != "" { + configPath, err := SearchConfigFile() + if err != nil { + // No config found, use default writable location + configPath = ConfigFile() + } + return ParseConfig(configPath) + } + + // XDG mode: search for all config files to merge + configFiles := SearchAllConfigFiles() + if len(configFiles) == 0 { + // No configs found, use default writable location + return ParseConfig(ConfigFile()) + } + + if len(configFiles) == 1 { + // Only one config file, no need to merge + return ParseConfig(configFiles[0]) + } + + // Multiple config files found, merge them + return parseAndMergeConfigs(configFiles) +} + +// parseAndMergeConfigs loads multiple config files and merges them. +// Files are processed in order (system → user), with later files taking precedence. +// Merging rules: +// - Global keys: later value completely replaces earlier value +// - "hosts" key: merge host entries, combining settings for matching hosts +func parseAndMergeConfigs(configFiles []string) (Config, error) { + if len(configFiles) == 0 { + return nil, fmt.Errorf("no config files to merge") + } + + // Parse the first (base) config file + _, baseRoot, err := ParseConfigFile(configFiles[0]) if err != nil { - // No config found, use default writable location - configPath = ConfigFile() + if os.IsNotExist(err) { + baseRoot = NewBlankRoot() + } else { + return nil, fmt.Errorf("failed to parse base config %s: %w", configFiles[0], err) + } + } + + // Merge each subsequent config file into the base + for i := 1; i < len(configFiles); i++ { + _, nextRoot, err := ParseConfigFile(configFiles[i]) + if err != nil { + if os.IsNotExist(err) { + continue // Skip non-existent files + } + return nil, fmt.Errorf("failed to parse config %s: %w", configFiles[i], err) + } + + // Merge nextRoot into baseRoot + baseRoot = mergeYAMLNodes(baseRoot, nextRoot) } - return ParseConfig(configPath) + + // Now load local config and aliases as usual + // Load local config file + if _, localRoot, err := ParseConfigFile(LocalConfigFile()); err == nil { + if len(localRoot.Content[0].Content) > 0 { + newContent := []*yaml.Node{ + {Value: "local"}, + localRoot.Content[0], + } + restContent := baseRoot.Content[0].Content + baseRoot.Content[0].Content = append(newContent, restContent...) + } + } + + // Load aliases config file + if _, aliasesRoot, err := ParseConfigFile(aliasesConfigFile()); err == nil { + if len(aliasesRoot.Content[0].Content) > 0 { + newContent := []*yaml.Node{ + {Value: "aliases"}, + aliasesRoot.Content[0], + } + restContent := baseRoot.Content[0].Content + baseRoot.Content[0].Content = append(newContent, restContent...) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + return NewConfig(baseRoot), nil +} + +// mergeYAMLNodes merges two YAML document nodes. +// For most keys, newer values completely replace base values. +// For the "hosts" key, we merge host entries intelligently. +func mergeYAMLNodes(base, newer *yaml.Node) *yaml.Node { + if base == nil || len(base.Content) == 0 { + return newer + } + if newer == nil || len(newer.Content) == 0 { + return base + } + + // Work with the mapping node (first content of document) + baseMap := base.Content[0] + newerMap := newer.Content[0] + + if baseMap.Kind != yaml.MappingNode || newerMap.Kind != yaml.MappingNode { + // If either is not a mapping, just use the newer one + return newer + } + + // Create a map of keys in base for quick lookup + baseKeys := make(map[string]int) // key -> index in Content slice + for i := 0; i < len(baseMap.Content); i += 2 { + if i+1 < len(baseMap.Content) { + key := baseMap.Content[i].Value + baseKeys[key] = i + } + } + + // Process keys from newer config + for i := 0; i < len(newerMap.Content); i += 2 { + if i+1 >= len(newerMap.Content) { + continue + } + + keyNode := newerMap.Content[i] + valueNode := newerMap.Content[i+1] + key := keyNode.Value + + if baseIdx, exists := baseKeys[key]; exists { + // Key exists in both configs + if key == "hosts" { + // Special merging for hosts + baseMap.Content[baseIdx+1] = mergeHostsNode(baseMap.Content[baseIdx+1], valueNode) + } else { + // For other keys, newer value replaces base value + baseMap.Content[baseIdx+1] = valueNode + } + } else { + // Key only in newer config, add it to base + baseMap.Content = append(baseMap.Content, keyNode, valueNode) + } + } + + return base +} + +// mergeHostsNode merges two "hosts" mapping nodes. +// Hosts that appear in both configs have their settings merged. +// Hosts that appear in only one config are included as-is. +func mergeHostsNode(base, newer *yaml.Node) *yaml.Node { + if base == nil || base.Kind != yaml.MappingNode { + return newer + } + if newer == nil || newer.Kind != yaml.MappingNode { + return base + } + + // Create a map of host names in base + baseHosts := make(map[string]int) // hostname -> index in Content slice + for i := 0; i < len(base.Content); i += 2 { + if i+1 < len(base.Content) { + hostname := base.Content[i].Value + baseHosts[hostname] = i + } + } + + // Process hosts from newer config + for i := 0; i < len(newer.Content); i += 2 { + if i+1 >= len(newer.Content) { + continue + } + + hostnameNode := newer.Content[i] + hostSettingsNode := newer.Content[i+1] + hostname := hostnameNode.Value + + if baseIdx, exists := baseHosts[hostname]; exists { + // Host exists in both configs, merge settings + base.Content[baseIdx+1] = mergeHostSettings(base.Content[baseIdx+1], hostSettingsNode) + } else { + // Host only in newer config, add it + base.Content = append(base.Content, hostnameNode, hostSettingsNode) + } + } + + return base +} + +// mergeHostSettings merges settings for a specific host. +// Settings in newer config override settings in base config. +func mergeHostSettings(base, newer *yaml.Node) *yaml.Node { + if base == nil || base.Kind != yaml.MappingNode { + return newer + } + if newer == nil || newer.Kind != yaml.MappingNode { + return base + } + + // Create a map of settings in base + baseSettings := make(map[string]int) // setting key -> index in Content slice + for i := 0; i < len(base.Content); i += 2 { + if i+1 < len(base.Content) { + settingKey := base.Content[i].Value + baseSettings[settingKey] = i + } + } + + // Process settings from newer config + for i := 0; i < len(newer.Content); i += 2 { + if i+1 >= len(newer.Content) { + continue + } + + settingKeyNode := newer.Content[i] + settingValueNode := newer.Content[i+1] + settingKey := settingKeyNode.Value + + if baseIdx, exists := baseSettings[settingKey]; exists { + // Setting exists in both, newer value overrides + base.Content[baseIdx+1] = settingValueNode + } else { + // Setting only in newer config, add it + base.Content = append(base.Content, settingKeyNode, settingValueNode) + } + } + + return base } var ReadConfigFile = func(filename string) ([]byte, error) { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 1157a6bf35796418793d42b8ceb92031ab0a67c1..86e3a137a87c159d1243a97a721c8d514b0db0cb 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -477,3 +477,159 @@ func Test_ConfigFile_PureFunction(t *testing.T) { _, err := os.Stat(configDir) assert.True(t, os.IsNotExist(err), "ConfigFile should not create directories") } + +func Test_ConfigMerging_GlobalKeys(t *testing.T) { + test.ClearEnvironmentVariables(t) + + // Create system config directory + systemConfigDir := t.TempDir() + systemGlabDir := filepath.Join(systemConfigDir, "glab-cli") + err := os.MkdirAll(systemGlabDir, 0o750) + require.NoError(t, err) + + // Create user config directory + userConfigDir := t.TempDir() + userGlabDir := filepath.Join(userConfigDir, "glab-cli") + err = os.MkdirAll(userGlabDir, 0o750) + require.NoError(t, err) + + // Write system config with some global settings + systemConfigContent := `editor: vim +git_protocol: ssh +pager: less +` + systemConfigFile := filepath.Join(systemGlabDir, "config.yml") + err = os.WriteFile(systemConfigFile, []byte(systemConfigContent), 0o600) + require.NoError(t, err) + + // Write user config that overrides some settings and adds new ones + userConfigContent := `editor: nano +browser: firefox +` + userConfigFile := filepath.Join(userGlabDir, "config.yml") + err = os.WriteFile(userConfigFile, []byte(userConfigContent), 0o600) + require.NoError(t, err) + + // Set XDG environment variables + t.Setenv("XDG_CONFIG_DIRS", systemConfigDir) + t.Setenv("XDG_CONFIG_HOME", userConfigDir) + xdg.Reload() + + // Parse config (should merge) + cfg, err := ParseDefaultConfig() + require.NoError(t, err) + + // Check merged values + editor, _ := cfg.Get("", "editor") + assert.Equal(t, "nano", editor) // User override + + gitProtocol, _ := cfg.Get("", "git_protocol") + assert.Equal(t, "ssh", gitProtocol) // From system + + pager, _ := cfg.Get("", "pager") + assert.Equal(t, "less", pager) // From system + + browser, _ := cfg.Get("", "browser") + assert.Equal(t, "firefox", browser) // From user +} + +func Test_ConfigMerging_HostSettings(t *testing.T) { + test.ClearEnvironmentVariables(t) + + // Create system config directory + systemConfigDir := t.TempDir() + systemGlabDir := filepath.Join(systemConfigDir, "glab-cli") + err := os.MkdirAll(systemGlabDir, 0o750) + require.NoError(t, err) + + // Create user config directory + userConfigDir := t.TempDir() + userGlabDir := filepath.Join(userConfigDir, "glab-cli") + err = os.MkdirAll(userGlabDir, 0o750) + require.NoError(t, err) + + // Write system config with host settings + systemConfigContent := `hosts: + gitlab.com: + api_protocol: https + token: system_token + gitlab.example.com: + api_protocol: https + token: example_token +` + systemConfigFile := filepath.Join(systemGlabDir, "config.yml") + err = os.WriteFile(systemConfigFile, []byte(systemConfigContent), 0o600) + require.NoError(t, err) + + // Write user config with overlapping and new host settings + userConfigContent := `hosts: + gitlab.com: + token: user_token + gitlab.another.com: + api_protocol: https + token: another_token +` + userConfigFile := filepath.Join(userGlabDir, "config.yml") + err = os.WriteFile(userConfigFile, []byte(userConfigContent), 0o600) + require.NoError(t, err) + + // Set XDG environment variables + t.Setenv("XDG_CONFIG_DIRS", systemConfigDir) + t.Setenv("XDG_CONFIG_HOME", userConfigDir) + xdg.Reload() + + // Parse config (should merge) + cfg, err := ParseDefaultConfig() + require.NoError(t, err) + + // gitlab.com: should have merged settings (user token overrides system) + token, _ := cfg.Get("gitlab.com", "token") + assert.Equal(t, "user_token", token) + apiProtocol, _ := cfg.Get("gitlab.com", "api_protocol") + assert.Equal(t, "https", apiProtocol) // From system + + // gitlab.example.com: only in system config + exampleToken, _ := cfg.Get("gitlab.example.com", "token") + assert.Equal(t, "example_token", exampleToken) + + // gitlab.another.com: only in user config + anotherToken, _ := cfg.Get("gitlab.another.com", "token") + assert.Equal(t, "another_token", anotherToken) +} + +func Test_ConfigMerging_NoMergingWithGLABConfigDir(t *testing.T) { + test.ClearEnvironmentVariables(t) + + // Create system config directory (should be ignored) + systemConfigDir := t.TempDir() + systemGlabDir := filepath.Join(systemConfigDir, "glab-cli") + err := os.MkdirAll(systemGlabDir, 0o750) + require.NoError(t, err) + + systemConfigContent := `editor: system_editor +` + systemConfigFile := filepath.Join(systemGlabDir, "config.yml") + err = os.WriteFile(systemConfigFile, []byte(systemConfigContent), 0o600) + require.NoError(t, err) + + // Create GLAB_CONFIG_DIR + glabConfigDir := t.TempDir() + glabConfigFile := filepath.Join(glabConfigDir, "config.yml") + glabConfigContent := `editor: glab_editor +` + err = os.WriteFile(glabConfigFile, []byte(glabConfigContent), 0o600) + require.NoError(t, err) + + // Set environment variables + t.Setenv("GLAB_CONFIG_DIR", glabConfigDir) + t.Setenv("XDG_CONFIG_DIRS", systemConfigDir) + xdg.Reload() + + // Parse config (should NOT merge, only use GLAB_CONFIG_DIR) + cfg, err := ParseDefaultConfig() + require.NoError(t, err) + + // Should only have settings from GLAB_CONFIG_DIR + editor, _ := cfg.Get("", "editor") + assert.Equal(t, "glab_editor", editor) +}