Skip to main content

grit_lib/
repo.rs

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