1use 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RepositoryLocation {
47 git_dir: PathBuf,
48 work_tree: Option<PathBuf>,
49}
50
51impl RepositoryLocation {
52 pub fn discover(options: &RepositoryOptions) -> Result<Self> {
69 Self::discover_with_config(options).map(|(location, _)| location)
70 }
71
72 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 #[must_use]
95 pub fn git_dir(&self) -> &Path {
96 &self.git_dir
97 }
98
99 #[must_use]
101 pub fn work_tree(&self) -> Option<&Path> {
102 self.work_tree.as_deref()
103 }
104
105 #[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
124fn 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 if fs::symlink_metadata(dir.join("HEAD")).is_ok() {
157 return Ok(None);
158 }
159 }
160 Ok(None)
161}
162
163fn 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}