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 traces_setup = env
128        .git_trace_setup
129        .as_deref()
130        .is_some_and(|p| !p.is_empty() && p != "0");
131    let has_ceilings = env
132        .git_ceiling_directories
133        .as_deref()
134        .is_some_and(|c| !c.is_empty());
135    if env.git_dir.is_some()
136        || env.git_work_tree.is_some()
137        || env.git_object_directory.is_some()
138        || has_ceilings
139        || traces_setup
140        || options.test_assume_different_owner
141        || env.test_assume_different_owner()
142    {
143        return Ok(None);
144    }
145
146    let start = env.discovery_cwd();
147    for dir in start.ancestors() {
148        let dot_git = dir.join(".git");
149        match fs::symlink_metadata(&dot_git) {
150            Ok(meta) if meta.is_dir() => return accept_dot_git_dir(options, dir, &dot_git),
151            Ok(_) => return Ok(None),
152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
153            Err(_) => return Ok(None),
154        }
155        // A `HEAD` here may make `dir` a bare repository or a linked worktree's admin directory.
156        if fs::symlink_metadata(dir.join("HEAD")).is_ok() {
157            return Ok(None);
158        }
159    }
160    Ok(None)
161}
162
163/// Accept `dot_git` in the same order full discovery checks it: the merged config must load,
164/// then the repository format must be supported, then the layout must be the plain one, and
165/// finally ownership is checked.
166fn accept_dot_git_dir(
167    options: &RepositoryOptions,
168    work_tree: &Path,
169    dot_git: &Path,
170) -> Result<Option<Located>> {
171    if !head_file_present(dot_git)
172        || dot_git.join("commondir").exists()
173        || !dot_git.join("objects").is_dir()
174    {
175        return Ok(None);
176    }
177    let Ok(git_dir) = dot_git.canonicalize_simplified() else {
178        return Ok(None);
179    };
180    let env = &options.environment;
181    let Ok(config) = ConfigSet::load(env, Some(&git_dir), true) else {
182        return Ok(None);
183    };
184
185    let config_path = git_dir.join("config");
186    match fs::read_to_string(&config_path) {
187        Ok(content) => {
188            let Ok(format) = parse_repository_format(&content, &config_path) else {
189                return Ok(None);
190            };
191            validate_repository_format_parsed(&format)?;
192        }
193        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
194        Err(_) => return Ok(None),
195    }
196
197    let (bare, core_worktree) = read_core_bare_and_worktree_from_config(&config);
198    if bare || core_worktree.is_some() {
199        return Ok(None);
200    }
201
202    ensure_valid_ownership(env, None, Some(work_tree), &git_dir)?;
203    let location = RepositoryLocation {
204        git_dir,
205        work_tree: Some(work_tree.to_path_buf()),
206    };
207    Ok(Some((location, config)))
208}
209
210#[cfg(test)]
211mod tests {
212    #![allow(clippy::unwrap_used)]
213
214    use super::*;
215    use crate::environment::Environment;
216    use crate::repo::InitOptions;
217    use std::ffi::OsString;
218
219    fn options_at(cwd: &Path, vars: &[(&str, &str)]) -> RepositoryOptions {
220        let vars: Vec<(OsString, OsString)> = vars
221            .iter()
222            .map(|(k, v)| (OsString::from(k), OsString::from(v)))
223            .collect();
224        RepositoryOptions {
225            environment: Environment::from_vars(vars, cwd.to_path_buf()),
226            ..RepositoryOptions::default()
227        }
228    }
229
230    fn init(dir: &Path) {
231        Repository::init(dir, &InitOptions::default()).unwrap();
232    }
233
234    fn full(options: &RepositoryOptions) -> RepositoryLocation {
235        RepositoryLocation::from(&Repository::discover(None, options).unwrap())
236    }
237
238    #[test]
239    fn dot_git_layout_matches_full_discovery_from_a_subdirectory() {
240        let tmp = tempfile::tempdir().unwrap();
241        init(tmp.path());
242        let sub = tmp.path().join("a/b");
243        fs::create_dir_all(&sub).unwrap();
244        let options = options_at(&sub, &[]);
245
246        let (fast, _) = locate_dot_git_dir(&options).unwrap().unwrap();
247        assert_eq!(fast, full(&options));
248        assert_eq!(RepositoryLocation::discover(&options).unwrap(), fast);
249        assert_eq!(fast.local_config_path(), fast.git_dir().join("config"));
250    }
251
252    #[test]
253    fn missing_repository_reports_not_a_repository() {
254        let tmp = tempfile::tempdir().unwrap();
255        let options = options_at(tmp.path(), &[("GIT_CEILING_DIRECTORIES", "/")]);
256        let err = RepositoryLocation::discover(&options).unwrap_err();
257        assert!(matches!(err, crate::error::Error::NotARepository(_)));
258    }
259
260    #[test]
261    fn unsupported_format_version_is_rejected() {
262        let tmp = tempfile::tempdir().unwrap();
263        init(tmp.path());
264        fs::write(
265            tmp.path().join(".git/config"),
266            "[core]\n\trepositoryformatversion = 2\n",
267        )
268        .unwrap();
269        let err = RepositoryLocation::discover(&options_at(tmp.path(), &[])).unwrap_err();
270        assert!(matches!(
271            err,
272            crate::error::Error::UnsupportedRepositoryFormatVersion(2)
273        ));
274    }
275
276    #[test]
277    fn layout_changing_local_config_defers_to_full_discovery() {
278        let tmp = tempfile::tempdir().unwrap();
279        init(tmp.path());
280        let other = tmp.path().join("elsewhere");
281        fs::create_dir(&other).unwrap();
282        fs::write(tmp.path().join(".git/extra"), "[core]\n\tbare = true\n").unwrap();
283        let config = tmp.path().join(".git/config");
284        let original = fs::read_to_string(&config).unwrap();
285        for extra in [
286            format!("[core]\n\tworktree = {}\n", other.display()),
287            "[core]\n\tbare = true\n".to_owned(),
288            "[include]\n\tpath = extra\n".to_owned(),
289        ] {
290            fs::write(&config, format!("{original}{extra}")).unwrap();
291            let options = options_at(tmp.path(), &[]);
292            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{extra}");
293            assert_eq!(
294                RepositoryLocation::discover(&options).unwrap(),
295                full(&options)
296            );
297        }
298    }
299
300    #[test]
301    fn layout_changing_override_and_global_config_match_full_discovery() {
302        let tmp = tempfile::tempdir().unwrap();
303        let repo = tmp.path().join("repo");
304        init(&repo);
305        let other = tmp.path().join("elsewhere");
306        fs::create_dir(&other).unwrap();
307        let global = tmp.path().join("global.cfg");
308        fs::write(
309            &global,
310            format!("[core]\n\tworktree = {}\n", other.display()),
311        )
312        .unwrap();
313        let global = global.to_str().unwrap().to_owned();
314        let cases: [Vec<(&str, &str)>; 3] = [
315            vec![
316                ("GIT_CONFIG_COUNT", "1"),
317                ("GIT_CONFIG_KEY_0", "core.bare"),
318                ("GIT_CONFIG_VALUE_0", "true"),
319            ],
320            vec![("GIT_CONFIG_PARAMETERS", "'core.bare'='true'")],
321            vec![("GIT_CONFIG_GLOBAL", global.as_str())],
322        ];
323        for vars in cases {
324            let options = options_at(&repo, &vars);
325            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{vars:?}");
326            assert_eq!(
327                RepositoryLocation::discover(&options).unwrap(),
328                full(&options),
329                "{vars:?}"
330            );
331        }
332        let bare_override = options_at(
333            &repo,
334            &[
335                ("GIT_CONFIG_COUNT", "1"),
336                ("GIT_CONFIG_KEY_0", "core.bare"),
337                ("GIT_CONFIG_VALUE_0", "true"),
338            ],
339        );
340        assert_eq!(
341            RepositoryLocation::discover(&bare_override)
342                .unwrap()
343                .work_tree(),
344            None
345        );
346    }
347
348    #[test]
349    fn discover_with_config_returns_the_merged_cascade() {
350        let tmp = tempfile::tempdir().unwrap();
351        let repo = tmp.path().join("repo");
352        init(&repo);
353        let global = tmp.path().join("global.cfg");
354        fs::write(&global, "[user]\n\tname = Global\n").unwrap();
355        let options = options_at(
356            &repo,
357            &[
358                ("GIT_CONFIG_GLOBAL", global.to_str().unwrap()),
359                ("GIT_CONFIG_COUNT", "1"),
360                ("GIT_CONFIG_KEY_0", "review.cmdline"),
361                ("GIT_CONFIG_VALUE_0", "yes"),
362            ],
363        );
364        let (location, config) = RepositoryLocation::discover_with_config(&options).unwrap();
365        assert_eq!(location, full(&options));
366        let expected =
367            ConfigSet::load(&options.environment, Some(location.git_dir()), true).unwrap();
368        let keyed = |set: &ConfigSet| -> Vec<(String, Option<String>)> {
369            set.entries()
370                .iter()
371                .map(|e| (e.key.clone(), e.value.clone()))
372                .collect()
373        };
374        assert_eq!(keyed(&config), keyed(&expected));
375        assert_eq!(config.get("user.name").as_deref(), Some("Global"));
376        assert_eq!(config.get("review.cmdline").as_deref(), Some("yes"));
377    }
378
379    #[test]
380    fn environment_overrides_defer_to_full_discovery() {
381        let tmp = tempfile::tempdir().unwrap();
382        init(tmp.path());
383        let git_dir = tmp.path().join(".git");
384        let git_dir = git_dir.to_str().unwrap();
385        let ceiling = tmp.path().parent().unwrap().to_str().unwrap().to_owned();
386        for vars in [
387            vec![("GIT_DIR", git_dir)],
388            vec![("GIT_CEILING_DIRECTORIES", ceiling.as_str())],
389            vec![("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")],
390        ] {
391            let options = options_at(tmp.path(), &vars);
392            assert!(locate_dot_git_dir(&options).unwrap().is_none(), "{vars:?}");
393        }
394    }
395
396    #[test]
397    fn gitfile_and_bare_layouts_use_full_discovery() {
398        let tmp = tempfile::tempdir().unwrap();
399        let bare = tmp.path().join("bare.git");
400        Repository::init(
401            &bare,
402            &InitOptions {
403                bare: true,
404                ..InitOptions::default()
405            },
406        )
407        .unwrap();
408        let options = options_at(&bare, &[]);
409        assert!(locate_dot_git_dir(&options).unwrap().is_none());
410        let location = RepositoryLocation::discover(&options).unwrap();
411        assert_eq!(location.work_tree(), None);
412        assert_eq!(location, full(&options));
413
414        let linked = tmp.path().join("linked");
415        fs::create_dir(&linked).unwrap();
416        fs::write(linked.join(".git"), format!("gitdir: {}\n", bare.display())).unwrap();
417        let options = options_at(&linked, &[]);
418        assert!(locate_dot_git_dir(&options).unwrap().is_none());
419        assert_eq!(
420            RepositoryLocation::discover(&options).unwrap(),
421            full(&options)
422        );
423    }
424
425    #[test]
426    fn location_from_repository_copies_paths() {
427        let tmp = tempfile::tempdir().unwrap();
428        init(tmp.path());
429        let repo = Repository::discover(None, &options_at(tmp.path(), &[])).unwrap();
430        let location = RepositoryLocation::from(&repo);
431        assert_eq!(location.git_dir(), repo.git_dir());
432        assert_eq!(location.work_tree(), repo.work_tree());
433    }
434}