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