Skip to main content

grit_lib/config/
location.rs

1//! Config file locations (`--local`, `--global`, `-f`) and scoped load/write helpers.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::environment::Environment;
7use crate::error::{ConfigError, Error, Result};
8
9use super::{
10    global_config_paths_pub, resolve_config_override_path, system_config_path, ConfigFile,
11    ConfigScope, ConfigSet, IncludeContext,
12};
13
14/// Which config file(s) to read or write.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ConfigLocation {
17    /// Repository `.git/config` (or `commondir/config` for linked worktrees).
18    Local,
19    /// Per-user global config (XDG + `~/.gitconfig`, or `GIT_CONFIG_GLOBAL`).
20    Global,
21    /// System-wide `/etc/gitconfig` (or platform equivalent).
22    System,
23    /// Per-worktree `config.worktree` (when enabled).
24    Worktree,
25    /// A single file from `-f` / `--file`.
26    File(PathBuf),
27}
28
29impl ConfigLocation {
30    /// Scope stored in parsed entries from this location's on-disk file.
31    #[must_use]
32    pub fn file_scope(&self) -> ConfigScope {
33        match self {
34            Self::Local => ConfigScope::Local,
35            Self::File(_) => ConfigScope::Local,
36            Self::Global => ConfigScope::Global,
37            Self::System => ConfigScope::System,
38            Self::Worktree => ConfigScope::Worktree,
39        }
40    }
41
42    /// Path to create or update when writing a value at this location.
43    ///
44    /// Global writes follow Git's writer preference: an existing `~/.gitconfig`, then an
45    /// existing XDG `git/config`, otherwise the conventional `~/.gitconfig`.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`Error::Config`] when the location needs a repository but none was given.
50    pub fn write_path(&self, env: &Environment, git_dir: Option<&Path>) -> Result<PathBuf> {
51        match self {
52            Self::Global => global_config_write_path(env),
53            Self::Local => {
54                let gd = git_dir.ok_or_else(|| {
55                    Error::Config(ConfigError::Other("not in a git repository".into()))
56                })?;
57                Ok(crate::repo::local_config_write_path(gd))
58            }
59            Self::File(path) => {
60                let s = path.to_string_lossy();
61                Ok(resolve_config_override_path(env, s.as_ref()))
62            }
63            Self::System => Ok(system_config_path(env)),
64            Self::Worktree => {
65                let gd = git_dir.ok_or_else(|| {
66                    Error::Config(ConfigError::Other("not in a git repository".into()))
67                })?;
68                Ok(gd.join("config.worktree"))
69            }
70        }
71    }
72
73    /// Load configuration from this location only (no full cascade).
74    ///
75    /// `[include]` / `[includeIf]` directives are expanded. Command-line overrides are not
76    /// included except for the full-repository [`ConfigSet::load`] path used by the CLI default
77    /// read mode.
78    pub fn load_single(&self, env: &Environment, git_dir: Option<&Path>) -> Result<ConfigSet> {
79        let mut set = ConfigSet::new();
80        let ctx = IncludeContext {
81            git_dir: git_dir.map(Path::to_path_buf),
82            command_line_relative_include_is_error: false,
83            cwd: env.cwd.clone(),
84            pwd: env.pwd.clone(),
85            env: std::sync::Arc::new(env.clone()),
86        };
87
88        match self {
89            Self::Global => {
90                for path in global_config_paths_pub(env) {
91                    if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Global)? {
92                        set.merge_file_with_includes(&f, true, &ctx)?;
93                    }
94                }
95            }
96            Self::Local => {
97                let gd = git_dir.ok_or_else(|| {
98                    Error::Config(ConfigError::Other("not in a git repository".into()))
99                })?;
100                let local_path = crate::repo::local_config_write_path(gd);
101                if let Some(f) = ConfigFile::from_path(&local_path, ConfigScope::Local)? {
102                    set.merge_file_with_includes(&f, true, &ctx)?;
103                }
104            }
105            Self::System => {
106                let system_path = system_config_path(env);
107                if let Some(f) = ConfigFile::from_path(&system_path, ConfigScope::System)? {
108                    set.merge_file_with_includes(&f, true, &ctx)?;
109                }
110            }
111            Self::Worktree => {
112                let gd = git_dir.ok_or_else(|| {
113                    Error::Config(ConfigError::Other("not in a git repository".into()))
114                })?;
115                let common = crate::repo::common_git_dir_for_config(gd);
116                if !crate::repo::worktree_config_enabled(&common) {
117                    return Ok(set);
118                }
119                let wt_path = gd.join("config.worktree");
120                if let Some(f) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree)? {
121                    set.merge_file_with_includes(&f, true, &ctx)?;
122                }
123            }
124            Self::File(path) => {
125                let s = path.to_string_lossy();
126                let path = resolve_config_override_path(env, s.as_ref());
127                if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Command)? {
128                    set.merge_file_with_includes(&f, true, &ctx)?;
129                }
130            }
131        }
132
133        Ok(set)
134    }
135}
136
137/// Global config file path for writes, matching Git's preference for an existing file.
138///
139/// When `GIT_CONFIG_GLOBAL` is set, that path is always used. Otherwise prefer an existing
140/// `~/.gitconfig`, then an existing XDG `git/config`, then create `~/.gitconfig`.
141pub fn global_config_write_path(env: &Environment) -> Result<PathBuf> {
142    let paths = global_config_paths_pub(env);
143    if paths.len() == 1 {
144        return paths.into_iter().next().ok_or_else(|| {
145            Error::Config(ConfigError::Other(
146                "could not determine global config path".into(),
147            ))
148        });
149    }
150    let dotgitconfig = paths.last().cloned();
151    if dotgitconfig.as_ref().is_some_and(|p| p.exists()) {
152        return dotgitconfig.ok_or_else(|| {
153            Error::Config(ConfigError::Other(
154                "could not determine global config path".into(),
155            ))
156        });
157    }
158    if let Some(xdg) = paths.first() {
159        if xdg.exists() {
160            return Ok(xdg.clone());
161        }
162    }
163    dotgitconfig.ok_or_else(|| {
164        Error::Config(ConfigError::Other(
165            "could not determine global config path".into(),
166        ))
167    })
168}
169
170/// Human-readable summary of a config write that `--dry-run` would perform.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ConfigWritePreview {
173    /// Config file that would be updated.
174    pub path: PathBuf,
175    /// Short description of the change.
176    pub summary: String,
177}
178
179fn ensure_worktree_write_allowed(location: &ConfigLocation, git_dir: Option<&Path>) -> Result<()> {
180    if !matches!(location, ConfigLocation::Worktree) {
181        return Ok(());
182    }
183    let Some(git_dir) = git_dir else {
184        return Err(Error::Config(ConfigError::Other(
185            "not in a git repository".into(),
186        )));
187    };
188    let common = crate::repo::common_git_dir_for_config(git_dir);
189    if !crate::repo::worktree_config_enabled(&common) {
190        return Err(Error::Config(ConfigError::WorktreeConfigNotEnabled));
191    }
192    Ok(())
193}
194
195fn open_location_config(
196    location: &ConfigLocation,
197    env: &Environment,
198    git_dir: Option<&Path>,
199) -> Result<(PathBuf, ConfigFile)> {
200    ensure_worktree_write_allowed(location, git_dir)?;
201    let path = location.write_path(env, git_dir)?;
202    let scope = location.file_scope();
203    let content = fs::read_to_string(&path).unwrap_or_default();
204    let cfg = ConfigFile::parse(&path, &content, scope)?;
205    Ok((path, cfg))
206}
207
208fn persist_config(
209    path: &Path,
210    cfg: &mut ConfigFile,
211    dry_run: bool,
212    summary: String,
213) -> Result<Option<ConfigWritePreview>> {
214    if dry_run {
215        return Ok(Some(ConfigWritePreview {
216            path: path.to_path_buf(),
217            summary,
218        }));
219    }
220    cfg.write()?;
221    Ok(None)
222}
223
224/// Set `key` to `value` in the config file for `location`.
225pub fn set_config_value(
226    location: ConfigLocation,
227    env: &Environment,
228    git_dir: Option<&Path>,
229    key: &str,
230    value: &str,
231    dry_run: bool,
232) -> Result<Option<ConfigWritePreview>> {
233    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
234    cfg.set(key, value)?;
235    persist_config(&path, &mut cfg, dry_run, format!("set {key}={value}"))
236}
237
238/// Append `value` for `key` (multi-valued) in the config file for `location`.
239pub fn add_config_value(
240    location: ConfigLocation,
241    env: &Environment,
242    git_dir: Option<&Path>,
243    key: &str,
244    value: &str,
245    dry_run: bool,
246) -> Result<Option<ConfigWritePreview>> {
247    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
248    cfg.add_value(key, value)?;
249    persist_config(&path, &mut cfg, dry_run, format!("add {key}={value}"))
250}
251
252/// Remove `key` from the config file for `location`.
253///
254/// # Errors
255///
256/// Returns [`ConfigError::KeyNotSet`] when the key is absent in that file.
257pub fn unset_config_value(
258    location: ConfigLocation,
259    env: &Environment,
260    git_dir: Option<&Path>,
261    key: &str,
262    dry_run: bool,
263) -> Result<Option<ConfigWritePreview>> {
264    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
265    if !path.exists() {
266        return Err(Error::Config(ConfigError::KeyNotSet {
267            key: key.to_owned(),
268        }));
269    }
270    let removed = cfg.unset_last(key)?;
271    if removed == 0 {
272        return Err(Error::Config(ConfigError::KeyNotSet {
273            key: key.to_owned(),
274        }));
275    }
276    persist_config(&path, &mut cfg, dry_run, format!("unset {key}"))
277}
278
279/// Remove every occurrence of `key` from the config file for `location`.
280pub fn unset_all_config_values(
281    location: ConfigLocation,
282    env: &Environment,
283    git_dir: Option<&Path>,
284    key: &str,
285    dry_run: bool,
286) -> Result<(usize, Option<ConfigWritePreview>)> {
287    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
288    let removed = cfg.unset_matching(key, None, false)?;
289    if removed == 0 {
290        return Err(Error::Config(ConfigError::KeyNotSet {
291            key: key.to_owned(),
292        }));
293    }
294    let preview = persist_config(
295        &path,
296        &mut cfg,
297        dry_run,
298        format!("unset-all {key} ({removed} values)"),
299    )?;
300    Ok((removed, preview))
301}
302
303/// Rename a section in the config file for `location`.
304pub fn rename_config_section(
305    location: ConfigLocation,
306    env: &Environment,
307    git_dir: Option<&Path>,
308    old_name: &str,
309    new_name: &str,
310    dry_run: bool,
311) -> Result<Option<ConfigWritePreview>> {
312    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
313    if !cfg.rename_section(old_name, new_name)? {
314        return Err(Error::Config(ConfigError::SectionNotFound {
315            section: old_name.to_owned(),
316        }));
317    }
318    persist_config(
319        &path,
320        &mut cfg,
321        dry_run,
322        format!("rename-section {old_name} -> {new_name}"),
323    )
324}
325
326/// Remove a section from the config file for `location`.
327pub fn remove_config_section(
328    location: ConfigLocation,
329    env: &Environment,
330    git_dir: Option<&Path>,
331    section: &str,
332    dry_run: bool,
333) -> Result<Option<ConfigWritePreview>> {
334    let (path, mut cfg) = open_location_config(&location, env, git_dir)?;
335    if !cfg.remove_section(section)? {
336        return Err(Error::Config(ConfigError::SectionNotFound {
337            section: section.to_owned(),
338        }));
339    }
340    persist_config(
341        &path,
342        &mut cfg,
343        dry_run,
344        format!("remove-section {section}"),
345    )
346}