Skip to main content

grit_lib/
repo_location.rs

1//! Find a repository's git directory without opening it.
2//!
3//! [`RepositoryLocation::discover`] answers "which repository is this?" for operations that only
4//! touch files inside the git directory, such as reading or rewriting `.git/config`. It skips
5//! the object database and the ref store that [`Repository::discover`] sets up.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::config::ConfigSet;
11use crate::environment::RepositoryOptions;
12use crate::error::Result;
13use crate::git_path::CanonicalizeExt;
14
15use super::{
16    ensure_valid_ownership, head_file_present, local_config_write_path, parse_repository_format,
17    read_core_bare_and_worktree_from_config, validate_repository_format_parsed, Repository,
18};
19
20/// Where a repository lives on disk: its git directory and, when it has one, its work tree.
21///
22/// Found by [`Self::discover`], which resolves the same repository and work tree as
23/// [`Repository::discover`] and applies the same checks: the repository format must be
24/// supported, and the work tree and git directory must be owned by the current user or allowed
25/// by `safe.directory`.
26///
27/// # Examples
28///
29/// ```
30/// use grit_lib::config::environment::{Environment, RepositoryOptions};
31/// use grit_lib::repo::{InitOptions, Repository, RepositoryLocation};
32///
33/// # fn main() -> grit_lib::error::Result<()> {
34/// let dir = tempfile::tempdir()?;
35/// Repository::init(dir.path(), &InitOptions::default())?;
36///
37/// let mut options = RepositoryOptions::default();
38/// options.environment = Environment::from_vars(std::iter::empty(), dir.path().to_path_buf());
39/// let location = RepositoryLocation::discover(&options)?;
40/// assert!(location.git_dir().ends_with(".git"));
41/// assert!(location.local_config_path().ends_with(".git/config"));
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RepositoryLocation {
47    git_dir: PathBuf,
48    work_tree: Option<PathBuf>,
49}
50
51impl RepositoryLocation {
52    /// Locate the repository containing the discovery directory of `options.environment`.
53    ///
54    /// The common layout (a `.git` directory owned by the current user, found by walking up
55    /// from the discovery directory) is resolved by reading `HEAD`, the `objects` directory and
56    /// the merged configuration cascade once. Every other layout is resolved by
57    /// [`Repository::discover`]: `GIT_DIR`, `GIT_WORK_TREE`, `GIT_OBJECT_DIRECTORY`,
58    /// `GIT_CEILING_DIRECTORIES`, gitfiles, linked worktrees, bare repositories, and any
59    /// configuration (from any scope, including `-c` and `GIT_CONFIG_*` overrides) that sets
60    /// `core.bare` to true or sets `core.worktree`.
61    ///
62    /// # Errors
63    ///
64    /// The same errors as [`Repository::discover`]: [`crate::error::Error::NotARepository`]
65    /// when no repository is found, [`crate::error::Error::DubiousOwnership`] when
66    /// `safe.directory` rejects it, and repository format errors such as
67    /// [`crate::error::Error::UnsupportedRepositoryFormatVersion`].
68    pub fn discover(options: &RepositoryOptions) -> Result<Self> {
69        Self::discover_with_config(options).map(|(location, _)| location)
70    }
71
72    /// Like [`Self::discover`], and also return the repository's merged configuration: the same
73    /// set as [`ConfigSet::load`] with this git directory and command-line overrides included.
74    ///
75    /// Discovery needs that cascade to resolve `core.bare` and `core.worktree`, so callers that
76    /// read configuration next can use it instead of loading the files again.
77    ///
78    /// # Errors
79    ///
80    /// The errors of [`Self::discover`], and config read or parse errors from
81    /// [`ConfigSet::load`].
82    pub fn discover_with_config(options: &RepositoryOptions) -> Result<(Self, ConfigSet)> {
83        if let Some(found) = locate_dot_git_dir(options)? {
84            return Ok(found);
85        }
86        let repo = Repository::discover(None, options)?;
87        let location = Self::from(&repo);
88        let config = ConfigSet::load(&options.environment, Some(location.git_dir()), true)?;
89        Ok((location, config))
90    }
91
92    /// Absolute path of the git directory (`.git`, a linked worktree's admin directory, or the
93    /// root of a bare repository).
94    #[must_use]
95    pub fn git_dir(&self) -> &Path {
96        &self.git_dir
97    }
98
99    /// Absolute path of the work tree, or `None` for a bare repository.
100    #[must_use]
101    pub fn work_tree(&self) -> Option<&Path> {
102        self.work_tree.as_deref()
103    }
104
105    /// The repository-local config file that `--local` reads and writes (`.git/config`, or the
106    /// common directory's `config` for a linked worktree).
107    #[must_use]
108    pub fn local_config_path(&self) -> PathBuf {
109        local_config_write_path(&self.git_dir)
110    }
111}
112
113impl From<&Repository> for RepositoryLocation {
114    fn from(repo: &Repository) -> Self {
115        Self {
116            git_dir: repo.git_dir().to_path_buf(),
117            work_tree: repo.work_tree().map(Path::to_path_buf),
118        }
119    }
120}
121
122type Located = (RepositoryLocation, ConfigSet);
123
124/// Resolve the plain `.git` directory layout, or `Ok(None)` to defer to full discovery.
125fn locate_dot_git_dir(options: &RepositoryOptions) -> Result<Option<Located>> {
126    let env = &options.environment;
127    let has_ceilings = env
128        .git_ceiling_directories
129        .as_deref()
130        .is_some_and(|c| !c.is_empty());
131    if env.git_dir.is_some()
132        || env.git_work_tree.is_some()
133        || env.git_object_directory.is_some()
134        || has_ceilings
135        || options.test_assume_different_owner
136        || env.test_assume_different_owner()
137    {
138        return Ok(None);
139    }
140
141    let start = env.discovery_cwd();
142    for dir in start.ancestors() {
143        let dot_git = dir.join(".git");
144        match fs::symlink_metadata(&dot_git) {
145            Ok(meta) if meta.is_dir() => return accept_dot_git_dir(options, dir, &dot_git),
146            Ok(_) => return Ok(None),
147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
148            Err(_) => return Ok(None),
149        }
150        // A `HEAD` here may make `dir` a bare repository or a linked worktree's admin directory.
151        if fs::symlink_metadata(dir.join("HEAD")).is_ok() {
152            return Ok(None);
153        }
154    }
155    Ok(None)
156}
157
158/// Accept `dot_git` in the same order full discovery checks it: the merged config must load,
159/// then the repository format must be supported, then the layout must be the plain one, and
160/// finally ownership is checked.
161fn accept_dot_git_dir(
162    options: &RepositoryOptions,
163    work_tree: &Path,
164    dot_git: &Path,
165) -> Result<Option<Located>> {
166    if !head_file_present(dot_git)
167        || dot_git.join("commondir").exists()
168        || !dot_git.join("objects").is_dir()
169    {
170        return Ok(None);
171    }
172    let Ok(git_dir) = dot_git.canonicalize_simplified() else {
173        return Ok(None);
174    };
175    let env = &options.environment;
176    let Ok(config) = ConfigSet::load(env, Some(&git_dir), true) else {
177        return Ok(None);
178    };
179
180    let config_path = git_dir.join("config");
181    match fs::read_to_string(&config_path) {
182        Ok(content) => {
183            let Ok(format) = parse_repository_format(&content, &config_path) else {
184                return Ok(None);
185            };
186            validate_repository_format_parsed(&format)?;
187        }
188        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
189        Err(_) => return Ok(None),
190    }
191
192    let (bare, core_worktree) = read_core_bare_and_worktree_from_config(&config);
193    if bare || core_worktree.is_some() {
194        return Ok(None);
195    }
196
197    ensure_valid_ownership(env, None, Some(work_tree), &git_dir)?;
198    let location = RepositoryLocation {
199        git_dir,
200        work_tree: Some(work_tree.to_path_buf()),
201    };
202    Ok(Some((location, config)))
203}
204
205#[cfg(test)]
206mod tests {
207    #![allow(clippy::unwrap_used)]
208
209    use super::*;
210    use crate::environment::Environment;
211    use crate::repo::InitOptions;
212    use std::ffi::OsString;
213
214    fn options_at(cwd: &Path, vars: &[(&str, &str)]) -> RepositoryOptions {
215        let vars: Vec<(OsString, OsString)> = vars
216            .iter()
217            .map(|(k, v)| (OsString::from(k), OsString::from(v)))
218            .collect();
219        RepositoryOptions {
220            environment: Environment::from_vars(vars, cwd.to_path_buf()),
221            ..RepositoryOptions::default()
222        }
223    }
224
225    fn init(dir: &Path) {
226        Repository::init(dir, &InitOptions::default()).unwrap();
227    }
228
229    fn full(options: &RepositoryOptions) -> RepositoryLocation {
230        RepositoryLocation::from(&Repository::discover(None, options).unwrap())
231    }
232
233    #[test]
234    fn dot_git_layout_matches_full_discovery_from_a_subdirectory() {
235        let tmp = tempfile::tempdir().unwrap();
236        init(tmp.path());
237        let sub = tmp.path().join("a/b");
238        fs::create_dir_all(&sub).unwrap();
239        let options = options_at(&sub, &[]);
240
241        let (fast, _) = locate_dot_git_dir(&options).unwrap().unwrap();
242        assert_eq!(fast, full(&options));
243        assert_eq!(RepositoryLocation::discover(&options).unwrap(), fast);
244        assert_eq!(fast.local_config_path(), fast.git_dir().join("config"));
245    }
246
247    #[test]
248    fn missing_repository_reports_not_a_repository() {
249        let tmp = tempfile::tempdir().unwrap();
250        let options = options_at(tmp.path(), &[("GIT_CEILING_DIRECTORIES", "/")]);
251        let err = RepositoryLocation::discover(&options).unwrap_err();
252        assert!(matches!(err, crate::error::Error::NotARepository(_)));
253    }
254
255    #[test]
256    fn unsupported_format_version_is_rejected() {
257        let tmp = tempfile::tempdir().unwrap();
258        init(tmp.path());
259        fs::write(
260            tmp.path().join(".git/config"),
261            "[core]\n\trepositoryformatversion = 2\n",
262        )
263        .unwrap();
264        let err = RepositoryLocation::discover(&options_at(tmp.path(), &[])).unwrap_err();
265        assert!(matches!(
266            err,
267            crate::error::Error::UnsupportedRepositoryFormatVersion(2)
268        ));
269    }
270
271    #[test]
272    fn layout_changing_local_config_defers_to_full_discovery() {
273        let tmp = tempfile::tempdir().unwrap();
274        init(tmp.path());
275        let other = tmp.path().join("elsewhere");
276        fs::create_dir(&other).unwrap();
277        fs::write(tmp.path().join(".git/extra"), "[core]\n\tbare = true\n").unwrap();
278        let config = tmp.path().join(".git/config");
279        let original = fs::read_to_string(&config).unwrap();
280        for extra in [
281            format!("[core]\n\tworktree = {}\n", other.display()),
282            "[core]\n\tbare = true\n".to_owned(),
283            "[include]\n\tpath = extra\n".to_owned(),
284        ] {
285            fs::write(&config, format!("{original}{extra}")).unwrap();
286            let options = options_at(tmp.path(), &[]);
287            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{extra}");
288            assert_eq!(
289                RepositoryLocation::discover(&options).unwrap(),
290                full(&options)
291            );
292        }
293    }
294
295    #[test]
296    fn layout_changing_override_and_global_config_match_full_discovery() {
297        let tmp = tempfile::tempdir().unwrap();
298        let repo = tmp.path().join("repo");
299        init(&repo);
300        let other = tmp.path().join("elsewhere");
301        fs::create_dir(&other).unwrap();
302        let global = tmp.path().join("global.cfg");
303        fs::write(
304            &global,
305            format!("[core]\n\tworktree = {}\n", other.display()),
306        )
307        .unwrap();
308        let global = global.to_str().unwrap().to_owned();
309        let cases: [Vec<(&str, &str)>; 3] = [
310            vec![
311                ("GIT_CONFIG_COUNT", "1"),
312                ("GIT_CONFIG_KEY_0", "core.bare"),
313                ("GIT_CONFIG_VALUE_0", "true"),
314            ],
315            vec![("GIT_CONFIG_PARAMETERS", "'core.bare'='true'")],
316            vec![("GIT_CONFIG_GLOBAL", global.as_str())],
317        ];
318        for vars in cases {
319            let options = options_at(&repo, &vars);
320            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{vars:?}");
321            assert_eq!(
322                RepositoryLocation::discover(&options).unwrap(),
323                full(&options),
324                "{vars:?}"
325            );
326        }
327        let bare_override = options_at(
328            &repo,
329            &[
330                ("GIT_CONFIG_COUNT", "1"),
331                ("GIT_CONFIG_KEY_0", "core.bare"),
332                ("GIT_CONFIG_VALUE_0", "true"),
333            ],
334        );
335        assert_eq!(
336            RepositoryLocation::discover(&bare_override)
337                .unwrap()
338                .work_tree(),
339            None
340        );
341    }
342
343    #[test]
344    fn discover_with_config_returns_the_merged_cascade() {
345        let tmp = tempfile::tempdir().unwrap();
346        let repo = tmp.path().join("repo");
347        init(&repo);
348        let global = tmp.path().join("global.cfg");
349        fs::write(&global, "[user]\n\tname = Global\n").unwrap();
350        let options = options_at(
351            &repo,
352            &[
353                ("GIT_CONFIG_GLOBAL", global.to_str().unwrap()),
354                ("GIT_CONFIG_COUNT", "1"),
355                ("GIT_CONFIG_KEY_0", "review.cmdline"),
356                ("GIT_CONFIG_VALUE_0", "yes"),
357            ],
358        );
359        let (location, config) = RepositoryLocation::discover_with_config(&options).unwrap();
360        assert_eq!(location, full(&options));
361        let expected =
362            ConfigSet::load(&options.environment, Some(location.git_dir()), true).unwrap();
363        let keyed = |set: &ConfigSet| -> Vec<(String, Option<String>)> {
364            set.entries()
365                .iter()
366                .map(|e| (e.key.clone(), e.value.clone()))
367                .collect()
368        };
369        assert_eq!(keyed(&config), keyed(&expected));
370        assert_eq!(config.get("user.name").as_deref(), Some("Global"));
371        assert_eq!(config.get("review.cmdline").as_deref(), Some("yes"));
372    }
373
374    #[test]
375    fn environment_overrides_defer_to_full_discovery() {
376        let tmp = tempfile::tempdir().unwrap();
377        init(tmp.path());
378        let git_dir = tmp.path().join(".git");
379        let git_dir = git_dir.to_str().unwrap();
380        let ceiling = tmp.path().parent().unwrap().to_str().unwrap().to_owned();
381        for vars in [
382            vec![("GIT_DIR", git_dir)],
383            vec![("GIT_CEILING_DIRECTORIES", ceiling.as_str())],
384            vec![("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")],
385        ] {
386            let options = options_at(tmp.path(), &vars);
387            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{vars:?}");
388        }
389    }
390
391    #[test]
392    fn gitfile_and_bare_layouts_use_full_discovery() {
393        let tmp = tempfile::tempdir().unwrap();
394        let bare = tmp.path().join("bare.git");
395        Repository::init(
396            &bare,
397            &InitOptions {
398                bare: true,
399                ..InitOptions::default()
400            },
401        )
402        .unwrap();
403        let options = options_at(&bare, &[]);
404        assert!(locate_dot_git_dir(&options).unwrap().is_none());
405        let location = RepositoryLocation::discover(&options).unwrap();
406        assert_eq!(location.work_tree(), None);
407        assert_eq!(location, full(&options));
408
409        let linked = tmp.path().join("linked");
410        fs::create_dir(&linked).unwrap();
411        fs::write(linked.join(".git"), format!("gitdir: {}\n", bare.display())).unwrap();
412        let options = options_at(&linked, &[]);
413        assert!(locate_dot_git_dir(&options).unwrap().is_none());
414        assert_eq!(
415            RepositoryLocation::discover(&options).unwrap(),
416            full(&options)
417        );
418    }
419
420    #[test]
421    fn location_from_repository_copies_paths() {
422        let tmp = tempfile::tempdir().unwrap();
423        init(tmp.path());
424        let repo = Repository::discover(None, &options_at(tmp.path(), &[])).unwrap();
425        let location = RepositoryLocation::from(&repo);
426        assert_eq!(location.git_dir(), repo.git_dir());
427        assert_eq!(location.work_tree(), repo.work_tree());
428    }
429}