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