From 8fd90a98e632124b59fd2f101eaf891c64340aeb Mon Sep 17 00:00:00 2001 From: Kai Armstrong Date: Mon, 27 Oct 2025 15:27:18 -0500 Subject: [PATCH] feat(config): add XDG config merging support When GLAB_CONFIG_DIR is not set, glab now merges configuration files from multiple XDG paths instead of using only the first file found. This allows system administrators to provide default configurations while users can selectively override specific settings. Relates to https://gitlab.com/gitlab-org/cli/-/issues/8018 --- README.md | 121 +++++++++---- internal/config/config_file.go | 270 +++++++++++++++++++++++++++- internal/config/config_file_test.go | 156 ++++++++++++++++ 3 files changed, 508 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 5c9056def..9eb653b6e 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 936735377..bc672b205 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 1157a6bf3..86e3a137a 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) +} -- GitLab