Skip to main content

grit_lib/
repo.rs

1//! Repository discovery and the primary `Repository` handle.
2//!
3//! # Discovery
4//!
5//! [`Repository::discover`] walks up from a starting directory to find the
6//! nearest `.git` directory (or bare repository), honouring `GIT_DIR` and
7//! `GIT_WORK_TREE` environment variables and the `.git` gitfile indirection.
8//!
9//! # Structure
10//!
11//! A [`Repository`] owns:
12//!
13//! - `git_dir` — absolute path to the `.git` directory (or the repo root for
14//!   bare repos).
15//! - `work_tree` — `Some(path)` for non-bare repos, `None` for bare.
16//! - [`Odb`] — the loose object database.
17
18use std::collections::{BTreeSet, HashSet};
19use std::env;
20use std::fs;
21use std::fs::OpenOptions;
22use std::io::Write;
23use std::path::{Component, Path, PathBuf};
24use std::sync::Mutex;
25
26use crate::config::{ConfigFile, ConfigScope, ConfigSet};
27use crate::error::{Error, Result};
28use crate::hooks::run_hook;
29use crate::index::Index;
30use crate::objects::parse_commit;
31use crate::odb::Odb;
32use crate::rev_parse::is_inside_work_tree;
33use crate::sparse_checkout::effective_cone_mode_for_sparse_file;
34use crate::split_index::{write_index_file_split, WriteSplitIndexRequest};
35use crate::state::resolve_head;
36use crate::worktree_cwd::cwd_relative_under_work_tree;
37
38const GIT_PREFIX_ENV: &str = "GIT_PREFIX";
39
40/// Set `GIT_PREFIX` to the repository-relative path of the process cwd (POSIX, no trailing `/`).
41///
42/// Git's `git-sh-setup` / `cd_to_toplevel` moves the process to the work tree root but preserves
43/// the original subdirectory in `GIT_PREFIX` (`setup.c`). Helpers such as `git-merge-one-file`
44/// rely on this for correct cwd-sensitive behavior.
45fn export_git_prefix_env(repo: &Repository) {
46    let Some(wt) = repo.work_tree.as_ref() else {
47        return;
48    };
49    let Ok(cwd) = env::current_dir() else {
50        return;
51    };
52    let new_s = cwd_relative_under_work_tree(wt, &cwd).unwrap_or_default();
53    if new_s.is_empty() {
54        if let Ok(existing) = env::var(GIT_PREFIX_ENV) {
55            if !existing.trim().is_empty() {
56                return;
57            }
58        }
59    }
60    env::set_var(GIT_PREFIX_ENV, new_s);
61}
62
63fn read_sparse_checkout_patterns(git_dir: &Path) -> Vec<String> {
64    let path = git_dir.join("info").join("sparse-checkout");
65    let Ok(content) = fs::read_to_string(&path) else {
66        return Vec::new();
67    };
68    content
69        .lines()
70        .map(|l| l.trim())
71        .filter(|l| !l.is_empty() && !l.starts_with('#'))
72        .map(String::from)
73        .collect()
74}
75
76/// A handle to an open Git repository.
77#[derive(Debug)]
78pub struct Repository {
79    /// Absolute path to the git directory (`.git/` or bare repo root).
80    pub git_dir: PathBuf,
81    /// Absolute path to the working tree, or `None` for bare repos.
82    pub work_tree: Option<PathBuf>,
83    /// Loose object database.
84    pub odb: Odb,
85    /// Discovery provenance: true when opened via `GIT_DIR` env or explicit API.
86    ///
87    /// This suppresses safe.bareRepository implicit checks.
88    pub explicit_git_dir: bool,
89    /// When the repo was found by walking from a directory containing `.git` / a gitfile,
90    /// that directory (matches Git's setup trace using `.git` for the default git-dir).
91    pub discovery_root: Option<PathBuf>,
92    /// `GIT_WORK_TREE` was set without `GIT_DIR` and applied after discovery (t1510 #1, #5, …).
93    pub work_tree_from_env: bool,
94    /// `.git` was a gitfile (not a directory) when the repo was discovered.
95    pub discovery_via_gitfile: bool,
96    /// Cached settings derived from config that are stable for the process lifetime.
97    ///
98    /// Cached the first time they are needed; recreated on each `Repository` open. Used to
99    /// avoid re-loading the system/global/local config cascade on every object read in hot
100    /// paths like `Repository::read_replaced`.
101    cached_settings: std::sync::Arc<std::sync::OnceLock<RepoCachedSettings>>,
102}
103
104/// Repository-level settings derived from config that are read on hot paths.
105#[derive(Debug, Clone)]
106struct RepoCachedSettings {
107    /// `core.useReplaceRefs` (default `true`).
108    use_replace_refs: bool,
109    /// Effective `refs/replace/` base path (always slash-terminated).
110    replace_ref_base: String,
111}
112
113impl Repository {
114    fn from_canonical_git_dir(git_dir: PathBuf, work_tree: Option<&Path>) -> Result<Self> {
115        // Check HEAD exists or is a symlink (linked worktrees have a symlink HEAD)
116        let head_path = git_dir.join("HEAD");
117        if !head_path.exists() && !head_path.is_symlink() {
118            return Err(Error::NotARepository(git_dir.display().to_string()));
119        }
120
121        // For git worktrees the `objects/` directory lives in the common git
122        // directory pointed to by the `commondir` file.
123        let objects_dir = if git_dir.join("objects").exists() {
124            git_dir.join("objects")
125        } else if let Some(common_dir) = resolve_common_dir(&git_dir) {
126            common_dir.join("objects")
127        } else {
128            return Err(Error::NotARepository(git_dir.display().to_string()));
129        };
130
131        if !objects_dir.exists() {
132            return Err(Error::NotARepository(git_dir.display().to_string()));
133        }
134
135        let work_tree = match work_tree {
136            Some(p) => {
137                let cwd = env::current_dir().map_err(Error::Io)?;
138                let mut resolved = if p.is_absolute() {
139                    p.to_path_buf()
140                } else {
141                    cwd.join(p)
142                };
143                if resolved.exists() {
144                    resolved = resolved
145                        .canonicalize()
146                        .map_err(|_| Error::PathError(p.display().to_string()))?;
147                }
148                Some(resolved)
149            }
150            None => None,
151        };
152
153        let odb = if let Some(ref wt) = work_tree {
154            Odb::with_work_tree(&objects_dir, wt).with_config_git_dir(git_dir.clone())
155        } else {
156            Odb::new(&objects_dir).with_config_git_dir(git_dir.clone())
157        };
158
159        Ok(Self {
160            git_dir,
161            work_tree,
162            odb,
163            explicit_git_dir: false,
164            discovery_root: None,
165            work_tree_from_env: false,
166            discovery_via_gitfile: false,
167            cached_settings: std::sync::Arc::new(std::sync::OnceLock::new()),
168        })
169    }
170
171    /// Lazily compute and return the cached repo-level settings used on hot paths.
172    ///
173    /// The settings are computed once per `Repository` instance: they read the system / global
174    /// / local config cascade and may stat env vars. Because `Repository` is reopened per
175    /// command invocation, this matches Git's process-lifetime caching of the same values.
176    fn cached_settings(&self) -> &RepoCachedSettings {
177        self.cached_settings.get_or_init(|| {
178            let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
179            let use_replace_refs = cfg
180                .get_bool("core.useReplaceRefs")
181                .and_then(|r| r.ok())
182                .unwrap_or(true);
183            let replace_ref_base = std::env::var("GIT_REPLACE_REF_BASE")
184                .ok()
185                .filter(|s| !s.is_empty())
186                .unwrap_or_else(|| "refs/replace/".to_owned());
187            let replace_ref_base = if replace_ref_base.ends_with('/') {
188                replace_ref_base
189            } else {
190                format!("{replace_ref_base}/")
191            };
192            RepoCachedSettings {
193                use_replace_refs,
194                replace_ref_base,
195            }
196        })
197    }
198
199    /// Open a repository from an explicit git-dir and optional work-tree.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`Error::NotARepository`] if `git_dir` does not look like a
204    /// valid git directory (missing `objects/`, `HEAD`, etc.).
205    pub fn open(git_dir: &Path, work_tree: Option<&Path>) -> Result<Self> {
206        let git_dir = git_dir
207            .canonicalize()
208            .map_err(|_| Error::NotARepository(git_dir.display().to_string()))?;
209
210        validate_repository_format(&git_dir)?;
211
212        Self::from_canonical_git_dir(git_dir, work_tree)
213    }
214
215    /// Like [`Self::open`] but skips [`validate_repository_format`].
216    ///
217    /// Used after repository discovery when the format is unsupported so callers still learn
218    /// the git directory (Git `GIT_DIR_INVALID_FORMAT` still records gitdir for `read_early_config`).
219    pub fn open_skipping_format_validation(
220        git_dir: &Path,
221        work_tree: Option<&Path>,
222    ) -> Result<Self> {
223        let git_dir = git_dir
224            .canonicalize()
225            .map_err(|_| Error::NotARepository(git_dir.display().to_string()))?;
226        Self::from_canonical_git_dir(git_dir, work_tree)
227    }
228
229    /// Discover the repository starting from `start` (defaults to cwd if `None`).
230    ///
231    /// Checks `GIT_DIR` first; if set, uses it directly.  Otherwise walks up
232    /// the directory tree looking for `.git` (regular directory or gitfile).
233    ///
234    /// # Errors
235    ///
236    /// Returns [`Error::NotARepository`] if no repository can be found.
237    pub fn discover(start: Option<&Path>) -> Result<Self> {
238        // GIT_DIR override
239        if let Ok(dir) = env::var("GIT_DIR") {
240            let cwd = env::current_dir()?;
241            let mut git_dir = PathBuf::from(&dir);
242            if git_dir.is_relative() {
243                git_dir = cwd.join(git_dir);
244            }
245            // `GIT_DIR` may name a gitfile (`.git` as a file); resolve like Git's `read_gitfile`.
246            git_dir = resolve_git_dir_env_path(&git_dir)?;
247            let work_tree = env::var("GIT_WORK_TREE").ok().map(|wt| {
248                let p = PathBuf::from(wt);
249                if p.is_absolute() {
250                    p
251                } else {
252                    cwd.join(p)
253                }
254            });
255            if let Some(ref wt_path) = work_tree {
256                if env::var("GIT_WORK_TREE")
257                    .ok()
258                    .is_some_and(|raw| Path::new(&raw).is_absolute())
259                {
260                    validate_git_work_tree_path(wt_path)?;
261                }
262            }
263            if work_tree.is_some() {
264                let mut repo = Self::open(&git_dir, work_tree.as_deref())?;
265                repo.explicit_git_dir = true;
266                repo.discovery_root = None;
267                repo.work_tree_from_env = false;
268                repo.discovery_via_gitfile = false;
269                export_git_prefix_env(&repo);
270                return Ok(repo);
271            }
272            // `GIT_DIR` without `GIT_WORK_TREE`: honour `core.bare` / `core.worktree` like Git.
273            let (is_bare, core_wt) = read_core_bare_and_worktree(&git_dir)?;
274            if is_bare && core_wt.is_some() {
275                warn_core_bare_worktree_conflict(&git_dir);
276            }
277            let resolved_wt = if is_bare {
278                None
279            } else if let Some(raw) = core_wt {
280                Some(resolve_core_worktree_path(&git_dir, &raw)?)
281            } else {
282                // Without `GIT_WORK_TREE`, Git uses the current working directory as the work
283                // tree root (see git-config(1) / `git help repository-layout`), not the parent
284                // of `$GIT_DIR`. This matches upstream tests that run
285                // `GIT_DIR=other/.git git …` from the top-level repo while manipulating paths
286                // under `$PWD` (e.g. t5402-post-merge-hook).
287                Some(cwd.canonicalize().unwrap_or_else(|_| cwd.clone()))
288            };
289            let mut repo = Self::open(&git_dir, resolved_wt.as_deref())?;
290            repo.explicit_git_dir = true;
291            repo.discovery_root = None;
292            repo.work_tree_from_env = false;
293            repo.discovery_via_gitfile = false;
294            export_git_prefix_env(&repo);
295            return Ok(repo);
296        }
297
298        let cwd = env::current_dir()?;
299
300        // If GIT_WORK_TREE is set without GIT_DIR, we still need to honor it
301        // after discovery (path is relative to cwd, like Git).
302        let env_work_tree = env::var("GIT_WORK_TREE").ok().map(|wt| {
303            let p = PathBuf::from(wt);
304            if p.is_absolute() {
305                p
306            } else {
307                cwd.join(p)
308            }
309        });
310        if let Some(ref p) = env_work_tree {
311            if env::var("GIT_WORK_TREE")
312                .ok()
313                .is_some_and(|raw| Path::new(&raw).is_absolute())
314            {
315                validate_git_work_tree_path(p)?;
316            }
317        }
318        let start = start.unwrap_or(&cwd);
319        let start = if start.is_absolute() {
320            start.to_path_buf()
321        } else {
322            cwd.join(start)
323        };
324
325        // Parse GIT_CEILING_DIRECTORIES — mirror Git `setup_git_directory_gently_1` +
326        // `longest_ancestor_length` on the canonical cwd path.
327        // A leading colon disables symlink resolution for both ceiling paths and cwd.
328        let (ceiling_paths, no_resolve_ceilings) = parse_ceiling_directories();
329        let ceiling_dirs: Vec<String> = ceiling_paths
330            .into_iter()
331            .map(|p| path_for_ceiling_compare(&p))
332            .collect();
333
334        let start_canon = start.canonicalize().unwrap_or_else(|_| start.clone());
335        // For ceiling comparison, use non-canonical path when leading colon disables resolution.
336        let ceil_cmp_buf = if no_resolve_ceilings {
337            path_for_ceiling_compare(&start)
338        } else {
339            path_for_ceiling_compare(&start_canon)
340        };
341        let mut dir_buf = path_for_ceiling_compare(&start_canon);
342        let min_offset = offset_1st_component(&dir_buf);
343        let mut ceil_offset: isize = longest_ancestor_length(&ceil_cmp_buf, &ceiling_dirs)
344            .map(|n| n as isize)
345            .unwrap_or(-1);
346        if ceil_offset < 0 {
347            ceil_offset = min_offset as isize - 2;
348        }
349
350        loop {
351            let current = Path::new(&dir_buf);
352            if let Some(DiscoveredAt { mut repo, gitfile }) = try_open_at(current)? {
353                // git/setup.c `setup_git_directory` runs `check_repository_format` on the resolved
354                // git dir and dies on a bad format (e.g. a v1-only `extensions.*` in a
355                // `repositoryformatversion = 0` repo; t0001 #60). Discovery itself opens with
356                // validation skipped so an empty `.git/` is walked past, but a *found* repository
357                // must satisfy the format check.
358                validate_repository_format(&repo.git_dir)?;
359                if let Some(ref wt) = env_work_tree {
360                    repo.work_tree = Some(wt.canonicalize().unwrap_or_else(|_| wt.clone()));
361                    repo.work_tree_from_env = true;
362                } else {
363                    repo.work_tree_from_env = false;
364                    // Linked worktree (gitfile → admin dir with `commondir`): `Repository::open`
365                    // already set `work_tree` to the directory that contains the `.git` file.
366                    // Do not replace it with `core.worktree` from the common config — it may be
367                    // stale (t1501 multi-worktree) or point at another linked checkout.
368                    let linked_gitfile =
369                        repo.discovery_via_gitfile && resolve_common_dir(&repo.git_dir).is_some();
370                    if !linked_gitfile {
371                        let (is_bare, core_wt) = read_core_bare_and_worktree(&repo.git_dir)?;
372                        if is_bare {
373                            repo.work_tree = None;
374                        } else if let Some(raw) = core_wt {
375                            repo.work_tree = Some(resolve_core_worktree_path(&repo.git_dir, &raw)?);
376                        }
377                    }
378                }
379                let assume_different = env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
380                    .ok()
381                    .map(|v| {
382                        let lower = v.to_ascii_lowercase();
383                        v == "1" || lower == "true" || lower == "yes" || lower == "on"
384                    })
385                    .unwrap_or(false);
386                if assume_different {
387                    repo.enforce_safe_directory()?;
388                } else {
389                    #[cfg(unix)]
390                    ensure_valid_ownership(
391                        gitfile.as_deref(),
392                        repo.work_tree.as_deref(),
393                        &repo.git_dir,
394                    )?;
395                }
396                export_git_prefix_env(&repo);
397                return Ok(repo);
398            }
399
400            let mut offset: isize = dir_buf.len() as isize;
401            if offset <= min_offset as isize {
402                break;
403            }
404            loop {
405                offset -= 1;
406                if offset <= ceil_offset {
407                    break;
408                }
409                if dir_buf
410                    .as_bytes()
411                    .get(offset as usize)
412                    .is_some_and(|b| *b == b'/')
413                {
414                    break;
415                }
416            }
417            if offset <= ceil_offset {
418                break;
419            }
420            let off_u = offset as usize;
421            let new_len = if off_u > min_offset {
422                off_u
423            } else {
424                min_offset
425            };
426            dir_buf.truncate(new_len);
427        }
428
429        Err(Error::NotARepository(start.display().to_string()))
430    }
431
432    /// Current directory to use for pathspec / cwd-prefix logic.
433    ///
434    /// When `GIT_WORK_TREE` points at a directory that does not contain the process cwd
435    /// (alternate work tree + index from the main repo directory), Git treats pathspecs as
436    /// relative to the work tree root — use that root as the effective cwd.
437    #[must_use]
438    pub fn effective_pathspec_cwd(&self) -> PathBuf {
439        let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
440        let Some(wt) = self.work_tree.as_ref() else {
441            return cwd;
442        };
443        let inside_lexical = cwd.strip_prefix(wt).is_ok();
444        let inside_canon = cwd
445            .canonicalize()
446            .ok()
447            .zip(wt.canonicalize().ok())
448            .is_some_and(|(c, w)| c.starts_with(&w));
449        if inside_lexical || inside_canon {
450            cwd
451        } else {
452            wt.clone()
453        }
454    }
455
456    /// Path to the index file.
457    #[must_use]
458    pub fn index_path(&self) -> PathBuf {
459        self.git_dir.join("index")
460    }
461
462    /// Resolve which index file to use, honouring `GIT_INDEX_FILE` like Git plumbing.
463    ///
464    /// Relative paths are resolved from the process current directory.
465    pub fn index_path_for_env(&self) -> Result<PathBuf> {
466        if let Ok(raw) = env::var("GIT_INDEX_FILE") {
467            if !raw.is_empty() {
468                let p = PathBuf::from(raw);
469                return Ok(if p.is_absolute() {
470                    p
471                } else {
472                    env::current_dir().map_err(Error::Io)?.join(p)
473                });
474            }
475        }
476        Ok(self.index_path())
477    }
478
479    /// Load the index, expanding sparse-directory placeholders from the object database.
480    ///
481    /// Commands that operate on individual paths should use this instead of [`Index::load`].
482    pub fn load_index(&self) -> Result<Index> {
483        let path = self.index_path_for_env()?;
484        self.load_index_at(&path)
485    }
486
487    /// Like [`Repository::load_index`], but reads from an explicit index file path
488    /// (e.g. `GIT_INDEX_FILE` or a worktree-specific index).
489    pub fn load_index_at(&self, path: &std::path::Path) -> Result<Index> {
490        let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
491        if let Some(res) = cfg.get_bool("index.sparse") {
492            res.map_err(Error::ConfigError)?;
493        }
494        let mut idx = Index::load_expand_sparse_optional(path, &self.odb)?;
495        crate::split_index::resolve_split_index_if_needed(&mut idx, &self.git_dir, path)?;
496        if let Some(ref wt) = self.work_tree {
497            crate::sparse_checkout::clear_skip_worktree_from_present_files(
498                &self.git_dir,
499                wt,
500                &mut idx,
501            );
502        }
503        Ok(idx)
504    }
505
506    /// Write the index to the default path after optionally collapsing skip-worktree
507    /// subtrees into sparse-directory placeholders (when sparse index is enabled).
508    pub fn write_index(&self, index: &mut Index) -> Result<()> {
509        self.write_index_at(&self.index_path(), index)
510    }
511
512    /// Like [`Repository::write_index`], but writes to an explicit index file path.
513    pub fn write_index_at(&self, path: &std::path::Path, index: &mut Index) -> Result<()> {
514        self.write_index_at_split(path, index, WriteSplitIndexRequest::default())
515    }
516
517    /// Write the index to `path`, optionally emitting a split index (shared base + `link` extension).
518    pub fn write_index_at_split(
519        &self,
520        path: &std::path::Path,
521        index: &mut Index,
522        split: WriteSplitIndexRequest,
523    ) -> Result<()> {
524        self.finalize_sparse_index_if_needed(index)?;
525        let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
526        let skip_hash = crate::index::index_skip_hash_for_write(Some(&cfg));
527        write_index_file_split(path, &self.git_dir, index, &cfg, split, skip_hash)?;
528        // Git `write_locked_index`: `post-index-change` after a successful index write (t1800).
529        // Grit does not yet track `updated_workdir` / `updated_skipworktree`; pass `0` `0`.
530        let _ = run_hook(self, "post-index-change", &["0", "0"], None);
531        Ok(())
532    }
533
534    fn finalize_sparse_index_if_needed(&self, index: &mut Index) -> Result<()> {
535        let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
536        let sparse_enabled = cfg
537            .get("core.sparseCheckout")
538            .map(|v| v == "true")
539            .unwrap_or(false);
540        if !sparse_enabled {
541            index.sparse_directories = false;
542            return Ok(());
543        }
544        let cone_cfg = cfg
545            .get("core.sparseCheckoutCone")
546            .and_then(|v| v.parse::<bool>().ok())
547            .unwrap_or(true);
548        let sparse_ix = cfg
549            .get("index.sparse")
550            .map(|v| v == "true")
551            .unwrap_or(false);
552        let patterns = read_sparse_checkout_patterns(&self.git_dir);
553        let cone = effective_cone_mode_for_sparse_file(cone_cfg, &patterns);
554        let head = resolve_head(&self.git_dir)?;
555        let tree_oid = if let Some(oid) = head.oid() {
556            let obj = self.odb.read(oid)?;
557            let commit = parse_commit(&obj.data)?;
558            Some(commit.tree)
559        } else {
560            None
561        };
562        if let Some(t) = tree_oid {
563            index.try_collapse_sparse_directories(&self.odb, &t, &patterns, cone, sparse_ix)?;
564        } else {
565            index.sparse_directories = false;
566        }
567        Ok(())
568    }
569
570    /// Path to the `refs/` directory.
571    #[must_use]
572    pub fn refs_dir(&self) -> PathBuf {
573        self.git_dir.join("refs")
574    }
575
576    /// Path to `HEAD`.
577    #[must_use]
578    pub fn head_path(&self) -> PathBuf {
579        self.git_dir.join("HEAD")
580    }
581
582    /// Relative path from the work tree root to the process current directory, `/`-separated.
583    ///
584    /// Used for `:(top)` / `:/` pathspec Bloom lookups. Returns `None` for bare repositories or
585    /// when paths cannot be resolved; callers should treat `None` like an empty prefix.
586    #[must_use]
587    pub fn bloom_pathspec_cwd(&self) -> Option<String> {
588        let wt = self.work_tree.as_ref()?;
589        let cwd = env::current_dir().ok()?;
590        let wt = wt.canonicalize().ok()?;
591        let cwd = cwd.canonicalize().ok()?;
592        let rel = cwd.strip_prefix(&wt).ok()?;
593        let s = rel.to_string_lossy().replace('\\', "/");
594        let s = s.trim_start_matches('/').to_string();
595        Some(s)
596    }
597
598    /// Whether this is a bare repository (no working tree).
599    #[must_use]
600    pub fn is_bare(&self) -> bool {
601        if let Ok(cfg) = ConfigSet::load(Some(&self.git_dir), true) {
602            if let Some(Ok(bare)) = cfg.get_bool("core.bare") {
603                return bare;
604            }
605        }
606        self.work_tree.is_none()
607    }
608
609    /// Read an object, transparently following replace refs.
610    ///
611    /// If `refs/replace/<hex>` exists for the requested OID and
612    /// `GIT_NO_REPLACE_OBJECTS` is **not** set, this reads the
613    /// replacement object instead.  Otherwise it behaves identically
614    /// to `self.odb.read(oid)`.
615    pub fn read_replaced(&self, oid: &crate::objects::ObjectId) -> Result<crate::objects::Object> {
616        if std::env::var_os("GIT_NO_REPLACE_OBJECTS").is_some() {
617            return self.odb.read(oid);
618        }
619        let settings = self.cached_settings();
620        if !settings.use_replace_refs {
621            return self.odb.read(oid);
622        }
623        let replace_ref =
624            self.git_dir
625                .join(format!("{}{}", settings.replace_ref_base, oid.to_hex()));
626        if replace_ref.is_file() {
627            if let Ok(content) = std::fs::read_to_string(&replace_ref) {
628                let hex = content.trim();
629                if let Ok(replacement_oid) = hex.parse::<crate::objects::ObjectId>() {
630                    if let Ok(obj) = self.odb.read(&replacement_oid) {
631                        return Ok(obj);
632                    }
633                }
634            }
635        }
636        self.odb.read(oid)
637    }
638}
639
640/// If `GIT_TRACE_SETUP` is an absolute path, append `setup:` lines (Git test format).
641///
642/// Upstream tests grep `^setup: ` from the trace file; they do not use the timestamped
643/// `trace.c:` prefix that full Git tracing adds.
644pub fn trace_repo_setup_if_requested(repo: &Repository) -> std::io::Result<()> {
645    let Ok(path) = env::var("GIT_TRACE_SETUP") else {
646        return Ok(());
647    };
648    if path.is_empty() || path == "0" {
649        return Ok(());
650    }
651    let trace_path = Path::new(&path);
652    if !trace_path.is_absolute() {
653        return Ok(());
654    }
655
656    let actual_cwd = env::current_dir()?;
657    let actual_cwd = actual_cwd
658        .canonicalize()
659        .unwrap_or_else(|_| actual_cwd.clone());
660
661    // After setup, Git's traced `cwd` is the worktree root when the process cwd started inside
662    // the worktree, but stays at the real cwd when outside (t1510 nephew cases).
663    let (trace_cwd, prefix) = if let Some(ref wt) = repo.work_tree {
664        let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.clone());
665        if actual_cwd.starts_with(&wt_canon) {
666            let rel = actual_cwd
667                .strip_prefix(&wt_canon)
668                .map(|p| p.to_path_buf())
669                .unwrap_or_default();
670            let prefix = if rel.as_os_str().is_empty() {
671                "(null)".to_owned()
672            } else {
673                let mut s = rel.to_string_lossy().replace('\\', "/");
674                if !s.ends_with('/') {
675                    s.push('/');
676                }
677                s
678            };
679            (wt_canon, prefix)
680        } else {
681            (actual_cwd.clone(), "(null)".to_owned())
682        }
683    } else {
684        (actual_cwd.clone(), "(null)".to_owned())
685    };
686
687    let git_dir_display =
688        display_git_dir_for_setup_trace(repo, &trace_cwd, &actual_cwd, prefix.as_str());
689    let common_display = display_common_dir_for_setup_trace(
690        repo,
691        &trace_cwd,
692        &actual_cwd,
693        prefix.as_str(),
694        &git_dir_display,
695    );
696    let worktree_display = repo
697        .work_tree
698        .as_ref()
699        .map(|p| {
700            p.canonicalize()
701                .unwrap_or_else(|_| lexical_normalize_path(p))
702                .display()
703                .to_string()
704        })
705        .unwrap_or_else(|| "(null)".to_owned());
706
707    let mut f = OpenOptions::new()
708        .create(true)
709        .append(true)
710        .open(trace_path)?;
711    writeln!(f, "setup: git_dir: {git_dir_display}")?;
712    writeln!(f, "setup: git_common_dir: {common_display}")?;
713    writeln!(f, "setup: worktree: {worktree_display}")?;
714    writeln!(f, "setup: cwd: {}", trace_cwd.display())?;
715    writeln!(f, "setup: prefix: {prefix}")?;
716    Ok(())
717}
718
719/// Collapse `.` / `..` in a path for display when `canonicalize()` fails (e.g. non-existent `..` segments).
720fn lexical_normalize_path(path: &Path) -> PathBuf {
721    let mut out = PathBuf::new();
722    let mut absolute = false;
723    for c in path.components() {
724        match c {
725            Component::Prefix(p) => {
726                out.push(p.as_os_str());
727            }
728            Component::RootDir => {
729                absolute = true;
730                out.push(c.as_os_str());
731            }
732            Component::CurDir => {}
733            Component::ParentDir => {
734                if absolute {
735                    let _ = out.pop();
736                } else if !out.pop() {
737                    out.push("..");
738                }
739            }
740            Component::Normal(s) => out.push(s),
741        }
742    }
743    if out.as_os_str().is_empty() {
744        PathBuf::from(".")
745    } else {
746        out
747    }
748}
749
750/// Path from `base` to `target` using `..` segments when needed (matches Git setup traces).
751fn path_relative_to(target: &Path, base: &Path) -> Option<PathBuf> {
752    let t = target.canonicalize().ok()?;
753    let b = base.canonicalize().ok()?;
754    let tc: Vec<_> = t.components().collect();
755    let bc: Vec<_> = b.components().collect();
756    let mut i = 0usize;
757    while i < tc.len() && i < bc.len() && tc[i] == bc[i] {
758        i += 1;
759    }
760    let up = bc.len().saturating_sub(i);
761    let mut out = PathBuf::new();
762    for _ in 0..up {
763        out.push("..");
764    }
765    for comp in &tc[i..] {
766        out.push(comp.as_os_str());
767    }
768    Some(out)
769}
770
771fn rel_path_for_setup_trace(target: &Path, trace_cwd: &Path) -> String {
772    let t = target
773        .canonicalize()
774        .unwrap_or_else(|_| target.to_path_buf());
775    let tc = trace_cwd
776        .canonicalize()
777        .unwrap_or_else(|_| trace_cwd.to_path_buf());
778    if let Some(rel) = path_relative_to(&t, &tc) {
779        let s = rel.to_string_lossy().replace('\\', "/");
780        return if s.is_empty() || s == "." {
781            ".".to_owned()
782        } else {
783            s
784        };
785    }
786    t.display().to_string()
787}
788
789fn trace_cwd_strictly_inside_git_parent(trace_cwd: &Path, git_dir: &Path) -> bool {
790    let tc = trace_cwd
791        .canonicalize()
792        .unwrap_or_else(|_| trace_cwd.to_path_buf());
793    let gd = git_dir
794        .canonicalize()
795        .unwrap_or_else(|_| git_dir.to_path_buf());
796    let Some(parent) = gd.parent() else {
797        return false;
798    };
799    let parent = parent.to_path_buf();
800    if tc == parent {
801        return false;
802    }
803    tc.starts_with(&parent) && tc != parent
804}
805
806fn display_git_dir_for_setup_trace(
807    repo: &Repository,
808    trace_cwd: &Path,
809    actual_cwd: &Path,
810    setup_prefix: &str,
811) -> String {
812    let gd = repo
813        .git_dir
814        .canonicalize()
815        .unwrap_or_else(|_| repo.git_dir.clone());
816    let tc = trace_cwd
817        .canonicalize()
818        .unwrap_or_else(|_| trace_cwd.to_path_buf());
819    let ac = actual_cwd
820        .canonicalize()
821        .unwrap_or_else(|_| actual_cwd.to_path_buf());
822
823    // Bare repo discovered without `GIT_DIR`: cwd inside the git directory (t1510 #16).
824    // Trace uses `.` at the git-dir root and the absolute git-dir path from subdirectories.
825    if repo.work_tree.is_none() && !repo.explicit_git_dir {
826        if ac == gd {
827            return ".".to_owned();
828        }
829        if ac.starts_with(&gd) && ac != gd {
830            return gd.display().to_string();
831        }
832    }
833
834    // Non-bare repo with `core.worktree` while cwd is inside the git-dir (t1510 #20a).
835    if !repo.explicit_git_dir {
836        if let Some(wt) = &repo.work_tree {
837            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
838            if ac.starts_with(&gd) && ac != wt {
839                return gd.display().to_string();
840            }
841        }
842    }
843
844    // `GIT_DIR` set: Git's `set_git_dir(gitdirenv, make_realpath)` keeps a relative
845    // `gitdirenv` only when cwd is at the worktree root or outside the worktree; from a
846    // subdirectory it realpath()s to an absolute path (see `setup.c` / t1510).
847    if repo.explicit_git_dir {
848        if repo.work_tree.is_none() {
849            if let Ok(raw) = env::var("GIT_DIR") {
850                let p = Path::new(raw.trim());
851                if p.is_absolute() {
852                    return gd.display().to_string();
853                }
854                let joined = ac.join(p);
855                if joined.is_file() {
856                    return gd.display().to_string();
857                }
858                if let Some(rel) = path_relative_to(&gd, &tc) {
859                    let s = rel.to_string_lossy().replace('\\', "/");
860                    return if s.is_empty() || s == "." {
861                        ".".to_owned()
862                    } else {
863                        s
864                    };
865                }
866            }
867            return gd.display().to_string();
868        }
869        if let Some(wt) = &repo.work_tree {
870            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
871            let strictly_inside_wt = ac.starts_with(&wt) && ac != wt;
872            if strictly_inside_wt {
873                return gd.display().to_string();
874            }
875            if let Ok(raw) = env::var("GIT_DIR") {
876                let p = Path::new(raw.trim());
877                if p.is_relative() {
878                    let joined = ac.join(p);
879                    if joined.is_file() {
880                        // `GIT_DIR` points at a gitfile; trace shows the resolved git dir.
881                        return gd.display().to_string();
882                    }
883                    if let Some(rel) = path_relative_to(&gd, &tc) {
884                        let s = rel.to_string_lossy().replace('\\', "/");
885                        return if s.is_empty() || s == "." {
886                            ".".to_owned()
887                        } else {
888                            s
889                        };
890                    }
891                }
892                return gd.display().to_string();
893            }
894        }
895        if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
896            return rel_path_for_setup_trace(&gd, trace_cwd);
897        }
898        return gd.display().to_string();
899    }
900
901    let work_relocated = match (&repo.discovery_root, &repo.work_tree) {
902        (Some(root), Some(wt)) if !repo.work_tree_from_env => {
903            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
904            let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
905            r != w
906        }
907        _ => false,
908    };
909
910    if repo.work_tree_from_env {
911        if !repo.discovery_via_gitfile {
912            if setup_prefix == "(null)" {
913                if let (Some(root), Some(wt)) = (&repo.discovery_root, &repo.work_tree) {
914                    let r = root.canonicalize().unwrap_or_else(|_| root.clone());
915                    let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
916                    if r == w {
917                        let dot_git = r.join(".git");
918                        let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
919                        if gd == dot_git {
920                            return ".git".to_owned();
921                        }
922                    }
923                }
924            }
925            if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
926                return rel_path_for_setup_trace(&gd, trace_cwd);
927            }
928        }
929        return gd.display().to_string();
930    }
931
932    if work_relocated {
933        if let Some(wt) = &repo.work_tree {
934            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
935            if ac == wt {
936                return gd.display().to_string();
937            }
938            let inside_wt = ac.starts_with(&wt) && ac != wt;
939            if inside_wt {
940                if let Some(rel) = path_relative_to(&gd, &ac) {
941                    let s = rel.to_string_lossy().replace('\\', "/");
942                    return if s.is_empty() || s == "." {
943                        ".".to_owned()
944                    } else {
945                        s
946                    };
947                }
948            }
949        }
950    }
951    if repo.work_tree.is_some() {
952        if let Some(root) = &repo.discovery_root {
953            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
954            let dot_git = r.join(".git");
955            let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
956            if gd == dot_git {
957                return ".git".to_owned();
958            }
959        } else if let Some(wt) = &repo.work_tree {
960            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
961            let dot_git = wt.join(".git");
962            let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
963            if gd == dot_git {
964                return ".git".to_owned();
965            }
966        }
967    }
968
969    if repo.discovery_via_gitfile && !repo.explicit_git_dir {
970        return gd.display().to_string();
971    }
972
973    // Bare repo whose git-dir is `parent/.git`: at `parent` the trace shows `.git`; from a
974    // subdirectory of `parent` that is still outside `.git`, Git uses the absolute git-dir (t1510
975    // #16c sub/ case — not `../.git`).
976    if repo.work_tree.is_none() && !repo.explicit_git_dir {
977        if let Some(gp) = gd.parent() {
978            let gp = gp.canonicalize().unwrap_or_else(|_| gp.to_path_buf());
979            let gdc = gd.canonicalize().unwrap_or_else(|_| gd.clone());
980            if tc.starts_with(&gp) && tc != gp && !tc.starts_with(&gdc) {
981                return gdc.display().to_string();
982            }
983            if tc == gp {
984                return rel_path_for_setup_trace(&gd, trace_cwd);
985            }
986        }
987    }
988
989    if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
990        rel_path_for_setup_trace(&gd, trace_cwd)
991    } else {
992        gd.display().to_string()
993    }
994}
995
996fn display_common_dir_for_setup_trace(
997    repo: &Repository,
998    trace_cwd: &Path,
999    actual_cwd: &Path,
1000    _setup_prefix: &str,
1001    git_dir_display: &str,
1002) -> String {
1003    let gd = repo
1004        .git_dir
1005        .canonicalize()
1006        .unwrap_or_else(|_| repo.git_dir.clone());
1007    let Some(common) = resolve_common_dir(&gd) else {
1008        return git_dir_display.to_owned();
1009    };
1010    let common = common.canonicalize().unwrap_or(common);
1011    if common == gd {
1012        return git_dir_display.to_owned();
1013    }
1014
1015    let ac = actual_cwd
1016        .canonicalize()
1017        .unwrap_or_else(|_| actual_cwd.to_path_buf());
1018    if repo.work_tree.is_none() && !repo.explicit_git_dir {
1019        if ac == common {
1020            return ".".to_owned();
1021        }
1022        if ac.starts_with(&common) && ac != common {
1023            return common.display().to_string();
1024        }
1025    }
1026
1027    let work_relocated = match (&repo.discovery_root, &repo.work_tree) {
1028        (Some(root), Some(wt)) if !repo.work_tree_from_env => {
1029            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
1030            let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1031            r != w
1032        }
1033        _ => false,
1034    };
1035    if work_relocated {
1036        if let Some(wt) = &repo.work_tree {
1037            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1038            if ac == wt {
1039                return common.display().to_string();
1040            }
1041            let inside_wt = ac.starts_with(&wt) && ac != wt;
1042            if inside_wt {
1043                if let Some(rel) = path_relative_to(&common, &ac) {
1044                    let s = rel.to_string_lossy().replace('\\', "/");
1045                    return if s.is_empty() || s == "." {
1046                        ".".to_owned()
1047                    } else {
1048                        s
1049                    };
1050                }
1051            }
1052        }
1053    }
1054
1055    if repo.discovery_via_gitfile && !repo.explicit_git_dir {
1056        return common.display().to_string();
1057    }
1058
1059    if repo.work_tree.is_none() && !repo.explicit_git_dir {
1060        let tc = trace_cwd
1061            .canonicalize()
1062            .unwrap_or_else(|_| trace_cwd.to_path_buf());
1063        if let Some(cp) = common.parent() {
1064            let cp = cp.canonicalize().unwrap_or_else(|_| cp.to_path_buf());
1065            let comc = common.canonicalize().unwrap_or_else(|_| common.clone());
1066            if tc.starts_with(&cp) && tc != cp && !tc.starts_with(&comc) {
1067                return comc.display().to_string();
1068            }
1069            if tc == cp {
1070                return rel_path_for_setup_trace(&common, trace_cwd);
1071            }
1072        }
1073    }
1074
1075    if trace_cwd_strictly_inside_git_parent(trace_cwd, &common) {
1076        rel_path_for_setup_trace(&common, trace_cwd)
1077    } else {
1078        common.display().to_string()
1079    }
1080}
1081
1082/// Resolve the common git directory for linked worktrees.
1083fn resolve_common_dir(git_dir: &Path) -> Option<PathBuf> {
1084    let common_raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
1085    let common_rel = common_raw.trim();
1086    if common_rel.is_empty() {
1087        return None;
1088    }
1089    let common_dir = if Path::new(common_rel).is_absolute() {
1090        PathBuf::from(common_rel)
1091    } else {
1092        git_dir.join(common_rel)
1093    };
1094    Some(common_dir.canonicalize().unwrap_or(common_dir))
1095}
1096
1097/// Directory holding `config` for early-config reads (`commondir` when present).
1098#[must_use]
1099pub fn common_git_dir_for_config(git_dir: &Path) -> PathBuf {
1100    resolve_common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf())
1101}
1102
1103/// True when `extensions.worktreeConfig` is enabled in the common `config`.
1104pub fn worktree_config_enabled(common_dir: &Path) -> bool {
1105    let path = common_dir.join("config");
1106    let Ok(content) = fs::read_to_string(&path) else {
1107        return false;
1108    };
1109    let mut in_extensions = false;
1110    for raw_line in content.lines() {
1111        let mut line = raw_line.trim();
1112        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1113            continue;
1114        }
1115        if line.starts_with('[') {
1116            let Some(end_idx) = line.find(']') else {
1117                continue;
1118            };
1119            let section = line[1..end_idx].trim();
1120            let section_name = section
1121                .split_whitespace()
1122                .next()
1123                .unwrap_or_default()
1124                .to_ascii_lowercase();
1125            in_extensions = section_name == "extensions";
1126            let remainder = line[end_idx + 1..].trim();
1127            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1128                continue;
1129            }
1130            line = remainder;
1131        }
1132        if in_extensions {
1133            let Some((key, value)) = line.split_once('=') else {
1134                continue;
1135            };
1136            if key.trim().eq_ignore_ascii_case("worktreeconfig") {
1137                let v = value.trim();
1138                return v.eq_ignore_ascii_case("true")
1139                    || v.eq_ignore_ascii_case("yes")
1140                    || v.eq_ignore_ascii_case("on")
1141                    || v == "1";
1142            }
1143        }
1144    }
1145    false
1146}
1147
1148fn open_or_create_config_file(path: &Path, scope: ConfigScope) -> Result<ConfigFile> {
1149    match ConfigFile::from_path(path, scope)? {
1150        Some(f) => Ok(f),
1151        None => {
1152            if let Some(parent) = path.parent() {
1153                fs::create_dir_all(parent).map_err(Error::Io)?;
1154            }
1155            ConfigFile::parse(path, "", scope)
1156        }
1157    }
1158}
1159
1160fn config_file_bool_true(cfg: &ConfigFile, key: &str) -> bool {
1161    cfg.get(key).is_some_and(|v| {
1162        matches!(
1163            v.trim().to_ascii_lowercase().as_str(),
1164            "true" | "yes" | "on" | "1"
1165        )
1166    })
1167}
1168
1169/// Enable per-worktree configuration (`extensions.worktreeConfig`) and create
1170/// `config.worktree`, matching Git's `init_worktree_config` in `worktree.c`.
1171///
1172/// When `core.bare` is true or `core.worktree` is set in the common config,
1173/// those keys are moved into `config.worktree` so linked worktrees keep working.
1174///
1175/// # Errors
1176///
1177/// Returns [`Error::Io`] or [`Error::ConfigError`] if config files cannot be read or written.
1178pub fn init_worktree_config(git_dir: &Path) -> Result<()> {
1179    let common_dir = common_git_dir_for_config(git_dir);
1180    let common_config_path = common_dir.join("config");
1181    let worktree_config_path = git_dir.join("config.worktree");
1182
1183    if worktree_config_enabled(&common_dir) {
1184        if !worktree_config_path.exists() {
1185            if let Some(parent) = worktree_config_path.parent() {
1186                fs::create_dir_all(parent).map_err(Error::Io)?;
1187            }
1188            fs::write(&worktree_config_path, "").map_err(Error::Io)?;
1189        }
1190        return Ok(());
1191    }
1192
1193    let mut common_cfg = open_or_create_config_file(&common_config_path, ConfigScope::Local)?;
1194    common_cfg.set("extensions.worktreeConfig", "true")?;
1195
1196    let mut wt_cfg = open_or_create_config_file(&worktree_config_path, ConfigScope::Worktree)?;
1197
1198    if config_file_bool_true(&common_cfg, "core.bare") {
1199        wt_cfg.set("core.bare", "true")?;
1200        common_cfg.unset("core.bare")?;
1201    }
1202    if let Some(worktree) = common_cfg.get("core.worktree") {
1203        wt_cfg.set("core.worktree", &worktree)?;
1204        common_cfg.unset("core.worktree")?;
1205    }
1206
1207    common_cfg.write()?;
1208    wt_cfg.write()?;
1209    Ok(())
1210}
1211
1212/// If the common `config` declares a repository format newer than Git's
1213/// `GIT_REPO_VERSION_READ`, return the human message Git prints for
1214/// `discover_git_directory_reason` / t1309.
1215pub fn early_config_ignore_repo_reason(common_dir: &Path) -> Option<String> {
1216    const GIT_REPO_VERSION_READ: u32 = 1;
1217    let path = common_dir.join("config");
1218    let content = fs::read_to_string(&path).ok()?;
1219    let mut version = 0u32;
1220    let mut in_core = false;
1221    for raw_line in content.lines() {
1222        let mut line = raw_line.trim();
1223        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1224            continue;
1225        }
1226        if line.starts_with('[') {
1227            let Some(end_idx) = line.find(']') else {
1228                continue;
1229            };
1230            let section = line[1..end_idx].trim();
1231            let section_name = section
1232                .split_whitespace()
1233                .next()
1234                .unwrap_or_default()
1235                .to_ascii_lowercase();
1236            in_core = section_name == "core";
1237            let remainder = line[end_idx + 1..].trim();
1238            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1239                continue;
1240            }
1241            line = remainder;
1242        }
1243        if in_core {
1244            if let Some((key, value)) = line.split_once('=') {
1245                if key.trim().eq_ignore_ascii_case("repositoryformatversion") {
1246                    if let Ok(v) = value.trim().parse::<u32>() {
1247                        version = v;
1248                    }
1249                }
1250            }
1251        }
1252    }
1253    if version > GIT_REPO_VERSION_READ {
1254        Some(format!(
1255            "Expected git repo version <= {GIT_REPO_VERSION_READ}, found {version}"
1256        ))
1257    } else {
1258        None
1259    }
1260}
1261
1262fn path_for_ceiling_compare(path: &Path) -> String {
1263    let path = path.to_string_lossy();
1264    #[cfg(windows)]
1265    {
1266        path.replace('\\', "/")
1267    }
1268    #[cfg(not(windows))]
1269    {
1270        path.into_owned()
1271    }
1272}
1273
1274fn offset_1st_component(path: &str) -> usize {
1275    if path.starts_with('/') {
1276        1
1277    } else {
1278        0
1279    }
1280}
1281
1282/// Git `longest_ancestor_length`: longest strict ancestor prefix among ceilings.
1283fn longest_ancestor_length(path: &str, ceilings: &[String]) -> Option<usize> {
1284    if path == "/" {
1285        return None;
1286    }
1287    let mut max_len: Option<usize> = None;
1288    for ceil in ceilings {
1289        let mut len = ceil.len();
1290        while len > 0 && ceil.as_bytes().get(len - 1) == Some(&b'/') {
1291            len -= 1;
1292        }
1293        if len == 0 {
1294            continue;
1295        }
1296        if path.len() <= len + 1 {
1297            continue;
1298        }
1299        if !path.starts_with(&ceil[..len]) {
1300            continue;
1301        }
1302        if path.as_bytes().get(len) != Some(&b'/') {
1303            continue;
1304        }
1305        if path.as_bytes().get(len + 1).is_none() {
1306            continue;
1307        }
1308        max_len = Some(max_len.map_or(len, |m| m.max(len)));
1309    }
1310    max_len
1311}
1312
1313/// Determine the config file path for a repository or linked worktree.
1314fn repository_config_path(git_dir: &Path) -> Option<PathBuf> {
1315    let local = git_dir.join("config");
1316    if local.exists() {
1317        return Some(local);
1318    }
1319    let common = resolve_common_dir(git_dir)?;
1320    let shared = common.join("config");
1321    if shared.exists() {
1322        Some(shared)
1323    } else {
1324        None
1325    }
1326}
1327
1328/// Validate core repository format/version compatibility.
1329///
1330/// Supports repository format versions 0 and 1, with extension handling that
1331/// matches Git's compatibility expectations in upstream repo-version tests.
1332/// Public wrapper for validate_repository_format.
1333pub fn validate_repo_format(git_dir: &Path) -> Result<()> {
1334    validate_repository_format(git_dir)
1335}
1336
1337fn validate_repository_format(git_dir: &Path) -> Result<()> {
1338    let Some(config_path) = repository_config_path(git_dir) else {
1339        return Ok(());
1340    };
1341
1342    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
1343    let mut in_core = false;
1344    let mut in_extensions = false;
1345    let mut repo_version = 0u32;
1346    let mut extensions = BTreeSet::new();
1347    let mut ref_storage: Option<String> = None;
1348
1349    for raw_line in content.lines() {
1350        let mut line = raw_line.trim();
1351        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1352            continue;
1353        }
1354
1355        if line.starts_with('[') {
1356            let Some(end_idx) = line.find(']') else {
1357                return Err(Error::ConfigError(format!(
1358                    "invalid config in {}",
1359                    config_path.display()
1360                )));
1361            };
1362
1363            let section = line[1..end_idx].trim();
1364            let section_name = section
1365                .split_whitespace()
1366                .next()
1367                .unwrap_or_default()
1368                .to_ascii_lowercase();
1369            in_core = section_name == "core";
1370            in_extensions = section_name == "extensions";
1371
1372            let remainder = line[end_idx + 1..].trim();
1373            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1374                continue;
1375            }
1376            line = remainder;
1377        }
1378
1379        if in_core {
1380            if let Some((key, value)) = line.split_once('=') {
1381                if key.trim().eq_ignore_ascii_case("repositoryformatversion") {
1382                    // Match Git's `read_repository_format`: bad values are ignored (version stays 0).
1383                    if let Ok(v) = value.trim().parse::<u32>() {
1384                        repo_version = v;
1385                    }
1386                }
1387            }
1388        }
1389
1390        if in_extensions {
1391            let (key, value) = if let Some((key, value)) = line.split_once('=') {
1392                (key.trim(), Some(value.trim()))
1393            } else {
1394                (line, None)
1395            };
1396            if key.eq_ignore_ascii_case("refstorage") {
1397                ref_storage = value.map(str::to_owned);
1398            }
1399            let key = if let Some((key, _)) = line.split_once('=') {
1400                key.trim()
1401            } else {
1402                line
1403            };
1404            if !key.is_empty() {
1405                extensions.insert(key.to_ascii_lowercase());
1406            }
1407        }
1408    }
1409
1410    if repo_version > 1 {
1411        return Err(Error::UnsupportedRepositoryFormatVersion(repo_version));
1412    }
1413
1414    if let Some(raw) = ref_storage.as_deref() {
1415        let lower = raw.to_ascii_lowercase();
1416        let name = lower
1417            .split_once(':')
1418            .map(|(prefix, _)| prefix)
1419            .unwrap_or(lower.as_str());
1420        if !matches!(name, "files" | "reftable") {
1421            return Err(Error::Message(format!(
1422                "error: invalid value for 'extensions.refstorage': '{raw}'"
1423            )));
1424        }
1425    }
1426
1427    // Mirror git/setup.c `check_repo_format` / `verify_repository_format`. Extensions split into:
1428    //   * v0-compatible (`handle_extension_v0`): respected even in a v0 repository.
1429    //   * v1-only (`handle_extension`): legal only when `core.repositoryformatversion >= 1`.
1430    // A v0 repository that declares any v1-only extension is rejected (t0001 #60, #62); an
1431    // unknown extension is rejected only in a v1 repository.
1432    let mut v1_only_found: Vec<String> = Vec::new();
1433    let mut unknown_found: Vec<String> = Vec::new();
1434    for extension in extensions {
1435        match extension.as_str() {
1436            // v0-compatible extensions — always allowed.
1437            "noop" | "preciousobjects" | "partialclone" | "worktreeconfig" => {}
1438            // v1-only extensions — only valid with repository format version >= 1.
1439            "noop-v1"
1440            | "objectformat"
1441            | "compatobjectformat"
1442            | "refstorage"
1443            | "relativeworktrees"
1444            | "submodulepathconfig" => {
1445                if repo_version == 0 {
1446                    v1_only_found.push(extension);
1447                }
1448            }
1449            // Unknown extension — rejected only in a v1 repository.
1450            _ => {
1451                if repo_version >= 1 {
1452                    unknown_found.push(extension);
1453                }
1454            }
1455        }
1456    }
1457
1458    if !unknown_found.is_empty() {
1459        let mut msg = if unknown_found.len() == 1 {
1460            "unknown repository extension found:".to_owned()
1461        } else {
1462            "unknown repository extensions found:".to_owned()
1463        };
1464        for ext in &unknown_found {
1465            msg.push_str(&format!("\n\t{ext}"));
1466        }
1467        return Err(Error::Message(msg));
1468    }
1469
1470    if !v1_only_found.is_empty() {
1471        let mut msg = if v1_only_found.len() == 1 {
1472            "repo version is 0, but v1-only extension found:".to_owned()
1473        } else {
1474            "repo version is 0, but v1-only extensions found:".to_owned()
1475        };
1476        for ext in &v1_only_found {
1477            msg.push_str(&format!("\n\t{ext}"));
1478        }
1479        return Err(Error::Message(msg));
1480    }
1481
1482    Ok(())
1483}
1484
1485/// Try to open a repository rooted exactly at `dir`.
1486///
1487/// Returns `Ok(None)` when `dir` is not a repository root (the caller should
1488/// walk up); returns `Err` on a structural problem.
1489/// Result of probing a single directory during [`Repository::discover`].
1490struct DiscoveredAt {
1491    repo: Repository,
1492    /// When discovery used a `.git` gitfile, the path to that file (for ownership checks).
1493    gitfile: Option<PathBuf>,
1494}
1495
1496fn try_open_at(dir: &Path) -> Result<Option<DiscoveredAt>> {
1497    let dot_git = dir.join(".git");
1498
1499    // Check for special file types (FIFO, socket, etc.) — reject them
1500    // instead of walking up to a parent repository.
1501    #[cfg(unix)]
1502    {
1503        use std::os::unix::fs::FileTypeExt;
1504        if let Ok(meta) = fs::symlink_metadata(&dot_git) {
1505            let ft = meta.file_type();
1506            if ft.is_fifo() || ft.is_socket() || ft.is_block_device() || ft.is_char_device() {
1507                return Err(Error::NotARepository(format!(
1508                    "invalid gitfile format: {} is not a regular file",
1509                    dot_git.display()
1510                )));
1511            }
1512            if ft.is_symlink() {
1513                if let Ok(target_meta) = fs::metadata(&dot_git) {
1514                    let tft = target_meta.file_type();
1515                    if tft.is_fifo()
1516                        || tft.is_socket()
1517                        || tft.is_block_device()
1518                        || tft.is_char_device()
1519                    {
1520                        return Err(Error::NotARepository(format!(
1521                            "invalid gitfile format: {} is not a regular file",
1522                            dot_git.display()
1523                        )));
1524                    }
1525                }
1526            }
1527        }
1528    }
1529
1530    if dot_git.is_file() {
1531        // gitfile indirection: file contains "gitdir: <path>"
1532        let content =
1533            fs::read_to_string(&dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
1534        let git_dir = parse_gitfile(&content, dir)?;
1535        let mut repo = Repository::open_skipping_format_validation(&git_dir, Some(dir))?;
1536        // Linked worktree: `core.worktree` in the common config may point at another directory
1537        // (t1501). When the process cwd is not inside that configured tree, Git uses the
1538        // discovery directory as the work tree (commondir overrides for ops under the real tree).
1539        if resolve_common_dir(&git_dir).is_some() {
1540            let cwd = env::current_dir().map_err(Error::Io)?;
1541            if repo.work_tree.is_some() && !is_inside_work_tree(&repo, &cwd) {
1542                let root = if dir.is_absolute() {
1543                    dir.to_path_buf()
1544                } else {
1545                    cwd.join(dir)
1546                };
1547                repo.work_tree = Some(root.canonicalize().unwrap_or(root));
1548            }
1549        }
1550        let root = if dir.is_absolute() {
1551            dir.to_path_buf()
1552        } else {
1553            env::current_dir().map_err(Error::Io)?.join(dir)
1554        };
1555        repo.discovery_root = Some(root.canonicalize().unwrap_or(root));
1556        repo.discovery_via_gitfile = true;
1557        warn_core_bare_worktree_conflict(&git_dir);
1558        return Ok(Some(DiscoveredAt {
1559            repo,
1560            gitfile: Some(dot_git.clone()),
1561        }));
1562    }
1563
1564    if dot_git.is_dir() {
1565        // If .git is a symlink to a directory, resolve the symlink target
1566        // for validation but keep the original .git path for user-facing output
1567        // (matches real git behavior: `rev-parse --git-dir` shows `.git`).
1568        let open_path = if dot_git.is_symlink() {
1569            // Resolve the symlink target for validation
1570            dot_git.read_link().unwrap_or_else(|_| dot_git.clone())
1571        } else {
1572            dot_git.clone()
1573        };
1574        // Try to open; if the directory is empty or invalid, continue
1575        // walking up (e.g. an empty .git/ directory should be ignored).
1576        match Repository::open_skipping_format_validation(&open_path, Some(dir)) {
1577            Ok(mut repo) => {
1578                // Restore the original path so rev-parse shows .git not the
1579                // resolved symlink target.
1580                if dot_git.is_symlink() {
1581                    let abs_dot_git = if dot_git.is_absolute() {
1582                        dot_git
1583                    } else {
1584                        dir.join(".git")
1585                    };
1586                    repo.git_dir = abs_dot_git;
1587                }
1588                let root = if dir.is_absolute() {
1589                    dir.to_path_buf()
1590                } else {
1591                    env::current_dir().map_err(Error::Io)?.join(dir)
1592                };
1593                repo.discovery_root = Some(root.canonicalize().unwrap_or(root));
1594                repo.discovery_via_gitfile = false;
1595                return Ok(Some(DiscoveredAt {
1596                    repo,
1597                    gitfile: None,
1598                }));
1599            }
1600            Err(Error::NotARepository(_)) | Err(Error::ConfigError(_)) => return Ok(None),
1601            Err(Error::Message(ref msg)) if msg.contains("bad config") => return Ok(None),
1602            Err(e) => return Err(e),
1603        }
1604    }
1605
1606    // Linked-worktree gitdir/admin directories contain HEAD and commondir,
1607    // and can be opened as repositories even without a local objects/ dir.
1608    if dir.join("HEAD").is_file() && dir.join("commondir").is_file() {
1609        maybe_trace_implicit_bare_repository(dir);
1610        let repo = Repository::open(dir, None)?;
1611        warn_core_bare_worktree_conflict(dir);
1612        return Ok(Some(DiscoveredAt {
1613            repo,
1614            gitfile: None,
1615        }));
1616    }
1617
1618    // Check if `dir` itself is a bare repo (has objects/ and HEAD directly)
1619    if dir.join("objects").is_dir() && dir.join("HEAD").is_file() {
1620        maybe_trace_implicit_bare_repository(dir);
1621        // Check safe.bareRepository policy before opening bare repos.
1622        // When set to "explicit", implicit bare repo discovery is forbidden
1623        // unless GIT_DIR was set (handled earlier in discover()).
1624        if !is_inside_dot_git(dir) {
1625            if let Ok(cfg) = crate::config::ConfigSet::load(None, true) {
1626                if let Some(val) = cfg.get("safe.bareRepository") {
1627                    if val.eq_ignore_ascii_case("explicit") {
1628                        return Err(Error::ForbiddenBareRepository(dir.display().to_string()));
1629                    }
1630                }
1631            }
1632        }
1633        let repo = Repository::open(dir, None)?;
1634        warn_core_bare_worktree_conflict(dir);
1635        return Ok(Some(DiscoveredAt {
1636            repo,
1637            gitfile: None,
1638        }));
1639    }
1640
1641    Ok(None)
1642}
1643
1644fn is_inside_dot_git(path: &Path) -> bool {
1645    path.components().any(|c| c.as_os_str() == ".git")
1646}
1647
1648fn maybe_trace_implicit_bare_repository(dir: &Path) {
1649    let path = match std::env::var("GIT_TRACE2_PERF") {
1650        Ok(p) if !p.is_empty() => p,
1651        _ => return,
1652    };
1653
1654    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
1655        let _ = writeln!(file, "setup: implicit-bare-repository:{}", dir.display());
1656    }
1657}
1658
1659/// Collect effective `safe.directory` values from protected config (system/global/command),
1660/// applying empty-value resets like Git.
1661fn safe_directory_effective_values(git_dir: &Path) -> Vec<String> {
1662    let cfg = crate::config::ConfigSet::load(Some(git_dir), true)
1663        .unwrap_or_else(|_| crate::config::ConfigSet::new());
1664    let mut values: Vec<String> = Vec::new();
1665    for e in cfg.entries() {
1666        if e.key == "safe.directory"
1667            && e.scope != crate::config::ConfigScope::Local
1668            && e.scope != crate::config::ConfigScope::Worktree
1669        {
1670            values.push(e.value.clone().unwrap_or_else(|| "true".to_owned()));
1671        }
1672    }
1673    let mut effective: Vec<String> = Vec::new();
1674    for v in values {
1675        if v.is_empty() {
1676            effective.clear();
1677        } else {
1678            effective.push(v);
1679        }
1680    }
1681    effective
1682}
1683
1684fn ensure_safe_directory_allows(git_dir: &Path, checked: &Path) -> Result<()> {
1685    let effective = safe_directory_effective_values(git_dir);
1686    let checked_s = checked.to_string_lossy().to_string();
1687    if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
1688        eprintln!("debug-safe-directory values={:?}", effective);
1689    }
1690    if effective
1691        .iter()
1692        .any(|v| safe_directory_matches(v, &checked_s))
1693    {
1694        return Ok(());
1695    }
1696    Err(Error::DubiousOwnership(checked_s))
1697}
1698
1699#[cfg(unix)]
1700fn path_lstat_uid(path: &Path) -> std::io::Result<u32> {
1701    use std::os::unix::fs::MetadataExt;
1702    let meta = fs::symlink_metadata(path)?;
1703    Ok(meta.uid())
1704}
1705
1706#[cfg(unix)]
1707fn extract_uid_from_env(name: &str) -> Option<u32> {
1708    let raw = std::env::var(name).ok()?;
1709    if raw.is_empty() {
1710        return None;
1711    }
1712    raw.parse::<u32>().ok()
1713}
1714
1715/// Match Git's `ensure_valid_ownership`: check gitfile, worktree, and gitdir ownership,
1716/// then `safe.directory` when any path is not owned by the effective user.
1717#[cfg(unix)]
1718fn ensure_valid_ownership(
1719    gitfile: Option<&Path>,
1720    worktree: Option<&Path>,
1721    gitdir: &Path,
1722) -> Result<()> {
1723    const ROOT_UID: u32 = 0;
1724
1725    fn owned_by_effective_user(path: &Path) -> std::io::Result<bool> {
1726        let st_uid = path_lstat_uid(path)?;
1727        let mut euid = unsafe { libc::geteuid() };
1728        if euid == ROOT_UID {
1729            if st_uid == ROOT_UID {
1730                return Ok(true);
1731            }
1732            if let Some(sudo_uid) = extract_uid_from_env("SUDO_UID") {
1733                euid = sudo_uid;
1734            }
1735        }
1736        Ok(st_uid == euid)
1737    }
1738
1739    let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1740        .ok()
1741        .map(|v| {
1742            let lower = v.to_ascii_lowercase();
1743            v == "1" || lower == "true" || lower == "yes" || lower == "on"
1744        })
1745        .unwrap_or(false);
1746    if !assume_different {
1747        let gitfile_ok = gitfile
1748            .map(owned_by_effective_user)
1749            .transpose()?
1750            .unwrap_or(true);
1751        // Git may use a `GIT_WORK_TREE` that does not exist yet (t1510); skip ownership when
1752        // the path is absent instead of failing discovery with ENOENT.
1753        let wt_ok = match worktree {
1754            None => true,
1755            Some(wt) => match owned_by_effective_user(wt) {
1756                Ok(ok) => ok,
1757                Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1758                Err(e) => return Err(Error::Io(e)),
1759            },
1760        };
1761        let gd_ok = owned_by_effective_user(gitdir)?;
1762        if gitfile_ok && wt_ok && gd_ok {
1763            return Ok(());
1764        }
1765    }
1766
1767    let data_path = if let Some(wt) = worktree {
1768        wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf())
1769    } else {
1770        gitdir
1771            .canonicalize()
1772            .unwrap_or_else(|_| gitdir.to_path_buf())
1773    };
1774    ensure_safe_directory_allows(gitdir, &data_path)
1775}
1776
1777#[cfg(not(unix))]
1778fn ensure_valid_ownership(
1779    _gitfile: Option<&Path>,
1780    _worktree: Option<&Path>,
1781    _gitdir: &Path,
1782) -> Result<()> {
1783    Ok(())
1784}
1785
1786impl Repository {
1787    /// Enforce `safe.directory` ownership checks, matching upstream behavior.
1788    ///
1789    /// When `GIT_TEST_ASSUME_DIFFERENT_OWNER=1`, ownership is considered unsafe
1790    /// unless a matching `safe.directory` value is configured in system/global/
1791    /// command scopes (repository-local config is ignored).
1792    pub fn enforce_safe_directory(&self) -> Result<()> {
1793        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1794            .ok()
1795            .map(|v| {
1796                let lower = v.to_ascii_lowercase();
1797                v == "1" || lower == "true" || lower == "yes" || lower == "on"
1798            })
1799            .unwrap_or(false);
1800        if !assume_different {
1801            return Ok(());
1802        }
1803
1804        if self.explicit_git_dir {
1805            return Ok(());
1806        }
1807
1808        // In normal discovery, ownership is checked against worktree paths
1809        // unless invocation starts inside the gitdir, in which case gitdir is
1810        // checked.
1811        let checked = if let Some(wt) = &self.work_tree {
1812            let cwd = std::env::current_dir().ok();
1813            if let Some(cwd) = cwd {
1814                if cwd
1815                    .canonicalize()
1816                    .ok()
1817                    .is_some_and(|c| c.starts_with(&self.git_dir))
1818                {
1819                    self.git_dir
1820                        .canonicalize()
1821                        .unwrap_or_else(|_| self.git_dir.clone())
1822                } else {
1823                    wt.canonicalize().unwrap_or_else(|_| wt.clone())
1824                }
1825            } else {
1826                wt.canonicalize().unwrap_or_else(|_| wt.clone())
1827            }
1828        } else {
1829            self.git_dir
1830                .canonicalize()
1831                .unwrap_or_else(|_| self.git_dir.clone())
1832        };
1833
1834        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
1835            eprintln!(
1836                "debug-safe-directory checked={} git_dir={} work_tree={:?} cwd={:?}",
1837                checked.display(),
1838                self.git_dir.display(),
1839                self.work_tree,
1840                std::env::current_dir().ok()
1841            );
1842        }
1843        self.enforce_safe_directory_checked(&checked)
1844    }
1845
1846    /// Enforce safe.directory checks using the repository git-dir path.
1847    ///
1848    /// Used by operations that explicitly open another repository by path
1849    /// (e.g. local clone source).
1850    pub fn enforce_safe_directory_git_dir(&self) -> Result<()> {
1851        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1852            .ok()
1853            .map(|v| {
1854                let lower = v.to_ascii_lowercase();
1855                v == "1" || lower == "true" || lower == "yes" || lower == "on"
1856            })
1857            .unwrap_or(false);
1858        if !assume_different {
1859            return Ok(());
1860        }
1861        let checked = self
1862            .git_dir
1863            .canonicalize()
1864            .unwrap_or_else(|_| self.git_dir.clone());
1865        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
1866            eprintln!(
1867                "debug-safe-directory(gitdir) checked={} git_dir={} work_tree={:?}",
1868                checked.display(),
1869                self.git_dir.display(),
1870                self.work_tree
1871            );
1872        }
1873        self.enforce_safe_directory_checked(&checked)
1874    }
1875
1876    /// Enforce safe.directory checks against an explicit checked path.
1877    pub fn enforce_safe_directory_git_dir_with_path(&self, checked: &Path) -> Result<()> {
1878        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1879            .ok()
1880            .map(|v| {
1881                let lower = v.to_ascii_lowercase();
1882                v == "1" || lower == "true" || lower == "yes" || lower == "on"
1883            })
1884            .unwrap_or(false);
1885        if !assume_different {
1886            return Ok(());
1887        }
1888        self.enforce_safe_directory_checked(checked)
1889    }
1890
1891    fn enforce_safe_directory_checked(&self, checked: &Path) -> Result<()> {
1892        ensure_safe_directory_allows(&self.git_dir, checked)
1893    }
1894
1895    /// Verify the repository is safe to use as a `git clone` source (local clone).
1896    ///
1897    /// When `GIT_TEST_ASSUME_DIFFERENT_OWNER` is set, applies the same `safe.directory`
1898    /// rules as discovery. Otherwise checks filesystem ownership of the git directory
1899    /// only (matching Git's `die_upon_dubious_ownership` for clone).
1900    pub fn verify_safe_for_clone_source(&self) -> Result<()> {
1901        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1902            .ok()
1903            .map(|v| {
1904                let lower = v.to_ascii_lowercase();
1905                v == "1" || lower == "true" || lower == "yes" || lower == "on"
1906            })
1907            .unwrap_or(false);
1908        if assume_different {
1909            self.enforce_safe_directory_git_dir()
1910        } else {
1911            #[cfg(unix)]
1912            {
1913                ensure_valid_ownership(None, None, &self.git_dir)
1914            }
1915            #[cfg(not(unix))]
1916            {
1917                Ok(())
1918            }
1919        }
1920    }
1921}
1922
1923fn normalize_fs_path(raw: &str) -> String {
1924    use std::path::Component;
1925    let p = std::path::Path::new(raw);
1926    let mut parts: Vec<String> = Vec::new();
1927    let mut absolute = false;
1928    for c in p.components() {
1929        match c {
1930            Component::RootDir => {
1931                absolute = true;
1932                parts.clear();
1933            }
1934            Component::CurDir => {}
1935            Component::ParentDir => {
1936                if !parts.is_empty() {
1937                    parts.pop();
1938                }
1939            }
1940            Component::Normal(s) => parts.push(s.to_string_lossy().to_string()),
1941            Component::Prefix(_) => {}
1942        }
1943    }
1944    let mut out = if absolute {
1945        String::from("/")
1946    } else {
1947        String::new()
1948    };
1949    out.push_str(&parts.join("/"));
1950    out
1951}
1952
1953fn safe_directory_matches(config_value: &str, checked: &str) -> bool {
1954    if config_value == "*" {
1955        return true;
1956    }
1957    if config_value == "." {
1958        // CWD only.
1959        if let Ok(cwd) = std::env::current_dir() {
1960            let cwd_s = normalize_fs_path(&cwd.to_string_lossy());
1961            let checked_s = normalize_fs_path(checked);
1962            return cwd_s == checked_s;
1963        }
1964        return false;
1965    }
1966
1967    let canonicalize_or_normalize = |raw: &str| -> String {
1968        let p = std::path::Path::new(raw);
1969        if p.exists() {
1970            p.canonicalize()
1971                .map(|c| c.to_string_lossy().to_string())
1972                .map(|s| normalize_fs_path(&s))
1973                .unwrap_or_else(|_| normalize_fs_path(raw))
1974        } else {
1975            normalize_fs_path(raw)
1976        }
1977    };
1978
1979    let config_norm = canonicalize_or_normalize(config_value);
1980    let checked_norm = normalize_fs_path(checked);
1981
1982    if config_norm.ends_with("/*") {
1983        let prefix_raw = &config_norm[..config_norm.len() - 2];
1984        let prefix_norm = canonicalize_or_normalize(prefix_raw);
1985        let mut prefix = prefix_norm;
1986        if !prefix.ends_with('/') {
1987            prefix.push('/');
1988        }
1989        return checked_norm.starts_with(&prefix);
1990    }
1991
1992    config_norm == checked_norm
1993}
1994
1995fn warn_core_bare_worktree_conflict(git_dir: &Path) {
1996    if env::var("GIT_WORK_TREE")
1997        .ok()
1998        .filter(|s| !s.trim().is_empty())
1999        .is_some()
2000    {
2001        return;
2002    }
2003    static WARNED_DIRS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
2004    if let Ok((bare, wt)) = read_core_bare_and_worktree(git_dir) {
2005        if bare && wt.is_some() {
2006            let key = git_dir
2007                .canonicalize()
2008                .unwrap_or_else(|_| git_dir.to_path_buf())
2009                .to_string_lossy()
2010                .to_string();
2011            let mut guard = WARNED_DIRS.lock().unwrap_or_else(|e| e.into_inner());
2012            let set = guard.get_or_insert_with(HashSet::new);
2013            if set.insert(key) {
2014                eprintln!("warning: core.bare and core.worktree do not make sense");
2015            }
2016        }
2017    }
2018}
2019
2020fn read_core_bare_and_worktree(git_dir: &Path) -> Result<(bool, Option<String>)> {
2021    let Some(config_path) = repository_config_path(git_dir) else {
2022        return Ok((false, None));
2023    };
2024    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
2025    let mut in_core = false;
2026    let mut bare = false;
2027    let mut worktree: Option<String> = None;
2028    for raw_line in content.lines() {
2029        let line = raw_line.trim();
2030        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2031            continue;
2032        }
2033        if line.starts_with('[') {
2034            in_core = line.eq_ignore_ascii_case("[core]");
2035            continue;
2036        }
2037        if !in_core {
2038            continue;
2039        }
2040        if let Some((k, v)) = line.split_once('=') {
2041            let key = k.trim();
2042            let val = v.trim();
2043            if key.eq_ignore_ascii_case("bare") {
2044                bare = val.eq_ignore_ascii_case("true");
2045            } else if key.eq_ignore_ascii_case("worktree") {
2046                worktree = Some(val.to_owned());
2047            }
2048        }
2049    }
2050    Ok((bare, worktree))
2051}
2052
2053/// Reject impossible `GIT_WORK_TREE` values before repository setup (matches Git's
2054/// `validate_worktree` / `die` on bogus absolute paths, e.g. t1501).
2055fn validate_git_work_tree_path(path: &Path) -> Result<()> {
2056    if !path.is_absolute() {
2057        return Ok(());
2058    }
2059    let comps: Vec<Component<'_>> = path.components().collect();
2060    let Some(last_normal_idx) = comps
2061        .iter()
2062        .enumerate()
2063        .rev()
2064        .find_map(|(i, c)| matches!(c, Component::Normal(_)).then_some(i))
2065    else {
2066        return Ok(());
2067    };
2068    let mut cur = PathBuf::new();
2069    for (i, comp) in comps.iter().enumerate() {
2070        match comp {
2071            Component::Prefix(p) => cur.push(p.as_os_str()),
2072            Component::RootDir => cur.push(comp.as_os_str()),
2073            Component::CurDir => {}
2074            Component::ParentDir => {
2075                let _ = cur.pop();
2076            }
2077            Component::Normal(seg) => {
2078                cur.push(seg);
2079                if i != last_normal_idx && !cur.exists() {
2080                    return Err(Error::PathError(format!(
2081                        "Invalid path '{}': No such file or directory",
2082                        cur.display()
2083                    )));
2084                }
2085            }
2086        }
2087    }
2088    Ok(())
2089}
2090
2091fn resolve_core_worktree_path(git_dir: &Path, raw: &str) -> Result<PathBuf> {
2092    let p = Path::new(raw);
2093    if p.is_absolute() {
2094        return Ok(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()));
2095    }
2096    let old = env::current_dir().map_err(Error::Io)?;
2097    env::set_current_dir(git_dir).map_err(Error::Io)?;
2098    env::set_current_dir(raw).map_err(Error::Io)?;
2099    let resolved = env::current_dir().map_err(Error::Io)?;
2100    env::set_current_dir(&old).map_err(Error::Io)?;
2101    Ok(resolved.canonicalize().unwrap_or(resolved))
2102}
2103
2104/// When `GIT_DIR` names a gitfile, resolve to the real git directory.
2105fn resolve_git_dir_env_path(git_dir: &Path) -> Result<PathBuf> {
2106    if git_dir.is_file() {
2107        let content =
2108            fs::read_to_string(git_dir).map_err(|e| Error::NotARepository(e.to_string()))?;
2109        let base = git_dir
2110            .parent()
2111            .ok_or_else(|| Error::NotARepository(git_dir.display().to_string()))?;
2112        return parse_gitfile(&content, base);
2113    }
2114    Ok(git_dir.to_path_buf())
2115}
2116
2117/// Resolve an explicit git directory path the same way as `GIT_DIR` (including gitfile indirection).
2118///
2119/// # Errors
2120///
2121/// Returns [`Error::NotARepository`] for invalid gitfile content.
2122pub fn resolve_git_directory_arg(git_dir: &Path) -> Result<PathBuf> {
2123    resolve_git_dir_env_path(git_dir)
2124}
2125
2126/// Resolves a work tree's `.git` path (directory or gitfile) to the real git directory.
2127///
2128/// # Errors
2129///
2130/// Returns [`Error::NotARepository`] when `.git` is missing, invalid, or the gitfile target is absent.
2131pub fn resolve_dot_git(dot_git: &Path) -> Result<PathBuf> {
2132    if dot_git.is_dir() {
2133        return dot_git
2134            .canonicalize()
2135            .map_err(|_| Error::NotARepository(dot_git.display().to_string()));
2136    }
2137    if dot_git.is_file() {
2138        let content =
2139            fs::read_to_string(dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
2140        let base = dot_git
2141            .parent()
2142            .ok_or_else(|| Error::NotARepository(dot_git.display().to_string()))?;
2143        return parse_gitfile(&content, base);
2144    }
2145    Err(Error::NotARepository(dot_git.display().to_string()))
2146}
2147
2148/// Parse a gitfile's `"gitdir: <path>"` line.
2149fn parse_gitfile(content: &str, base: &Path) -> Result<PathBuf> {
2150    for line in content.lines() {
2151        if let Some(rest) = line.strip_prefix("gitdir:") {
2152            let rel = rest.trim();
2153            let path = if Path::new(rel).is_absolute() {
2154                PathBuf::from(rel)
2155            } else {
2156                base.join(rel)
2157            };
2158            if !path.exists() {
2159                return Err(Error::NotARepository(path.display().to_string()));
2160            }
2161            return Ok(path);
2162        }
2163    }
2164    Err(Error::NotARepository("invalid gitfile format".to_owned()))
2165}
2166
2167/// Initialise a new Git repository at the given path.
2168///
2169/// Creates the standard directory skeleton (objects/, refs/heads/, refs/tags/,
2170/// info/, hooks/) and a default `HEAD` pointing to `refs/heads/<initial_branch>`.
2171///
2172/// # Parameters
2173///
2174/// - `path` — root directory to initialise (created if absent).
2175/// - `bare` — if true, `path` itself becomes the git-dir; otherwise `path/.git`.
2176/// - `initial_branch` — branch name for `HEAD` (e.g. `"main"`).
2177/// - `template_dir` — optional template directory; if `None`, a minimal skeleton
2178///   is created.
2179///
2180/// # Errors
2181///
2182/// Returns [`Error::Io`] on filesystem failures.
2183fn write_fresh_git_directory(
2184    git_dir: &Path,
2185    bare: bool,
2186    initial_branch: &str,
2187    template_dir: Option<&Path>,
2188    ref_storage: &str,
2189    skip_hooks_and_info: bool,
2190) -> Result<()> {
2191    let mut subs = vec![
2192        "objects",
2193        "objects/info",
2194        "objects/pack",
2195        "refs",
2196        "refs/heads",
2197        "refs/tags",
2198    ];
2199    if !bare && !skip_hooks_and_info {
2200        subs.push("info");
2201        subs.push("hooks");
2202    }
2203    for sub in subs {
2204        fs::create_dir_all(git_dir.join(sub))?;
2205    }
2206
2207    if ref_storage == "reftable" {
2208        let reftable_dir = git_dir.join("reftable");
2209        fs::create_dir_all(&reftable_dir)?;
2210        let tables_list = reftable_dir.join("tables.list");
2211        if !tables_list.exists() {
2212            fs::write(&tables_list, "")?;
2213        }
2214    }
2215
2216    if let Some(tmpl) = template_dir {
2217        if tmpl.is_dir() {
2218            copy_template(tmpl, git_dir)?;
2219        }
2220    }
2221
2222    let head_content = format!("ref: refs/heads/{initial_branch}\n");
2223    fs::write(git_dir.join("HEAD"), head_content)?;
2224
2225    let needs_extensions = ref_storage == "reftable";
2226    let repo_version = if needs_extensions { 1 } else { 0 };
2227
2228    let mut config_content = String::from("[core]\n");
2229    config_content.push_str(&format!("\trepositoryformatversion = {repo_version}\n"));
2230    config_content.push_str("\tfilemode = true\n");
2231    if bare {
2232        config_content.push_str("\tbare = true\n");
2233    } else {
2234        config_content.push_str("\tbare = false\n");
2235        config_content.push_str("\tlogallrefupdates = true\n");
2236    }
2237    if needs_extensions {
2238        config_content.push_str("[extensions]\n");
2239        config_content.push_str("\trefStorage = reftable\n");
2240    }
2241    fs::write(git_dir.join("config"), config_content)?;
2242
2243    // Merge `config` from the template on top of the default (matches `git clone --template`).
2244    if let Some(tmpl) = template_dir {
2245        if tmpl.is_dir() {
2246            let tmpl_config = tmpl.join("config");
2247            if tmpl_config.is_file() {
2248                let tmpl_text = fs::read_to_string(&tmpl_config)?;
2249                let tmpl_parsed = ConfigFile::parse(&tmpl_config, &tmpl_text, ConfigScope::Local)?;
2250                let dest_path = git_dir.join("config");
2251                let dest_text = fs::read_to_string(&dest_path)?;
2252                let mut dest_parsed =
2253                    ConfigFile::parse(&dest_path, &dest_text, ConfigScope::Local)?;
2254                for e in &tmpl_parsed.entries {
2255                    // Git clone ignores `core.bare` from templates (non-bare clone must stay non-bare).
2256                    if e.key == "core.bare" {
2257                        continue;
2258                    }
2259                    if let Some(v) = &e.value {
2260                        let _ = dest_parsed.set(&e.key, v);
2261                    } else {
2262                        let _ = dest_parsed.set(&e.key, "true");
2263                    }
2264                }
2265                dest_parsed.write()?;
2266            }
2267        }
2268    }
2269
2270    fs::write(
2271        git_dir.join("description"),
2272        "Unnamed repository; edit this file 'description' to name the repository.\n",
2273    )?;
2274    Ok(())
2275}
2276
2277/// Initialise a non-bare repository with the git directory at `git_dir` and the work tree at `work_tree`.
2278///
2279/// Creates `work_tree/.git` as a gitfile pointing at `git_dir` (absolute path). Matches `git clone
2280/// --separate-git-dir` layout.
2281///
2282/// # Errors
2283///
2284/// Returns [`Error::Io`] on filesystem failures.
2285pub fn init_repository_separate_git_dir(
2286    work_tree: &Path,
2287    git_dir: &Path,
2288    initial_branch: &str,
2289    template_dir: Option<&Path>,
2290    ref_storage: &str,
2291) -> Result<Repository> {
2292    let skip_hooks_info = template_dir.is_some_and(|p| p.as_os_str().is_empty());
2293    fs::create_dir_all(work_tree)?;
2294    fs::create_dir_all(git_dir)?;
2295    write_fresh_git_directory(
2296        git_dir,
2297        false,
2298        initial_branch,
2299        template_dir,
2300        ref_storage,
2301        skip_hooks_info,
2302    )?;
2303
2304    // Use a relative `gitdir:` path from the work tree (matches C Git). Absolute paths break
2305    // nested submodule layouts: the inner clone would keep a `.git` directory instead of a
2306    // gitfile, so `.git/modules/<outer>/modules/<inner>` is never created (t1013).
2307    let gitfile = work_tree.join(".git");
2308    let rel_git_dir = pathdiff_relative_gitfile(work_tree, git_dir);
2309    fs::write(gitfile, format!("gitdir: {rel_git_dir}\n"))?;
2310
2311    Repository::open(git_dir, Some(work_tree))
2312}
2313
2314/// Relative path from `from` to `to` using `..` segments (forward slashes), for gitfile lines.
2315fn pathdiff_relative_gitfile(from: &Path, to: &Path) -> String {
2316    let from_c = fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf());
2317    let to_c = fs::canonicalize(to).unwrap_or_else(|_| to.to_path_buf());
2318    let from_comp: Vec<Component<'_>> = from_c.components().collect();
2319    let to_comp: Vec<Component<'_>> = to_c.components().collect();
2320    let mut i = 0usize;
2321    while i < from_comp.len() && i < to_comp.len() && from_comp[i] == to_comp[i] {
2322        i += 1;
2323    }
2324    let mut out = PathBuf::new();
2325    for _ in i..from_comp.len() {
2326        out.push("..");
2327    }
2328    for c in &to_comp[i..] {
2329        out.push(c.as_os_str());
2330    }
2331    out.to_string_lossy().replace('\\', "/")
2332}
2333
2334/// Initialise a **minimal** bare repository directory layout matching `git clone --template= --bare`.
2335///
2336/// Git's clone-with-empty-template omits `hooks/`, `info/`, `description`, and `branches/` until
2337/// something needs them; tests rely on `mkdir <repo>/info` succeeding afterward.
2338///
2339/// # Parameters
2340///
2341/// - `git_dir` — bare repository root (the destination `.git` directory for a bare clone).
2342/// - `initial_branch` — used only for the initial `HEAD` symref text before clone rewires it.
2343///
2344/// # Errors
2345///
2346/// Returns [`Error::Io`] on filesystem failures.
2347/// Ensure `core.bare = true` in the repository `config` (used after `git clone --bare`).
2348pub fn ensure_core_bare(git_dir: &Path) -> Result<()> {
2349    let path = git_dir.join("config");
2350    let text = fs::read_to_string(&path).unwrap_or_default();
2351    if text.lines().any(|l| {
2352        let t = l.trim();
2353        t == "bare = true" || t == "bare=true"
2354    }) {
2355        return Ok(());
2356    }
2357    let mut out = text;
2358    if !out.ends_with('\n') && !out.is_empty() {
2359        out.push('\n');
2360    }
2361    if !out.contains("[core]") {
2362        out.push_str("[core]\n");
2363    }
2364    out.push_str("\tbare = true\n");
2365    fs::write(path, out).map_err(Error::Io)
2366}
2367
2368pub fn init_bare_clone_minimal(
2369    git_dir: &Path,
2370    initial_branch: &str,
2371    ref_storage: &str,
2372) -> Result<()> {
2373    for sub in &[
2374        "objects",
2375        "objects/info",
2376        "objects/pack",
2377        "refs",
2378        "refs/heads",
2379        "refs/tags",
2380    ] {
2381        fs::create_dir_all(git_dir.join(sub))?;
2382    }
2383
2384    if ref_storage == "reftable" {
2385        let reftable_dir = git_dir.join("reftable");
2386        fs::create_dir_all(&reftable_dir)?;
2387        let tables_list = reftable_dir.join("tables.list");
2388        if !tables_list.exists() {
2389            fs::write(&tables_list, "")?;
2390        }
2391    }
2392
2393    let head_content = format!("ref: refs/heads/{initial_branch}\n");
2394    fs::write(git_dir.join("HEAD"), head_content)?;
2395
2396    let needs_extensions = ref_storage == "reftable";
2397    let repo_version = if needs_extensions { 1 } else { 0 };
2398    let mut config_content = String::from("[core]\n");
2399    config_content.push_str(&format!("\trepositoryformatversion = {repo_version}\n"));
2400    config_content.push_str("\tfilemode = true\n");
2401    config_content.push_str("\tbare = true\n");
2402    if needs_extensions {
2403        config_content.push_str("[extensions]\n");
2404        config_content.push_str("\trefStorage = reftable\n");
2405    }
2406    fs::write(git_dir.join("config"), config_content)?;
2407
2408    fs::write(
2409        git_dir.join("packed-refs"),
2410        "# pack-refs with: peeled fully-peeled sorted\n",
2411    )?;
2412    Ok(())
2413}
2414
2415pub fn init_repository(
2416    path: &Path,
2417    bare: bool,
2418    initial_branch: &str,
2419    template_dir: Option<&Path>,
2420    ref_storage: &str,
2421) -> Result<Repository> {
2422    let skip_hooks_info = !bare && template_dir.is_some_and(|p| p.as_os_str().is_empty());
2423    let git_dir = if bare {
2424        path.to_path_buf()
2425    } else {
2426        path.join(".git")
2427    };
2428
2429    if !bare {
2430        fs::create_dir_all(path)?;
2431    }
2432    fs::create_dir_all(&git_dir)?;
2433    write_fresh_git_directory(
2434        &git_dir,
2435        bare,
2436        initial_branch,
2437        template_dir,
2438        ref_storage,
2439        skip_hooks_info,
2440    )?;
2441
2442    let work_tree = if bare { None } else { Some(path) };
2443    Repository::open(&git_dir, work_tree)
2444}
2445
2446/// Initialise a **bare** repository at `git_dir` with `core.worktree` set to `work_tree`.
2447///
2448/// Used when `GIT_WORK_TREE` is set during `git clone`: the clone destination is the bare
2449/// git directory and checked-out files go under the environment work tree (matches upstream Git).
2450///
2451/// # Errors
2452///
2453/// Returns [`Error::Io`] on filesystem failures.
2454pub fn init_bare_with_env_worktree(
2455    git_dir: &Path,
2456    work_tree: &Path,
2457    initial_branch: &str,
2458    template_dir: Option<&Path>,
2459    ref_storage: &str,
2460) -> Result<Repository> {
2461    fs::create_dir_all(git_dir)?;
2462    fs::create_dir_all(work_tree)?;
2463    write_fresh_git_directory(
2464        git_dir,
2465        true,
2466        initial_branch,
2467        template_dir,
2468        ref_storage,
2469        false,
2470    )?;
2471    let work_tree_abs = fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
2472    let config_path = git_dir.join("config");
2473    let mut config = match ConfigFile::from_path(&config_path, ConfigScope::Local)? {
2474        Some(c) => c,
2475        None => ConfigFile::parse(&config_path, "", ConfigScope::Local)?,
2476    };
2477    config.set("core.worktree", &work_tree_abs.to_string_lossy())?;
2478    config.write()?;
2479    Repository::open(git_dir, Some(work_tree))
2480}
2481
2482/// Initialise a repository whose git directory is separate from the work tree.
2483///
2484/// Creates `git_dir` with the usual layout, writes `work_tree/.git` as a gitfile
2485/// pointing at `git_dir`, and sets `core.worktree` in `git_dir/config`.
2486pub fn init_repository_separate(
2487    work_tree: &Path,
2488    git_dir: &Path,
2489    initial_branch: &str,
2490    template_dir: Option<&Path>,
2491) -> Result<Repository> {
2492    fs::create_dir_all(work_tree)?;
2493    if git_dir.exists() {
2494        return Err(Error::PathError(format!(
2495            "git directory '{}' already exists",
2496            git_dir.display()
2497        )));
2498    }
2499
2500    for sub in &[
2501        "objects",
2502        "objects/info",
2503        "objects/pack",
2504        "refs",
2505        "refs/heads",
2506        "refs/tags",
2507        "info",
2508        "hooks",
2509    ] {
2510        fs::create_dir_all(git_dir.join(sub))?;
2511    }
2512
2513    if let Some(tmpl) = template_dir {
2514        if tmpl.is_dir() {
2515            copy_template(tmpl, git_dir)?;
2516        }
2517    }
2518
2519    fs::write(
2520        git_dir.join("HEAD"),
2521        format!("ref: refs/heads/{initial_branch}\n"),
2522    )?;
2523
2524    let work_tree_abs = fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
2525    let git_dir_abs = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
2526    let config_content = format!(
2527        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n\tworktree = {}\n",
2528        work_tree_abs.display()
2529    );
2530    fs::write(git_dir.join("config"), config_content)?;
2531    fs::write(
2532        git_dir.join("description"),
2533        "Unnamed repository; edit this file 'description' to name the repository.\n",
2534    )?;
2535
2536    let gitfile = work_tree.join(".git");
2537    fs::write(&gitfile, format!("gitdir: {}\n", git_dir_abs.display()))?;
2538
2539    Repository::open(git_dir, Some(work_tree))
2540}
2541
2542/// Recursively copy template files from `src` to `dst`.
2543fn copy_template(src: &Path, dst: &Path) -> Result<()> {
2544    for entry in fs::read_dir(src)? {
2545        let entry = entry?;
2546        let src_path = entry.path();
2547        let dst_path = dst.join(entry.file_name());
2548        if src_path.is_dir() {
2549            fs::create_dir_all(&dst_path)?;
2550            copy_template(&src_path, &dst_path)?;
2551        } else {
2552            fs::copy(&src_path, &dst_path)?;
2553        }
2554    }
2555    Ok(())
2556}
2557
2558/// Parse `GIT_CEILING_DIRECTORIES` into a list of absolute paths and whether
2559/// symlink resolution should be skipped.
2560///
2561/// The variable is colon-separated (`:`) on Unix.  Empty entries and
2562/// non-absolute paths are silently skipped, matching Git's behaviour.
2563///
2564/// A leading colon (`:path1:path2`) disables symlink resolution for all
2565/// ceiling paths AND the cwd used for comparison (Git `resolve_symlinks` flag).
2566fn parse_ceiling_directories() -> (Vec<PathBuf>, bool) {
2567    let raw = match env::var("GIT_CEILING_DIRECTORIES") {
2568        Ok(val) => val,
2569        Err(_) => return (Vec::new(), false),
2570    };
2571    if raw.is_empty() {
2572        return (Vec::new(), false);
2573    }
2574    // A leading colon means "don't resolve symlinks".
2575    let (no_resolve, effective) = if raw.starts_with(':') {
2576        (true, &raw[1..])
2577    } else {
2578        (false, raw.as_str())
2579    };
2580    let paths = effective
2581        .split(':')
2582        .filter(|s| !s.is_empty())
2583        .filter_map(|s| {
2584            let p = PathBuf::from(s);
2585            if !p.is_absolute() {
2586                return None;
2587            }
2588            if no_resolve {
2589                // Strip trailing slashes for consistent comparison but don't resolve symlinks.
2590                let s = s.trim_end_matches('/');
2591                Some(PathBuf::from(s))
2592            } else {
2593                // Canonicalize to resolve symlinks; fall back to the raw path
2594                // (with trailing slashes stripped) when the directory doesn't exist.
2595                Some(p.canonicalize().unwrap_or_else(|_| {
2596                    let s = s.trim_end_matches('/');
2597                    PathBuf::from(s)
2598                }))
2599            }
2600        })
2601        .collect();
2602    (paths, no_resolve)
2603}
2604
2605/// Validate the repository format version from config text.
2606/// Returns Ok if the format is supported, Err with message if not.
2607pub fn validate_repo_config(config_text: &str) -> std::result::Result<(), String> {
2608    let mut version: u32 = 0;
2609    let mut in_core = false;
2610    for line in config_text.lines() {
2611        let trimmed = line.trim();
2612        if trimmed.starts_with('[') {
2613            in_core = trimmed.to_lowercase().starts_with("[core");
2614            continue;
2615        }
2616        if in_core {
2617            if let Some(rest) = trimmed.strip_prefix("repositoryformatversion") {
2618                let val = rest.trim_start_matches([' ', '=']).trim();
2619                if let Ok(v) = val.parse::<u32>() {
2620                    version = v;
2621                }
2622            }
2623        }
2624    }
2625    if version >= 2 {
2626        return Err(format!("unknown repository format version: {version}"));
2627    }
2628    Ok(())
2629}