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 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 if fs::symlink_metadata(dir.join("HEAD")).is_ok() {
152 return Ok(None);
153 }
154 }
155 Ok(None)
156}
157
158fn 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}