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        self.finalize_sparse_index_if_needed(index)?;
622        let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
623        let skip_hash = crate::index::index_skip_hash_for_write(Some(&cfg));
624        write_index_file_split(path, &self.git_dir, index, &cfg, split, skip_hash)?;
625        // Git `write_locked_index`: `post-index-change` after a successful index write (t1800).
626        let updated_workdir_arg = if updated_workdir { "1" } else { "0" };
627        let updated_skipworktree_arg = if updated_skipworktree { "1" } else { "0" };
628        let _ = run_hook(
629            self,
630            "post-index-change",
631            &[updated_workdir_arg, updated_skipworktree_arg],
632            None,
633        );
634        Ok(())
635    }
636
637    fn finalize_sparse_index_if_needed(&self, index: &mut Index) -> Result<()> {
638        let cfg = ConfigSet::load(Some(&self.git_dir), true).unwrap_or_default();
639        let sparse_enabled = cfg
640            .get("core.sparseCheckout")
641            .map(|v| v == "true")
642            .unwrap_or(false);
643        if !sparse_enabled {
644            index.sparse_directories = false;
645            return Ok(());
646        }
647        let cone_cfg = cfg
648            .get("core.sparseCheckoutCone")
649            .and_then(|v| v.parse::<bool>().ok())
650            .unwrap_or(true);
651        let sparse_ix = cfg
652            .get("index.sparse")
653            .map(|v| v == "true")
654            .unwrap_or(false);
655        let patterns = read_sparse_checkout_patterns(&self.git_dir);
656        let cone = effective_cone_mode_for_sparse_file(cone_cfg, &patterns);
657        let head = resolve_head(&self.git_dir)?;
658        let tree_oid = if let Some(oid) = head.oid() {
659            let obj = self.odb.read(oid)?;
660            let commit = parse_commit(&obj.data)?;
661            Some(commit.tree)
662        } else {
663            None
664        };
665        if let Some(t) = tree_oid {
666            index.try_collapse_sparse_directories(&self.odb, &t, &patterns, cone, sparse_ix)?;
667        } else {
668            index.sparse_directories = false;
669        }
670        Ok(())
671    }
672
673    /// Path to the `refs/` directory.
674    #[must_use]
675    pub fn refs_dir(&self) -> PathBuf {
676        self.git_dir.join("refs")
677    }
678
679    /// Path to `HEAD`.
680    #[must_use]
681    pub fn head_path(&self) -> PathBuf {
682        self.git_dir.join("HEAD")
683    }
684
685    /// Relative path from the work tree root to the process current directory, `/`-separated.
686    ///
687    /// Used for `:(top)` / `:/` pathspec Bloom lookups. Returns `None` for bare repositories or
688    /// when paths cannot be resolved; callers should treat `None` like an empty prefix.
689    #[must_use]
690    pub fn bloom_pathspec_cwd(&self) -> Option<String> {
691        let wt = self.work_tree.as_ref()?;
692        let cwd = env::current_dir().ok()?;
693        let wt = wt.canonicalize().ok()?;
694        let cwd = cwd.canonicalize().ok()?;
695        let rel = cwd.strip_prefix(&wt).ok()?;
696        let s = rel.to_string_lossy().replace('\\', "/");
697        let s = s.trim_start_matches('/').to_string();
698        Some(s)
699    }
700
701    /// Whether this is a bare repository (no working tree).
702    #[must_use]
703    pub fn is_bare(&self) -> bool {
704        if let Ok(cfg) = ConfigSet::load(Some(&self.git_dir), true) {
705            if let Some(Ok(bare)) = cfg.get_bool("core.bare") {
706                return bare;
707            }
708        }
709        self.work_tree.is_none()
710    }
711
712    /// Read an object, transparently following replace refs.
713    ///
714    /// If `refs/replace/<hex>` exists for the requested OID and
715    /// `GIT_NO_REPLACE_OBJECTS` is **not** set, this reads the
716    /// replacement object instead.  Otherwise it behaves identically
717    /// to `self.odb.read(oid)`.
718    pub fn read_replaced(&self, oid: &crate::objects::ObjectId) -> Result<crate::objects::Object> {
719        if std::env::var_os("GIT_NO_REPLACE_OBJECTS").is_some() {
720            return self.odb.read(oid);
721        }
722        let settings = self.cached_settings();
723        if !settings.use_replace_refs {
724            return self.odb.read(oid);
725        }
726        let replace_ref =
727            self.git_dir
728                .join(format!("{}{}", settings.replace_ref_base, oid.to_hex()));
729        if replace_ref.is_file() {
730            if let Ok(content) = std::fs::read_to_string(&replace_ref) {
731                let hex = content.trim();
732                if let Ok(replacement_oid) = hex.parse::<crate::objects::ObjectId>() {
733                    if let Ok(obj) = self.odb.read(&replacement_oid) {
734                        return Ok(obj);
735                    }
736                }
737            }
738        }
739        self.odb.read(oid)
740    }
741}
742
743/// If `GIT_TRACE_SETUP` is an absolute path, append `setup:` lines (Git test format).
744///
745/// Upstream tests grep `^setup: ` from the trace file; they do not use the timestamped
746/// `trace.c:` prefix that full Git tracing adds.
747pub fn trace_repo_setup_if_requested(repo: &Repository) -> std::io::Result<()> {
748    let Ok(path) = env::var("GIT_TRACE_SETUP") else {
749        return Ok(());
750    };
751    if path.is_empty() || path == "0" {
752        return Ok(());
753    }
754    let trace_path = Path::new(&path);
755    if !trace_path.is_absolute() {
756        return Ok(());
757    }
758
759    let actual_cwd = env::current_dir()?;
760    let actual_cwd = actual_cwd
761        .canonicalize()
762        .unwrap_or_else(|_| actual_cwd.clone());
763
764    // After setup, Git's traced `cwd` is the worktree root when the process cwd started inside
765    // the worktree, but stays at the real cwd when outside (t1510 nephew cases).
766    let (trace_cwd, prefix) = if let Some(ref wt) = repo.work_tree {
767        let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.clone());
768        if actual_cwd.starts_with(&wt_canon) {
769            let rel = actual_cwd
770                .strip_prefix(&wt_canon)
771                .map(|p| p.to_path_buf())
772                .unwrap_or_default();
773            let prefix = if rel.as_os_str().is_empty() {
774                "(null)".to_owned()
775            } else {
776                let mut s = rel.to_string_lossy().replace('\\', "/");
777                if !s.ends_with('/') {
778                    s.push('/');
779                }
780                s
781            };
782            (wt_canon, prefix)
783        } else {
784            (actual_cwd.clone(), "(null)".to_owned())
785        }
786    } else {
787        (actual_cwd.clone(), "(null)".to_owned())
788    };
789
790    let git_dir_display =
791        display_git_dir_for_setup_trace(repo, &trace_cwd, &actual_cwd, prefix.as_str());
792    let common_display = display_common_dir_for_setup_trace(
793        repo,
794        &trace_cwd,
795        &actual_cwd,
796        prefix.as_str(),
797        &git_dir_display,
798    );
799    let worktree_display = repo
800        .work_tree
801        .as_ref()
802        .map(|p| {
803            p.canonicalize()
804                .unwrap_or_else(|_| lexical_normalize_path(p))
805                .display()
806                .to_string()
807        })
808        .unwrap_or_else(|| "(null)".to_owned());
809
810    let mut f = OpenOptions::new()
811        .create(true)
812        .append(true)
813        .open(trace_path)?;
814    writeln!(f, "setup: git_dir: {git_dir_display}")?;
815    writeln!(f, "setup: git_common_dir: {common_display}")?;
816    writeln!(f, "setup: worktree: {worktree_display}")?;
817    writeln!(f, "setup: cwd: {}", trace_cwd.display())?;
818    writeln!(f, "setup: prefix: {prefix}")?;
819    Ok(())
820}
821
822/// Collapse `.` / `..` in a path for display when `canonicalize()` fails (e.g. non-existent `..` segments).
823fn lexical_normalize_path(path: &Path) -> PathBuf {
824    let mut out = PathBuf::new();
825    let mut absolute = false;
826    for c in path.components() {
827        match c {
828            Component::Prefix(p) => {
829                out.push(p.as_os_str());
830            }
831            Component::RootDir => {
832                absolute = true;
833                out.push(c.as_os_str());
834            }
835            Component::CurDir => {}
836            Component::ParentDir => {
837                if absolute {
838                    let _ = out.pop();
839                } else if !out.pop() {
840                    out.push("..");
841                }
842            }
843            Component::Normal(s) => out.push(s),
844        }
845    }
846    if out.as_os_str().is_empty() {
847        PathBuf::from(".")
848    } else {
849        out
850    }
851}
852
853/// Path from `base` to `target` using `..` segments when needed (matches Git setup traces).
854fn path_relative_to(target: &Path, base: &Path) -> Option<PathBuf> {
855    let t = target.canonicalize().ok()?;
856    let b = base.canonicalize().ok()?;
857    let tc: Vec<_> = t.components().collect();
858    let bc: Vec<_> = b.components().collect();
859    let mut i = 0usize;
860    while i < tc.len() && i < bc.len() && tc[i] == bc[i] {
861        i += 1;
862    }
863    let up = bc.len().saturating_sub(i);
864    let mut out = PathBuf::new();
865    for _ in 0..up {
866        out.push("..");
867    }
868    for comp in &tc[i..] {
869        out.push(comp.as_os_str());
870    }
871    Some(out)
872}
873
874fn rel_path_for_setup_trace(target: &Path, trace_cwd: &Path) -> String {
875    let t = target
876        .canonicalize()
877        .unwrap_or_else(|_| target.to_path_buf());
878    let tc = trace_cwd
879        .canonicalize()
880        .unwrap_or_else(|_| trace_cwd.to_path_buf());
881    if let Some(rel) = path_relative_to(&t, &tc) {
882        let s = rel.to_string_lossy().replace('\\', "/");
883        return if s.is_empty() || s == "." {
884            ".".to_owned()
885        } else {
886            s
887        };
888    }
889    t.display().to_string()
890}
891
892fn trace_cwd_strictly_inside_git_parent(trace_cwd: &Path, git_dir: &Path) -> bool {
893    let tc = trace_cwd
894        .canonicalize()
895        .unwrap_or_else(|_| trace_cwd.to_path_buf());
896    let gd = git_dir
897        .canonicalize()
898        .unwrap_or_else(|_| git_dir.to_path_buf());
899    let Some(parent) = gd.parent() else {
900        return false;
901    };
902    let parent = parent.to_path_buf();
903    if tc == parent {
904        return false;
905    }
906    tc.starts_with(&parent) && tc != parent
907}
908
909fn display_git_dir_for_setup_trace(
910    repo: &Repository,
911    trace_cwd: &Path,
912    actual_cwd: &Path,
913    setup_prefix: &str,
914) -> String {
915    let gd = repo
916        .git_dir
917        .canonicalize()
918        .unwrap_or_else(|_| repo.git_dir.clone());
919    let tc = trace_cwd
920        .canonicalize()
921        .unwrap_or_else(|_| trace_cwd.to_path_buf());
922    let ac = actual_cwd
923        .canonicalize()
924        .unwrap_or_else(|_| actual_cwd.to_path_buf());
925
926    // Bare repo discovered without `GIT_DIR`: cwd inside the git directory (t1510 #16).
927    // Trace uses `.` at the git-dir root and the absolute git-dir path from subdirectories.
928    if repo.work_tree.is_none() && !repo.explicit_git_dir {
929        if ac == gd {
930            return ".".to_owned();
931        }
932        if ac.starts_with(&gd) && ac != gd {
933            return gd.display().to_string();
934        }
935    }
936
937    // Non-bare repo with `core.worktree` while cwd is inside the git-dir (t1510 #20a).
938    if !repo.explicit_git_dir {
939        if let Some(wt) = &repo.work_tree {
940            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
941            if ac.starts_with(&gd) && ac != wt {
942                return gd.display().to_string();
943            }
944        }
945    }
946
947    // `GIT_DIR` set: Git's `set_git_dir(gitdirenv, make_realpath)` keeps a relative
948    // `gitdirenv` only when cwd is at the worktree root or outside the worktree; from a
949    // subdirectory it realpath()s to an absolute path (see `setup.c` / t1510).
950    if repo.explicit_git_dir {
951        if repo.work_tree.is_none() {
952            if let Ok(raw) = env::var("GIT_DIR") {
953                let p = Path::new(raw.trim());
954                if p.is_absolute() {
955                    return gd.display().to_string();
956                }
957                let joined = ac.join(p);
958                if joined.is_file() {
959                    return gd.display().to_string();
960                }
961                if let Some(rel) = path_relative_to(&gd, &tc) {
962                    let s = rel.to_string_lossy().replace('\\', "/");
963                    return if s.is_empty() || s == "." {
964                        ".".to_owned()
965                    } else {
966                        s
967                    };
968                }
969            }
970            return gd.display().to_string();
971        }
972        if let Some(wt) = &repo.work_tree {
973            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
974            let strictly_inside_wt = ac.starts_with(&wt) && ac != wt;
975            if strictly_inside_wt {
976                return gd.display().to_string();
977            }
978            if let Ok(raw) = env::var("GIT_DIR") {
979                let p = Path::new(raw.trim());
980                if p.is_relative() {
981                    let joined = ac.join(p);
982                    if joined.is_file() {
983                        // `GIT_DIR` points at a gitfile; trace shows the resolved git dir.
984                        return gd.display().to_string();
985                    }
986                    if let Some(rel) = path_relative_to(&gd, &tc) {
987                        let s = rel.to_string_lossy().replace('\\', "/");
988                        return if s.is_empty() || s == "." {
989                            ".".to_owned()
990                        } else {
991                            s
992                        };
993                    }
994                }
995                return gd.display().to_string();
996            }
997        }
998        if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
999            return rel_path_for_setup_trace(&gd, trace_cwd);
1000        }
1001        return gd.display().to_string();
1002    }
1003
1004    let work_relocated = match (&repo.discovery_root, &repo.work_tree) {
1005        (Some(root), Some(wt)) if !repo.work_tree_from_env => {
1006            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
1007            let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1008            r != w
1009        }
1010        _ => false,
1011    };
1012
1013    if repo.work_tree_from_env {
1014        if !repo.discovery_via_gitfile {
1015            if setup_prefix == "(null)" {
1016                if let (Some(root), Some(wt)) = (&repo.discovery_root, &repo.work_tree) {
1017                    let r = root.canonicalize().unwrap_or_else(|_| root.clone());
1018                    let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1019                    if r == w {
1020                        let dot_git = r.join(".git");
1021                        let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
1022                        if gd == dot_git {
1023                            return ".git".to_owned();
1024                        }
1025                    }
1026                }
1027            }
1028            if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
1029                return rel_path_for_setup_trace(&gd, trace_cwd);
1030            }
1031        }
1032        return gd.display().to_string();
1033    }
1034
1035    if work_relocated {
1036        if let Some(wt) = &repo.work_tree {
1037            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1038            if ac == wt {
1039                return gd.display().to_string();
1040            }
1041            let inside_wt = ac.starts_with(&wt) && ac != wt;
1042            if inside_wt {
1043                if let Some(rel) = path_relative_to(&gd, &ac) {
1044                    let s = rel.to_string_lossy().replace('\\', "/");
1045                    return if s.is_empty() || s == "." {
1046                        ".".to_owned()
1047                    } else {
1048                        s
1049                    };
1050                }
1051            }
1052        }
1053    }
1054    if repo.work_tree.is_some() {
1055        if let Some(root) = &repo.discovery_root {
1056            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
1057            let dot_git = r.join(".git");
1058            let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
1059            if gd == dot_git {
1060                return ".git".to_owned();
1061            }
1062        } else if let Some(wt) = &repo.work_tree {
1063            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1064            let dot_git = wt.join(".git");
1065            let dot_git = dot_git.canonicalize().unwrap_or(dot_git);
1066            if gd == dot_git {
1067                return ".git".to_owned();
1068            }
1069        }
1070    }
1071
1072    if repo.discovery_via_gitfile && !repo.explicit_git_dir {
1073        return gd.display().to_string();
1074    }
1075
1076    // Bare repo whose git-dir is `parent/.git`: at `parent` the trace shows `.git`; from a
1077    // subdirectory of `parent` that is still outside `.git`, Git uses the absolute git-dir (t1510
1078    // #16c sub/ case — not `../.git`).
1079    if repo.work_tree.is_none() && !repo.explicit_git_dir {
1080        if let Some(gp) = gd.parent() {
1081            let gp = gp.canonicalize().unwrap_or_else(|_| gp.to_path_buf());
1082            let gdc = gd.canonicalize().unwrap_or_else(|_| gd.clone());
1083            if tc.starts_with(&gp) && tc != gp && !tc.starts_with(&gdc) {
1084                return gdc.display().to_string();
1085            }
1086            if tc == gp {
1087                return rel_path_for_setup_trace(&gd, trace_cwd);
1088            }
1089        }
1090    }
1091
1092    if trace_cwd_strictly_inside_git_parent(trace_cwd, &gd) {
1093        rel_path_for_setup_trace(&gd, trace_cwd)
1094    } else {
1095        gd.display().to_string()
1096    }
1097}
1098
1099fn display_common_dir_for_setup_trace(
1100    repo: &Repository,
1101    trace_cwd: &Path,
1102    actual_cwd: &Path,
1103    _setup_prefix: &str,
1104    git_dir_display: &str,
1105) -> String {
1106    let gd = repo
1107        .git_dir
1108        .canonicalize()
1109        .unwrap_or_else(|_| repo.git_dir.clone());
1110    let Some(common) = resolve_common_dir(&gd) else {
1111        return git_dir_display.to_owned();
1112    };
1113    let common = common.canonicalize().unwrap_or(common);
1114    if common == gd {
1115        return git_dir_display.to_owned();
1116    }
1117
1118    let ac = actual_cwd
1119        .canonicalize()
1120        .unwrap_or_else(|_| actual_cwd.to_path_buf());
1121    if repo.work_tree.is_none() && !repo.explicit_git_dir {
1122        if ac == common {
1123            return ".".to_owned();
1124        }
1125        if ac.starts_with(&common) && ac != common {
1126            return common.display().to_string();
1127        }
1128    }
1129
1130    let work_relocated = match (&repo.discovery_root, &repo.work_tree) {
1131        (Some(root), Some(wt)) if !repo.work_tree_from_env => {
1132            let r = root.canonicalize().unwrap_or_else(|_| root.clone());
1133            let w = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1134            r != w
1135        }
1136        _ => false,
1137    };
1138    if work_relocated {
1139        if let Some(wt) = &repo.work_tree {
1140            let wt = wt.canonicalize().unwrap_or_else(|_| wt.clone());
1141            if ac == wt {
1142                return common.display().to_string();
1143            }
1144            let inside_wt = ac.starts_with(&wt) && ac != wt;
1145            if inside_wt {
1146                if let Some(rel) = path_relative_to(&common, &ac) {
1147                    let s = rel.to_string_lossy().replace('\\', "/");
1148                    return if s.is_empty() || s == "." {
1149                        ".".to_owned()
1150                    } else {
1151                        s
1152                    };
1153                }
1154            }
1155        }
1156    }
1157
1158    if repo.discovery_via_gitfile && !repo.explicit_git_dir {
1159        return common.display().to_string();
1160    }
1161
1162    if repo.work_tree.is_none() && !repo.explicit_git_dir {
1163        let tc = trace_cwd
1164            .canonicalize()
1165            .unwrap_or_else(|_| trace_cwd.to_path_buf());
1166        if let Some(cp) = common.parent() {
1167            let cp = cp.canonicalize().unwrap_or_else(|_| cp.to_path_buf());
1168            let comc = common.canonicalize().unwrap_or_else(|_| common.clone());
1169            if tc.starts_with(&cp) && tc != cp && !tc.starts_with(&comc) {
1170                return comc.display().to_string();
1171            }
1172            if tc == cp {
1173                return rel_path_for_setup_trace(&common, trace_cwd);
1174            }
1175        }
1176    }
1177
1178    if trace_cwd_strictly_inside_git_parent(trace_cwd, &common) {
1179        rel_path_for_setup_trace(&common, trace_cwd)
1180    } else {
1181        common.display().to_string()
1182    }
1183}
1184
1185/// Resolve the common git directory for linked worktrees.
1186fn resolve_common_dir(git_dir: &Path) -> Option<PathBuf> {
1187    let common_raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
1188    let common_rel = common_raw.trim();
1189    if common_rel.is_empty() {
1190        return None;
1191    }
1192    let common_dir = if Path::new(common_rel).is_absolute() {
1193        PathBuf::from(common_rel)
1194    } else {
1195        git_dir.join(common_rel)
1196    };
1197    Some(common_dir.canonicalize().unwrap_or(common_dir))
1198}
1199
1200/// Directory holding `config` for early-config reads (`commondir` when present).
1201#[must_use]
1202pub fn common_git_dir_for_config(git_dir: &Path) -> PathBuf {
1203    resolve_common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf())
1204}
1205
1206/// True when `extensions.worktreeConfig` is enabled in the common `config`.
1207pub fn worktree_config_enabled(common_dir: &Path) -> bool {
1208    let path = common_dir.join("config");
1209    let Ok(content) = fs::read_to_string(&path) else {
1210        return false;
1211    };
1212    let mut in_extensions = false;
1213    for raw_line in content.lines() {
1214        let mut line = raw_line.trim();
1215        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1216            continue;
1217        }
1218        if line.starts_with('[') {
1219            let Some(end_idx) = line.find(']') else {
1220                continue;
1221            };
1222            let section = line[1..end_idx].trim();
1223            let section_name = section
1224                .split_whitespace()
1225                .next()
1226                .unwrap_or_default()
1227                .to_ascii_lowercase();
1228            in_extensions = section_name == "extensions";
1229            let remainder = line[end_idx + 1..].trim();
1230            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1231                continue;
1232            }
1233            line = remainder;
1234        }
1235        if in_extensions {
1236            let Some((key, value)) = line.split_once('=') else {
1237                continue;
1238            };
1239            if key.trim().eq_ignore_ascii_case("worktreeconfig") {
1240                let v = value.trim();
1241                return v.eq_ignore_ascii_case("true")
1242                    || v.eq_ignore_ascii_case("yes")
1243                    || v.eq_ignore_ascii_case("on")
1244                    || v == "1";
1245            }
1246        }
1247    }
1248    false
1249}
1250
1251fn open_or_create_config_file(path: &Path, scope: ConfigScope) -> Result<ConfigFile> {
1252    match ConfigFile::from_path(path, scope)? {
1253        Some(f) => Ok(f),
1254        None => {
1255            if let Some(parent) = path.parent() {
1256                fs::create_dir_all(parent).map_err(Error::Io)?;
1257            }
1258            ConfigFile::parse(path, "", scope)
1259        }
1260    }
1261}
1262
1263fn config_file_bool_true(cfg: &ConfigFile, key: &str) -> bool {
1264    cfg.get(key).is_some_and(|v| {
1265        matches!(
1266            v.trim().to_ascii_lowercase().as_str(),
1267            "true" | "yes" | "on" | "1"
1268        )
1269    })
1270}
1271
1272/// Enable per-worktree configuration (`extensions.worktreeConfig`) and create
1273/// `config.worktree`, matching Git's `init_worktree_config` in `worktree.c`.
1274///
1275/// When `core.bare` is true or `core.worktree` is set in the common config,
1276/// those keys are moved into `config.worktree` so linked worktrees keep working.
1277///
1278/// # Errors
1279///
1280/// Returns [`Error::Io`] or [`Error::ConfigError`] if config files cannot be read or written.
1281pub fn init_worktree_config(git_dir: &Path) -> Result<()> {
1282    let common_dir = common_git_dir_for_config(git_dir);
1283    let common_config_path = common_dir.join("config");
1284    let worktree_config_path = git_dir.join("config.worktree");
1285
1286    if worktree_config_enabled(&common_dir) {
1287        if !worktree_config_path.exists() {
1288            if let Some(parent) = worktree_config_path.parent() {
1289                fs::create_dir_all(parent).map_err(Error::Io)?;
1290            }
1291            fs::write(&worktree_config_path, "").map_err(Error::Io)?;
1292        }
1293        return Ok(());
1294    }
1295
1296    let mut common_cfg = open_or_create_config_file(&common_config_path, ConfigScope::Local)?;
1297    common_cfg.set("extensions.worktreeConfig", "true")?;
1298
1299    let mut wt_cfg = open_or_create_config_file(&worktree_config_path, ConfigScope::Worktree)?;
1300
1301    if config_file_bool_true(&common_cfg, "core.bare") {
1302        wt_cfg.set("core.bare", "true")?;
1303        common_cfg.unset("core.bare")?;
1304    }
1305    if let Some(worktree) = common_cfg.get("core.worktree") {
1306        wt_cfg.set("core.worktree", &worktree)?;
1307        common_cfg.unset("core.worktree")?;
1308    }
1309
1310    common_cfg.write()?;
1311    wt_cfg.write()?;
1312    Ok(())
1313}
1314
1315/// If the common `config` declares a repository format newer than Git's
1316/// `GIT_REPO_VERSION_READ`, return the human message Git prints for
1317/// `discover_git_directory_reason` / t1309.
1318pub fn early_config_ignore_repo_reason(common_dir: &Path) -> Option<String> {
1319    const GIT_REPO_VERSION_READ: u32 = 1;
1320    let path = common_dir.join("config");
1321    let content = fs::read_to_string(&path).ok()?;
1322    let mut version = 0u32;
1323    let mut in_core = false;
1324    for raw_line in content.lines() {
1325        let mut line = raw_line.trim();
1326        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1327            continue;
1328        }
1329        if line.starts_with('[') {
1330            let Some(end_idx) = line.find(']') else {
1331                continue;
1332            };
1333            let section = line[1..end_idx].trim();
1334            let section_name = section
1335                .split_whitespace()
1336                .next()
1337                .unwrap_or_default()
1338                .to_ascii_lowercase();
1339            in_core = section_name == "core";
1340            let remainder = line[end_idx + 1..].trim();
1341            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1342                continue;
1343            }
1344            line = remainder;
1345        }
1346        if in_core {
1347            if let Some((key, value)) = line.split_once('=') {
1348                if key.trim().eq_ignore_ascii_case("repositoryformatversion") {
1349                    if let Ok(v) = value.trim().parse::<u32>() {
1350                        version = v;
1351                    }
1352                }
1353            }
1354        }
1355    }
1356    if version > GIT_REPO_VERSION_READ {
1357        Some(format!(
1358            "Expected git repo version <= {GIT_REPO_VERSION_READ}, found {version}"
1359        ))
1360    } else {
1361        None
1362    }
1363}
1364
1365fn path_for_ceiling_compare(path: &Path) -> String {
1366    let path = path.to_string_lossy();
1367    #[cfg(windows)]
1368    {
1369        path.replace('\\', "/")
1370    }
1371    #[cfg(not(windows))]
1372    {
1373        path.into_owned()
1374    }
1375}
1376
1377fn offset_1st_component(path: &str) -> usize {
1378    if path.starts_with('/') {
1379        1
1380    } else {
1381        0
1382    }
1383}
1384
1385/// Git `longest_ancestor_length`: longest strict ancestor prefix among ceilings.
1386fn longest_ancestor_length(path: &str, ceilings: &[String]) -> Option<usize> {
1387    if path == "/" {
1388        return None;
1389    }
1390    let mut max_len: Option<usize> = None;
1391    for ceil in ceilings {
1392        let mut len = ceil.len();
1393        while len > 0 && ceil.as_bytes().get(len - 1) == Some(&b'/') {
1394            len -= 1;
1395        }
1396        if len == 0 {
1397            continue;
1398        }
1399        if path.len() <= len + 1 {
1400            continue;
1401        }
1402        if !path.starts_with(&ceil[..len]) {
1403            continue;
1404        }
1405        if path.as_bytes().get(len) != Some(&b'/') {
1406            continue;
1407        }
1408        if path.as_bytes().get(len + 1).is_none() {
1409            continue;
1410        }
1411        max_len = Some(max_len.map_or(len, |m| m.max(len)));
1412    }
1413    max_len
1414}
1415
1416/// Determine the config file path for a repository or linked worktree.
1417fn repository_config_path(git_dir: &Path) -> Option<PathBuf> {
1418    let local = git_dir.join("config");
1419    if local.exists() {
1420        return Some(local);
1421    }
1422    let common = resolve_common_dir(git_dir)?;
1423    let shared = common.join("config");
1424    if shared.exists() {
1425        Some(shared)
1426    } else {
1427        None
1428    }
1429}
1430
1431/// Validate core repository format/version compatibility.
1432///
1433/// Supports repository format versions 0 and 1, with extension handling that
1434/// matches Git's compatibility expectations in upstream repo-version tests.
1435/// Public wrapper for validate_repository_format.
1436pub fn validate_repo_format(git_dir: &Path) -> Result<()> {
1437    validate_repository_format(git_dir)
1438}
1439
1440fn validate_repository_format(git_dir: &Path) -> Result<()> {
1441    let Some(config_path) = repository_config_path(git_dir) else {
1442        return Ok(());
1443    };
1444
1445    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
1446    let parsed = parse_repository_format(&content, &config_path)?;
1447
1448    if parsed.repo_version > 1 {
1449        return Err(Error::UnsupportedRepositoryFormatVersion(
1450            parsed.repo_version,
1451        ));
1452    }
1453
1454    if let Some(raw) = parsed.ref_storage.as_deref() {
1455        let lower = raw.to_ascii_lowercase();
1456        let name = lower
1457            .split_once(':')
1458            .map(|(prefix, _)| prefix)
1459            .unwrap_or(lower.as_str());
1460        if !matches!(name, "files" | "reftable") {
1461            return Err(Error::Message(format!(
1462                "error: invalid value for 'extensions.refstorage': '{raw}'"
1463            )));
1464        }
1465    }
1466
1467    if let Some(msg) = parsed.format_error_message() {
1468        return Err(Error::Message(msg));
1469    }
1470
1471    Ok(())
1472}
1473
1474/// The result of parsing `core.repositoryformatversion` and `extensions.*` from a
1475/// repository's `config` file, mirroring git/setup.c `check_repo_format`.
1476struct RepositoryFormat {
1477    /// Declared `core.repositoryformatversion` (defaults to 0; invalid values ignored).
1478    repo_version: u32,
1479    /// All extension keys (lowercased) declared under `[extensions]`.
1480    extensions: BTreeSet<String>,
1481    /// Raw value of `extensions.refstorage`, if present.
1482    ref_storage: Option<String>,
1483}
1484
1485impl RepositoryFormat {
1486    /// Build git's `verify_repository_format` warning/error message for unsupported
1487    /// extension declarations, or `None` if the extensions are all acceptable.
1488    ///
1489    /// This does not cover the `core.repositoryformatversion > 1` case; callers that
1490    /// need the version message handle it separately via
1491    /// [`repository_format_warning`].
1492    fn format_error_message(&self) -> Option<String> {
1493        // Mirror git/setup.c `check_repo_format` / `verify_repository_format`. Extensions
1494        // split into:
1495        //   * v0-compatible (`handle_extension_v0`): respected even in a v0 repository.
1496        //   * v1-only (`handle_extension`): legal only when `core.repositoryformatversion >= 1`.
1497        // A v0 repository that declares any v1-only extension is rejected (t0001 #60, #62);
1498        // an unknown extension is rejected only in a v1 repository.
1499        let mut v1_only_found: Vec<&str> = Vec::new();
1500        let mut unknown_found: Vec<&str> = Vec::new();
1501        for extension in &self.extensions {
1502            match extension.as_str() {
1503                // v0-compatible extensions — always allowed.
1504                "noop" | "preciousobjects" | "partialclone" | "worktreeconfig" => {}
1505                // v1-only extensions — only valid with repository format version >= 1.
1506                "noop-v1"
1507                | "objectformat"
1508                | "compatobjectformat"
1509                | "refstorage"
1510                | "relativeworktrees"
1511                | "submodulepathconfig" => {
1512                    if self.repo_version == 0 {
1513                        v1_only_found.push(extension);
1514                    }
1515                }
1516                // Unknown extension — rejected only in a v1 repository.
1517                _ => {
1518                    if self.repo_version >= 1 {
1519                        unknown_found.push(extension);
1520                    }
1521                }
1522            }
1523        }
1524
1525        if !unknown_found.is_empty() {
1526            let mut msg = if unknown_found.len() == 1 {
1527                "unknown repository extension found:".to_owned()
1528            } else {
1529                "unknown repository extensions found:".to_owned()
1530            };
1531            for ext in &unknown_found {
1532                msg.push_str(&format!("\n\t{ext}"));
1533            }
1534            return Some(msg);
1535        }
1536
1537        if !v1_only_found.is_empty() {
1538            let mut msg = if v1_only_found.len() == 1 {
1539                "repo version is 0, but v1-only extension found:".to_owned()
1540            } else {
1541                "repo version is 0, but v1-only extensions found:".to_owned()
1542            };
1543            for ext in &v1_only_found {
1544                msg.push_str(&format!("\n\t{ext}"));
1545            }
1546            return Some(msg);
1547        }
1548
1549        None
1550    }
1551}
1552
1553/// Parse `core.repositoryformatversion` and `[extensions]` entries out of raw
1554/// `config` file contents.
1555///
1556/// # Errors
1557///
1558/// Returns [`Error::ConfigError`] if a section header is malformed (no closing `]`).
1559fn parse_repository_format(content: &str, config_path: &Path) -> Result<RepositoryFormat> {
1560    let mut in_core = false;
1561    let mut in_extensions = false;
1562    let mut repo_version = 0u32;
1563    let mut extensions = BTreeSet::new();
1564    let mut ref_storage: Option<String> = None;
1565
1566    for raw_line in content.lines() {
1567        let mut line = raw_line.trim();
1568        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1569            continue;
1570        }
1571
1572        if line.starts_with('[') {
1573            let Some(end_idx) = line.find(']') else {
1574                return Err(Error::ConfigError(format!(
1575                    "invalid config in {}",
1576                    config_path.display()
1577                )));
1578            };
1579
1580            let section = line[1..end_idx].trim();
1581            let section_name = section
1582                .split_whitespace()
1583                .next()
1584                .unwrap_or_default()
1585                .to_ascii_lowercase();
1586            in_core = section_name == "core";
1587            in_extensions = section_name == "extensions";
1588
1589            let remainder = line[end_idx + 1..].trim();
1590            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
1591                continue;
1592            }
1593            line = remainder;
1594        }
1595
1596        if in_core {
1597            if let Some((key, value)) = line.split_once('=') {
1598                if key.trim().eq_ignore_ascii_case("repositoryformatversion") {
1599                    // Match Git's `read_repository_format`: bad values are ignored (version stays 0).
1600                    if let Ok(v) = value.trim().parse::<u32>() {
1601                        repo_version = v;
1602                    }
1603                }
1604            }
1605        }
1606
1607        if in_extensions {
1608            let (key, value) = if let Some((key, value)) = line.split_once('=') {
1609                (key.trim(), Some(value.trim()))
1610            } else {
1611                (line, None)
1612            };
1613            if key.eq_ignore_ascii_case("refstorage") {
1614                ref_storage = value.map(str::to_owned);
1615            }
1616            if !key.is_empty() {
1617                extensions.insert(key.to_ascii_lowercase());
1618            }
1619        }
1620    }
1621
1622    Ok(RepositoryFormat {
1623        repo_version,
1624        extensions,
1625        ref_storage,
1626    })
1627}
1628
1629/// Return the warning message git would print for a repository whose `config`
1630/// declares an unsupported format, or `None` if the format is acceptable.
1631///
1632/// This mirrors git/setup.c `check_repository_format_gently` + `verify_repository_format`:
1633/// commands that run with `RUN_SETUP_GENTLY` (e.g. `git config`) emit this text as a
1634/// `warning:` and then behave as if no repository were present, which makes the command
1635/// fail with a non-zero exit. The returned string is the bare message without the
1636/// `warning: ` prefix.
1637///
1638/// `git_dir` is the resolved git directory; its `config` (or the common-dir `config` for
1639/// linked worktrees) is parsed. A missing config yields `None` (git treats it as ok).
1640///
1641/// # Errors
1642///
1643/// Returns [`Error::Io`] if the config file exists but cannot be read, or
1644/// [`Error::ConfigError`] if a section header is malformed.
1645pub fn repository_format_warning(git_dir: &Path) -> Result<Option<String>> {
1646    const GIT_REPO_VERSION_READ: u32 = 1;
1647    let Some(config_path) = repository_config_path(git_dir) else {
1648        return Ok(None);
1649    };
1650    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
1651    let parsed = parse_repository_format(&content, &config_path)?;
1652
1653    if parsed.repo_version > GIT_REPO_VERSION_READ {
1654        return Ok(Some(format!(
1655            "Expected git repo version <= {GIT_REPO_VERSION_READ}, found {}",
1656            parsed.repo_version
1657        )));
1658    }
1659
1660    Ok(parsed.format_error_message())
1661}
1662
1663/// Try to open a repository rooted exactly at `dir`.
1664///
1665/// Returns `Ok(None)` when `dir` is not a repository root (the caller should
1666/// walk up); returns `Err` on a structural problem.
1667/// Result of probing a single directory during [`Repository::discover`].
1668struct DiscoveredAt {
1669    repo: Repository,
1670    /// When discovery used a `.git` gitfile, the path to that file (for ownership checks).
1671    gitfile: Option<PathBuf>,
1672}
1673
1674fn try_open_at(dir: &Path) -> Result<Option<DiscoveredAt>> {
1675    let dot_git = dir.join(".git");
1676
1677    // Check for special file types (FIFO, socket, etc.) — reject them
1678    // instead of walking up to a parent repository.
1679    #[cfg(unix)]
1680    {
1681        use std::os::unix::fs::FileTypeExt;
1682        if let Ok(meta) = fs::symlink_metadata(&dot_git) {
1683            let ft = meta.file_type();
1684            if ft.is_fifo() || ft.is_socket() || ft.is_block_device() || ft.is_char_device() {
1685                return Err(Error::NotARepository(format!(
1686                    "invalid gitfile format: {} is not a regular file",
1687                    dot_git.display()
1688                )));
1689            }
1690            if ft.is_symlink() {
1691                if let Ok(target_meta) = fs::metadata(&dot_git) {
1692                    let tft = target_meta.file_type();
1693                    if tft.is_fifo()
1694                        || tft.is_socket()
1695                        || tft.is_block_device()
1696                        || tft.is_char_device()
1697                    {
1698                        return Err(Error::NotARepository(format!(
1699                            "invalid gitfile format: {} is not a regular file",
1700                            dot_git.display()
1701                        )));
1702                    }
1703                }
1704            }
1705        }
1706    }
1707
1708    if dot_git.is_file() {
1709        // gitfile indirection: file contains "gitdir: <path>"
1710        let content =
1711            fs::read_to_string(&dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
1712        let git_dir = parse_gitfile(&content, dir)?;
1713        let mut repo = Repository::open_skipping_format_validation(&git_dir, Some(dir))?;
1714        // Linked worktree: `core.worktree` in the common config may point at another directory
1715        // (t1501). When the process cwd is not inside that configured tree, Git uses the
1716        // discovery directory as the work tree (commondir overrides for ops under the real tree).
1717        if resolve_common_dir(&git_dir).is_some() {
1718            let cwd = env::current_dir().map_err(Error::Io)?;
1719            if repo.work_tree.is_some() && !is_inside_work_tree(&repo, &cwd) {
1720                let root = if dir.is_absolute() {
1721                    dir.to_path_buf()
1722                } else {
1723                    cwd.join(dir)
1724                };
1725                repo.work_tree = Some(root.canonicalize().unwrap_or(root));
1726            }
1727        }
1728        let root = if dir.is_absolute() {
1729            dir.to_path_buf()
1730        } else {
1731            env::current_dir().map_err(Error::Io)?.join(dir)
1732        };
1733        repo.discovery_root = Some(root.canonicalize().unwrap_or(root));
1734        repo.discovery_via_gitfile = true;
1735        warn_core_bare_worktree_conflict(&git_dir);
1736        return Ok(Some(DiscoveredAt {
1737            repo,
1738            gitfile: Some(dot_git.clone()),
1739        }));
1740    }
1741
1742    if dot_git.is_dir() {
1743        // If .git is a symlink to a directory, resolve the symlink target
1744        // for validation but keep the original .git path for user-facing output
1745        // (matches real git behavior: `rev-parse --git-dir` shows `.git`).
1746        let open_path = if dot_git.is_symlink() {
1747            // Resolve the symlink target for validation
1748            dot_git.read_link().unwrap_or_else(|_| dot_git.clone())
1749        } else {
1750            dot_git.clone()
1751        };
1752        // Try to open; if the directory is empty or invalid, continue
1753        // walking up (e.g. an empty .git/ directory should be ignored).
1754        match Repository::open_skipping_format_validation(&open_path, Some(dir)) {
1755            Ok(mut repo) => {
1756                // Restore the original path so rev-parse shows .git not the
1757                // resolved symlink target.
1758                if dot_git.is_symlink() {
1759                    let abs_dot_git = if dot_git.is_absolute() {
1760                        dot_git
1761                    } else {
1762                        dir.join(".git")
1763                    };
1764                    repo.git_dir = abs_dot_git;
1765                }
1766                let root = if dir.is_absolute() {
1767                    dir.to_path_buf()
1768                } else {
1769                    env::current_dir().map_err(Error::Io)?.join(dir)
1770                };
1771                repo.discovery_root = Some(root.canonicalize().unwrap_or(root));
1772                repo.discovery_via_gitfile = false;
1773                return Ok(Some(DiscoveredAt {
1774                    repo,
1775                    gitfile: None,
1776                }));
1777            }
1778            Err(Error::NotARepository(_)) | Err(Error::ConfigError(_)) => return Ok(None),
1779            Err(Error::Message(ref msg)) if msg.contains("bad config") => return Ok(None),
1780            Err(e) => return Err(e),
1781        }
1782    }
1783
1784    // Linked-worktree gitdir/admin directories contain HEAD and commondir,
1785    // and can be opened as repositories even without a local objects/ dir.
1786    if dir.join("HEAD").is_file() && dir.join("commondir").is_file() {
1787        maybe_trace_implicit_bare_repository(dir);
1788        let repo = Repository::open(dir, None)?;
1789        warn_core_bare_worktree_conflict(dir);
1790        return Ok(Some(DiscoveredAt {
1791            repo,
1792            gitfile: None,
1793        }));
1794    }
1795
1796    // Check if `dir` itself is a bare repo (has objects/ and HEAD directly)
1797    if dir.join("objects").is_dir() && dir.join("HEAD").is_file() {
1798        maybe_trace_implicit_bare_repository(dir);
1799        // Check safe.bareRepository policy before opening bare repos.
1800        // When set to "explicit", implicit bare repo discovery is forbidden
1801        // unless GIT_DIR was set (handled earlier in discover()).
1802        if !is_inside_dot_git(dir) {
1803            if let Ok(cfg) = crate::config::ConfigSet::load(None, true) {
1804                if let Some(val) = cfg.get("safe.bareRepository") {
1805                    if val.eq_ignore_ascii_case("explicit") {
1806                        return Err(Error::ForbiddenBareRepository(dir.display().to_string()));
1807                    }
1808                }
1809            }
1810        }
1811        let repo = Repository::open(dir, None)?;
1812        warn_core_bare_worktree_conflict(dir);
1813        return Ok(Some(DiscoveredAt {
1814            repo,
1815            gitfile: None,
1816        }));
1817    }
1818
1819    Ok(None)
1820}
1821
1822fn is_inside_dot_git(path: &Path) -> bool {
1823    path.components().any(|c| c.as_os_str() == ".git")
1824}
1825
1826fn maybe_trace_implicit_bare_repository(dir: &Path) {
1827    let path = match std::env::var("GIT_TRACE2_PERF") {
1828        Ok(p) if !p.is_empty() => p,
1829        _ => return,
1830    };
1831
1832    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
1833        let _ = writeln!(file, "setup: implicit-bare-repository:{}", dir.display());
1834    }
1835}
1836
1837/// Collect effective `safe.directory` values from protected config (system/global/command),
1838/// applying empty-value resets like Git.
1839fn safe_directory_effective_values(git_dir: &Path) -> Vec<String> {
1840    let cfg = crate::config::ConfigSet::load(Some(git_dir), true)
1841        .unwrap_or_else(|_| crate::config::ConfigSet::new());
1842    let mut values: Vec<String> = Vec::new();
1843    for e in cfg.entries() {
1844        if e.key == "safe.directory"
1845            && e.scope != crate::config::ConfigScope::Local
1846            && e.scope != crate::config::ConfigScope::Worktree
1847        {
1848            values.push(e.value.clone().unwrap_or_else(|| "true".to_owned()));
1849        }
1850    }
1851    let mut effective: Vec<String> = Vec::new();
1852    for v in values {
1853        if v.is_empty() {
1854            effective.clear();
1855        } else {
1856            effective.push(v);
1857        }
1858    }
1859    effective
1860}
1861
1862fn ensure_safe_directory_allows(git_dir: &Path, checked: &Path) -> Result<()> {
1863    let effective = safe_directory_effective_values(git_dir);
1864    let checked_s = checked.to_string_lossy().to_string();
1865    if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
1866        eprintln!("debug-safe-directory values={:?}", effective);
1867    }
1868    if effective
1869        .iter()
1870        .any(|v| safe_directory_matches(v, &checked_s))
1871    {
1872        return Ok(());
1873    }
1874    Err(Error::DubiousOwnership(checked_s))
1875}
1876
1877#[cfg(unix)]
1878fn path_lstat_uid(path: &Path) -> std::io::Result<u32> {
1879    use std::os::unix::fs::MetadataExt;
1880    let meta = fs::symlink_metadata(path)?;
1881    Ok(meta.uid())
1882}
1883
1884#[cfg(unix)]
1885fn extract_uid_from_env(name: &str) -> Option<u32> {
1886    let raw = std::env::var(name).ok()?;
1887    if raw.is_empty() {
1888        return None;
1889    }
1890    raw.parse::<u32>().ok()
1891}
1892
1893/// Match Git's `ensure_valid_ownership`: check gitfile, worktree, and gitdir ownership,
1894/// then `safe.directory` when any path is not owned by the effective user.
1895#[cfg(unix)]
1896fn ensure_valid_ownership(
1897    gitfile: Option<&Path>,
1898    worktree: Option<&Path>,
1899    gitdir: &Path,
1900) -> Result<()> {
1901    const ROOT_UID: u32 = 0;
1902
1903    fn owned_by_effective_user(path: &Path) -> std::io::Result<bool> {
1904        let st_uid = path_lstat_uid(path)?;
1905        let mut euid = unsafe { libc::geteuid() };
1906        if euid == ROOT_UID {
1907            if st_uid == ROOT_UID {
1908                return Ok(true);
1909            }
1910            if let Some(sudo_uid) = extract_uid_from_env("SUDO_UID") {
1911                euid = sudo_uid;
1912            }
1913        }
1914        Ok(st_uid == euid)
1915    }
1916
1917    let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1918        .ok()
1919        .map(|v| {
1920            let lower = v.to_ascii_lowercase();
1921            v == "1" || lower == "true" || lower == "yes" || lower == "on"
1922        })
1923        .unwrap_or(false);
1924    if !assume_different {
1925        let gitfile_ok = gitfile
1926            .map(owned_by_effective_user)
1927            .transpose()?
1928            .unwrap_or(true);
1929        // Git may use a `GIT_WORK_TREE` that does not exist yet (t1510); skip ownership when
1930        // the path is absent instead of failing discovery with ENOENT.
1931        let wt_ok = match worktree {
1932            None => true,
1933            Some(wt) => match owned_by_effective_user(wt) {
1934                Ok(ok) => ok,
1935                Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
1936                Err(e) => return Err(Error::Io(e)),
1937            },
1938        };
1939        let gd_ok = owned_by_effective_user(gitdir)?;
1940        if gitfile_ok && wt_ok && gd_ok {
1941            return Ok(());
1942        }
1943    }
1944
1945    let data_path = if let Some(wt) = worktree {
1946        wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf())
1947    } else {
1948        gitdir
1949            .canonicalize()
1950            .unwrap_or_else(|_| gitdir.to_path_buf())
1951    };
1952    ensure_safe_directory_allows(gitdir, &data_path)
1953}
1954
1955#[cfg(not(unix))]
1956fn ensure_valid_ownership(
1957    _gitfile: Option<&Path>,
1958    _worktree: Option<&Path>,
1959    _gitdir: &Path,
1960) -> Result<()> {
1961    Ok(())
1962}
1963
1964impl Repository {
1965    /// Enforce `safe.directory` ownership checks, matching upstream behavior.
1966    ///
1967    /// When `GIT_TEST_ASSUME_DIFFERENT_OWNER=1`, ownership is considered unsafe
1968    /// unless a matching `safe.directory` value is configured in system/global/
1969    /// command scopes (repository-local config is ignored).
1970    pub fn enforce_safe_directory(&self) -> Result<()> {
1971        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
1972            .ok()
1973            .map(|v| {
1974                let lower = v.to_ascii_lowercase();
1975                v == "1" || lower == "true" || lower == "yes" || lower == "on"
1976            })
1977            .unwrap_or(false);
1978        if !assume_different {
1979            return Ok(());
1980        }
1981
1982        if self.explicit_git_dir {
1983            return Ok(());
1984        }
1985
1986        // In normal discovery, ownership is checked against worktree paths
1987        // unless invocation starts inside the gitdir, in which case gitdir is
1988        // checked.
1989        let checked = if let Some(wt) = &self.work_tree {
1990            let cwd = std::env::current_dir().ok();
1991            if let Some(cwd) = cwd {
1992                if cwd
1993                    .canonicalize()
1994                    .ok()
1995                    .is_some_and(|c| c.starts_with(&self.git_dir))
1996                {
1997                    self.git_dir
1998                        .canonicalize()
1999                        .unwrap_or_else(|_| self.git_dir.clone())
2000                } else {
2001                    wt.canonicalize().unwrap_or_else(|_| wt.clone())
2002                }
2003            } else {
2004                wt.canonicalize().unwrap_or_else(|_| wt.clone())
2005            }
2006        } else {
2007            self.git_dir
2008                .canonicalize()
2009                .unwrap_or_else(|_| self.git_dir.clone())
2010        };
2011
2012        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
2013            eprintln!(
2014                "debug-safe-directory checked={} git_dir={} work_tree={:?} cwd={:?}",
2015                checked.display(),
2016                self.git_dir.display(),
2017                self.work_tree,
2018                std::env::current_dir().ok()
2019            );
2020        }
2021        self.enforce_safe_directory_checked(&checked)
2022    }
2023
2024    /// Enforce safe.directory checks using the repository git-dir path.
2025    ///
2026    /// Used by operations that explicitly open another repository by path
2027    /// (e.g. local clone source).
2028    pub fn enforce_safe_directory_git_dir(&self) -> Result<()> {
2029        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
2030            .ok()
2031            .map(|v| {
2032                let lower = v.to_ascii_lowercase();
2033                v == "1" || lower == "true" || lower == "yes" || lower == "on"
2034            })
2035            .unwrap_or(false);
2036        if !assume_different {
2037            return Ok(());
2038        }
2039        let checked = self
2040            .git_dir
2041            .canonicalize()
2042            .unwrap_or_else(|_| self.git_dir.clone());
2043        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
2044            eprintln!(
2045                "debug-safe-directory(gitdir) checked={} git_dir={} work_tree={:?}",
2046                checked.display(),
2047                self.git_dir.display(),
2048                self.work_tree
2049            );
2050        }
2051        self.enforce_safe_directory_checked(&checked)
2052    }
2053
2054    /// Enforce safe.directory checks against an explicit checked path.
2055    pub fn enforce_safe_directory_git_dir_with_path(&self, checked: &Path) -> Result<()> {
2056        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
2057            .ok()
2058            .map(|v| {
2059                let lower = v.to_ascii_lowercase();
2060                v == "1" || lower == "true" || lower == "yes" || lower == "on"
2061            })
2062            .unwrap_or(false);
2063        if !assume_different {
2064            return Ok(());
2065        }
2066        self.enforce_safe_directory_checked(checked)
2067    }
2068
2069    fn enforce_safe_directory_checked(&self, checked: &Path) -> Result<()> {
2070        ensure_safe_directory_allows(&self.git_dir, checked)
2071    }
2072
2073    /// Verify the repository is safe to use as a `git clone` source (local clone).
2074    ///
2075    /// When `GIT_TEST_ASSUME_DIFFERENT_OWNER` is set, applies the same `safe.directory`
2076    /// rules as discovery. Otherwise checks filesystem ownership of the git directory
2077    /// only (matching Git's `die_upon_dubious_ownership` for clone).
2078    pub fn verify_safe_for_clone_source(&self) -> Result<()> {
2079        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
2080            .ok()
2081            .map(|v| {
2082                let lower = v.to_ascii_lowercase();
2083                v == "1" || lower == "true" || lower == "yes" || lower == "on"
2084            })
2085            .unwrap_or(false);
2086        if assume_different {
2087            self.enforce_safe_directory_git_dir()
2088        } else {
2089            #[cfg(unix)]
2090            {
2091                ensure_valid_ownership(None, None, &self.git_dir)
2092            }
2093            #[cfg(not(unix))]
2094            {
2095                Ok(())
2096            }
2097        }
2098    }
2099}
2100
2101fn normalize_fs_path(raw: &str) -> String {
2102    use std::path::Component;
2103    let p = std::path::Path::new(raw);
2104    let mut parts: Vec<String> = Vec::new();
2105    let mut absolute = false;
2106    for c in p.components() {
2107        match c {
2108            Component::RootDir => {
2109                absolute = true;
2110                parts.clear();
2111            }
2112            Component::CurDir => {}
2113            Component::ParentDir => {
2114                if !parts.is_empty() {
2115                    parts.pop();
2116                }
2117            }
2118            Component::Normal(s) => parts.push(s.to_string_lossy().to_string()),
2119            Component::Prefix(_) => {}
2120        }
2121    }
2122    let mut out = if absolute {
2123        String::from("/")
2124    } else {
2125        String::new()
2126    };
2127    out.push_str(&parts.join("/"));
2128    out
2129}
2130
2131fn safe_directory_matches(config_value: &str, checked: &str) -> bool {
2132    if config_value == "*" {
2133        return true;
2134    }
2135    if config_value == "." {
2136        // CWD only.
2137        if let Ok(cwd) = std::env::current_dir() {
2138            let cwd_s = normalize_fs_path(&cwd.to_string_lossy());
2139            let checked_s = normalize_fs_path(checked);
2140            return cwd_s == checked_s;
2141        }
2142        return false;
2143    }
2144
2145    let canonicalize_or_normalize = |raw: &str| -> String {
2146        let p = std::path::Path::new(raw);
2147        if p.exists() {
2148            p.canonicalize()
2149                .map(|c| c.to_string_lossy().to_string())
2150                .map(|s| normalize_fs_path(&s))
2151                .unwrap_or_else(|_| normalize_fs_path(raw))
2152        } else {
2153            normalize_fs_path(raw)
2154        }
2155    };
2156
2157    let config_norm = canonicalize_or_normalize(config_value);
2158    let checked_norm = normalize_fs_path(checked);
2159
2160    if config_norm.ends_with("/*") {
2161        let prefix_raw = &config_norm[..config_norm.len() - 2];
2162        let prefix_norm = canonicalize_or_normalize(prefix_raw);
2163        let mut prefix = prefix_norm;
2164        if !prefix.ends_with('/') {
2165            prefix.push('/');
2166        }
2167        return checked_norm.starts_with(&prefix);
2168    }
2169
2170    config_norm == checked_norm
2171}
2172
2173fn warn_core_bare_worktree_conflict(git_dir: &Path) {
2174    if env::var("GIT_WORK_TREE")
2175        .ok()
2176        .filter(|s| !s.trim().is_empty())
2177        .is_some()
2178    {
2179        return;
2180    }
2181    static WARNED_DIRS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
2182    if let Ok((bare, wt)) = read_core_bare_and_worktree(git_dir) {
2183        if bare && wt.is_some() {
2184            let key = git_dir
2185                .canonicalize()
2186                .unwrap_or_else(|_| git_dir.to_path_buf())
2187                .to_string_lossy()
2188                .to_string();
2189            let mut guard = WARNED_DIRS.lock().unwrap_or_else(|e| e.into_inner());
2190            let set = guard.get_or_insert_with(HashSet::new);
2191            if set.insert(key) {
2192                eprintln!("warning: core.bare and core.worktree do not make sense");
2193            }
2194        }
2195    }
2196}
2197
2198fn read_core_bare_and_worktree(git_dir: &Path) -> Result<(bool, Option<String>)> {
2199    let Some(config_path) = repository_config_path(git_dir) else {
2200        return Ok((false, None));
2201    };
2202    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
2203    let mut in_core = false;
2204    let mut bare = false;
2205    let mut worktree: Option<String> = None;
2206    for raw_line in content.lines() {
2207        let line = raw_line.trim();
2208        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
2209            continue;
2210        }
2211        if line.starts_with('[') {
2212            in_core = line.eq_ignore_ascii_case("[core]");
2213            continue;
2214        }
2215        if !in_core {
2216            continue;
2217        }
2218        if let Some((k, v)) = line.split_once('=') {
2219            let key = k.trim();
2220            let val = v.trim();
2221            if key.eq_ignore_ascii_case("bare") {
2222                bare = val.eq_ignore_ascii_case("true");
2223            } else if key.eq_ignore_ascii_case("worktree") {
2224                worktree = Some(val.to_owned());
2225            }
2226        }
2227    }
2228    Ok((bare, worktree))
2229}
2230
2231/// Reject impossible `GIT_WORK_TREE` values before repository setup (matches Git's
2232/// `validate_worktree` / `die` on bogus absolute paths, e.g. t1501).
2233fn validate_git_work_tree_path(path: &Path) -> Result<()> {
2234    if !path.is_absolute() {
2235        return Ok(());
2236    }
2237    let comps: Vec<Component<'_>> = path.components().collect();
2238    let Some(last_normal_idx) = comps
2239        .iter()
2240        .enumerate()
2241        .rev()
2242        .find_map(|(i, c)| matches!(c, Component::Normal(_)).then_some(i))
2243    else {
2244        return Ok(());
2245    };
2246    let mut cur = PathBuf::new();
2247    for (i, comp) in comps.iter().enumerate() {
2248        match comp {
2249            Component::Prefix(p) => cur.push(p.as_os_str()),
2250            Component::RootDir => cur.push(comp.as_os_str()),
2251            Component::CurDir => {}
2252            Component::ParentDir => {
2253                let _ = cur.pop();
2254            }
2255            Component::Normal(seg) => {
2256                cur.push(seg);
2257                if i != last_normal_idx && !cur.exists() {
2258                    return Err(Error::PathError(format!(
2259                        "Invalid path '{}': No such file or directory",
2260                        cur.display()
2261                    )));
2262                }
2263            }
2264        }
2265    }
2266    Ok(())
2267}
2268
2269fn resolve_core_worktree_path(git_dir: &Path, raw: &str) -> Result<PathBuf> {
2270    let p = Path::new(raw);
2271    if p.is_absolute() {
2272        return Ok(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()));
2273    }
2274    let old = env::current_dir().map_err(Error::Io)?;
2275    env::set_current_dir(git_dir).map_err(Error::Io)?;
2276    env::set_current_dir(raw).map_err(Error::Io)?;
2277    let resolved = env::current_dir().map_err(Error::Io)?;
2278    env::set_current_dir(&old).map_err(Error::Io)?;
2279    Ok(resolved.canonicalize().unwrap_or(resolved))
2280}
2281
2282/// When `GIT_DIR` names a gitfile, resolve to the real git directory.
2283fn resolve_git_dir_env_path(git_dir: &Path) -> Result<PathBuf> {
2284    if git_dir.is_file() {
2285        let content =
2286            fs::read_to_string(git_dir).map_err(|e| Error::NotARepository(e.to_string()))?;
2287        let base = git_dir
2288            .parent()
2289            .ok_or_else(|| Error::NotARepository(git_dir.display().to_string()))?;
2290        return parse_gitfile(&content, base);
2291    }
2292    Ok(git_dir.to_path_buf())
2293}
2294
2295/// Resolve an explicit git directory path the same way as `GIT_DIR` (including gitfile indirection).
2296///
2297/// # Errors
2298///
2299/// Returns [`Error::NotARepository`] for invalid gitfile content.
2300pub fn resolve_git_directory_arg(git_dir: &Path) -> Result<PathBuf> {
2301    resolve_git_dir_env_path(git_dir)
2302}
2303
2304/// Resolves a work tree's `.git` path (directory or gitfile) to the real git directory.
2305///
2306/// # Errors
2307///
2308/// Returns [`Error::NotARepository`] when `.git` is missing, invalid, or the gitfile target is absent.
2309pub fn resolve_dot_git(dot_git: &Path) -> Result<PathBuf> {
2310    if dot_git.is_dir() {
2311        return dot_git
2312            .canonicalize()
2313            .map_err(|_| Error::NotARepository(dot_git.display().to_string()));
2314    }
2315    if dot_git.is_file() {
2316        let content =
2317            fs::read_to_string(dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
2318        let base = dot_git
2319            .parent()
2320            .ok_or_else(|| Error::NotARepository(dot_git.display().to_string()))?;
2321        return parse_gitfile(&content, base);
2322    }
2323    Err(Error::NotARepository(dot_git.display().to_string()))
2324}
2325
2326/// Parse a gitfile's `"gitdir: <path>"` line.
2327fn parse_gitfile(content: &str, base: &Path) -> Result<PathBuf> {
2328    for line in content.lines() {
2329        if let Some(rest) = line.strip_prefix("gitdir:") {
2330            let rel = rest.trim();
2331            let path = if Path::new(rel).is_absolute() {
2332                PathBuf::from(rel)
2333            } else {
2334                base.join(rel)
2335            };
2336            if !path.exists() {
2337                return Err(Error::NotARepository(path.display().to_string()));
2338            }
2339            return Ok(path);
2340        }
2341    }
2342    Err(Error::NotARepository("invalid gitfile format".to_owned()))
2343}
2344
2345/// Initialise a new Git repository at the given path.
2346///
2347/// Creates the standard directory skeleton (objects/, refs/heads/, refs/tags/,
2348/// info/, hooks/) and a default `HEAD` pointing to `refs/heads/<initial_branch>`.
2349///
2350/// # Parameters
2351///
2352/// - `path` — root directory to initialise (created if absent).
2353/// - `bare` — if true, `path` itself becomes the git-dir; otherwise `path/.git`.
2354/// - `initial_branch` — branch name for `HEAD` (e.g. `"main"`).
2355/// - `template_dir` — optional template directory; if `None`, a minimal skeleton
2356///   is created.
2357///
2358/// # Errors
2359///
2360/// Returns [`Error::Io`] on filesystem failures.
2361fn write_fresh_git_directory(
2362    git_dir: &Path,
2363    bare: bool,
2364    initial_branch: &str,
2365    template_dir: Option<&Path>,
2366    ref_storage: &str,
2367    skip_hooks_and_info: bool,
2368) -> Result<()> {
2369    let mut subs = vec![
2370        "objects",
2371        "objects/info",
2372        "objects/pack",
2373        "refs",
2374        "refs/heads",
2375        "refs/tags",
2376    ];
2377    if !bare && !skip_hooks_and_info {
2378        subs.push("info");
2379        subs.push("hooks");
2380    }
2381    for sub in subs {
2382        fs::create_dir_all(git_dir.join(sub))?;
2383    }
2384
2385    if ref_storage == "reftable" {
2386        let reftable_dir = git_dir.join("reftable");
2387        fs::create_dir_all(&reftable_dir)?;
2388        let tables_list = reftable_dir.join("tables.list");
2389        if !tables_list.exists() {
2390            fs::write(&tables_list, "")?;
2391        }
2392    }
2393
2394    if let Some(tmpl) = template_dir {
2395        if tmpl.is_dir() {
2396            copy_template(tmpl, git_dir)?;
2397        }
2398    }
2399
2400    let head_content = format!("ref: refs/heads/{initial_branch}\n");
2401    fs::write(git_dir.join("HEAD"), head_content)?;
2402
2403    let needs_extensions = ref_storage == "reftable";
2404    let repo_version = if needs_extensions { 1 } else { 0 };
2405
2406    let mut config_content = String::from("[core]\n");
2407    config_content.push_str(&format!("\trepositoryformatversion = {repo_version}\n"));
2408    config_content.push_str("\tfilemode = true\n");
2409    if bare {
2410        config_content.push_str("\tbare = true\n");
2411    } else {
2412        config_content.push_str("\tbare = false\n");
2413        config_content.push_str("\tlogallrefupdates = true\n");
2414    }
2415    if needs_extensions {
2416        config_content.push_str("[extensions]\n");
2417        config_content.push_str("\trefStorage = reftable\n");
2418    }
2419    fs::write(git_dir.join("config"), config_content)?;
2420
2421    // Merge `config` from the template on top of the default (matches `git clone --template`).
2422    if let Some(tmpl) = template_dir {
2423        if tmpl.is_dir() {
2424            let tmpl_config = tmpl.join("config");
2425            if tmpl_config.is_file() {
2426                let tmpl_text = fs::read_to_string(&tmpl_config)?;
2427                let tmpl_parsed = ConfigFile::parse(&tmpl_config, &tmpl_text, ConfigScope::Local)?;
2428                let dest_path = git_dir.join("config");
2429                let dest_text = fs::read_to_string(&dest_path)?;
2430                let mut dest_parsed =
2431                    ConfigFile::parse(&dest_path, &dest_text, ConfigScope::Local)?;
2432                for e in &tmpl_parsed.entries {
2433                    // Git clone ignores `core.bare` from templates (non-bare clone must stay non-bare).
2434                    if e.key == "core.bare" {
2435                        continue;
2436                    }
2437                    if let Some(v) = &e.value {
2438                        let _ = dest_parsed.set(&e.key, v);
2439                    } else {
2440                        let _ = dest_parsed.set(&e.key, "true");
2441                    }
2442                }
2443                dest_parsed.write()?;
2444            }
2445        }
2446    }
2447
2448    fs::write(
2449        git_dir.join("description"),
2450        "Unnamed repository; edit this file 'description' to name the repository.\n",
2451    )?;
2452    Ok(())
2453}
2454
2455/// Initialise a non-bare repository with the git directory at `git_dir` and the work tree at `work_tree`.
2456///
2457/// Creates `work_tree/.git` as a gitfile pointing at `git_dir` (absolute path). Matches `git clone
2458/// --separate-git-dir` layout.
2459///
2460/// # Errors
2461///
2462/// Returns [`Error::Io`] on filesystem failures.
2463pub fn init_repository_separate_git_dir(
2464    work_tree: &Path,
2465    git_dir: &Path,
2466    initial_branch: &str,
2467    template_dir: Option<&Path>,
2468    ref_storage: &str,
2469) -> Result<Repository> {
2470    let skip_hooks_info = template_dir.is_some_and(|p| p.as_os_str().is_empty());
2471    fs::create_dir_all(work_tree)?;
2472    fs::create_dir_all(git_dir)?;
2473    write_fresh_git_directory(
2474        git_dir,
2475        false,
2476        initial_branch,
2477        template_dir,
2478        ref_storage,
2479        skip_hooks_info,
2480    )?;
2481
2482    // Write an absolute `gitdir:` path, matching C Git's `init_db` →
2483    // `set_git_dir(real_git_dir, make_realpath=1)` → `separate_git_dir`, which records the
2484    // realpath of the separate git directory (`t5601` "clone separate gitdir: output"). This
2485    // path is only used by `git clone --separate-git-dir`; submodule layouts use a different
2486    // code path and are unaffected.
2487    let gitfile = work_tree.join(".git");
2488    let abs_git_dir = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
2489    let abs_git_dir = abs_git_dir.to_string_lossy().replace('\\', "/");
2490    fs::write(gitfile, format!("gitdir: {abs_git_dir}\n"))?;
2491
2492    Repository::open(git_dir, Some(work_tree))
2493}
2494
2495/// Initialise a **minimal** bare repository directory layout matching `git clone --template= --bare`.
2496///
2497/// Git's clone-with-empty-template omits `hooks/`, `info/`, `description`, and `branches/` until
2498/// something needs them; tests rely on `mkdir <repo>/info` succeeding afterward.
2499///
2500/// # Parameters
2501///
2502/// - `git_dir` — bare repository root (the destination `.git` directory for a bare clone).
2503/// - `initial_branch` — used only for the initial `HEAD` symref text before clone rewires it.
2504///
2505/// # Errors
2506///
2507/// Returns [`Error::Io`] on filesystem failures.
2508/// Ensure `core.bare = true` in the repository `config` (used after `git clone --bare`).
2509pub fn ensure_core_bare(git_dir: &Path) -> Result<()> {
2510    let path = git_dir.join("config");
2511    let text = fs::read_to_string(&path).unwrap_or_default();
2512    if text.lines().any(|l| {
2513        let t = l.trim();
2514        t == "bare = true" || t == "bare=true"
2515    }) {
2516        return Ok(());
2517    }
2518    let mut out = text;
2519    if !out.ends_with('\n') && !out.is_empty() {
2520        out.push('\n');
2521    }
2522    if !out.contains("[core]") {
2523        out.push_str("[core]\n");
2524    }
2525    out.push_str("\tbare = true\n");
2526    fs::write(path, out).map_err(Error::Io)
2527}
2528
2529pub fn init_bare_clone_minimal(
2530    git_dir: &Path,
2531    initial_branch: &str,
2532    ref_storage: &str,
2533) -> Result<()> {
2534    for sub in &[
2535        "objects",
2536        "objects/info",
2537        "objects/pack",
2538        "refs",
2539        "refs/heads",
2540        "refs/tags",
2541    ] {
2542        fs::create_dir_all(git_dir.join(sub))?;
2543    }
2544
2545    if ref_storage == "reftable" {
2546        let reftable_dir = git_dir.join("reftable");
2547        fs::create_dir_all(&reftable_dir)?;
2548        let tables_list = reftable_dir.join("tables.list");
2549        if !tables_list.exists() {
2550            fs::write(&tables_list, "")?;
2551        }
2552    }
2553
2554    let head_content = format!("ref: refs/heads/{initial_branch}\n");
2555    fs::write(git_dir.join("HEAD"), head_content)?;
2556
2557    let needs_extensions = ref_storage == "reftable";
2558    let repo_version = if needs_extensions { 1 } else { 0 };
2559    let mut config_content = String::from("[core]\n");
2560    config_content.push_str(&format!("\trepositoryformatversion = {repo_version}\n"));
2561    config_content.push_str("\tfilemode = true\n");
2562    config_content.push_str("\tbare = true\n");
2563    if needs_extensions {
2564        config_content.push_str("[extensions]\n");
2565        config_content.push_str("\trefStorage = reftable\n");
2566    }
2567    fs::write(git_dir.join("config"), config_content)?;
2568
2569    fs::write(
2570        git_dir.join("packed-refs"),
2571        "# pack-refs with: peeled fully-peeled sorted\n",
2572    )?;
2573    Ok(())
2574}
2575
2576pub fn init_repository(
2577    path: &Path,
2578    bare: bool,
2579    initial_branch: &str,
2580    template_dir: Option<&Path>,
2581    ref_storage: &str,
2582) -> Result<Repository> {
2583    let skip_hooks_info = !bare && template_dir.is_some_and(|p| p.as_os_str().is_empty());
2584    let git_dir = if bare {
2585        path.to_path_buf()
2586    } else {
2587        path.join(".git")
2588    };
2589
2590    if !bare {
2591        fs::create_dir_all(path)?;
2592    }
2593    fs::create_dir_all(&git_dir)?;
2594    write_fresh_git_directory(
2595        &git_dir,
2596        bare,
2597        initial_branch,
2598        template_dir,
2599        ref_storage,
2600        skip_hooks_info,
2601    )?;
2602
2603    let work_tree = if bare { None } else { Some(path) };
2604    Repository::open(&git_dir, work_tree)
2605}
2606
2607/// Initialise a **bare** repository at `git_dir` with `core.worktree` set to `work_tree`.
2608///
2609/// Used when `GIT_WORK_TREE` is set during `git clone`: the clone destination is the bare
2610/// git directory and checked-out files go under the environment work tree (matches upstream Git).
2611///
2612/// # Errors
2613///
2614/// Returns [`Error::Io`] on filesystem failures.
2615pub fn init_bare_with_env_worktree(
2616    git_dir: &Path,
2617    work_tree: &Path,
2618    initial_branch: &str,
2619    template_dir: Option<&Path>,
2620    ref_storage: &str,
2621) -> Result<Repository> {
2622    fs::create_dir_all(git_dir)?;
2623    fs::create_dir_all(work_tree)?;
2624    write_fresh_git_directory(
2625        git_dir,
2626        true,
2627        initial_branch,
2628        template_dir,
2629        ref_storage,
2630        false,
2631    )?;
2632    let work_tree_abs = fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
2633    let config_path = git_dir.join("config");
2634    let mut config = match ConfigFile::from_path(&config_path, ConfigScope::Local)? {
2635        Some(c) => c,
2636        None => ConfigFile::parse(&config_path, "", ConfigScope::Local)?,
2637    };
2638    config.set("core.worktree", &work_tree_abs.to_string_lossy())?;
2639    config.write()?;
2640    Repository::open(git_dir, Some(work_tree))
2641}
2642
2643/// Initialise a repository whose git directory is separate from the work tree.
2644///
2645/// Creates `git_dir` with the usual layout, writes `work_tree/.git` as a gitfile
2646/// pointing at `git_dir`, and sets `core.worktree` in `git_dir/config`.
2647pub fn init_repository_separate(
2648    work_tree: &Path,
2649    git_dir: &Path,
2650    initial_branch: &str,
2651    template_dir: Option<&Path>,
2652) -> Result<Repository> {
2653    fs::create_dir_all(work_tree)?;
2654    if git_dir.exists() {
2655        return Err(Error::PathError(format!(
2656            "git directory '{}' already exists",
2657            git_dir.display()
2658        )));
2659    }
2660
2661    for sub in &[
2662        "objects",
2663        "objects/info",
2664        "objects/pack",
2665        "refs",
2666        "refs/heads",
2667        "refs/tags",
2668        "info",
2669        "hooks",
2670    ] {
2671        fs::create_dir_all(git_dir.join(sub))?;
2672    }
2673
2674    if let Some(tmpl) = template_dir {
2675        if tmpl.is_dir() {
2676            copy_template(tmpl, git_dir)?;
2677        }
2678    }
2679
2680    fs::write(
2681        git_dir.join("HEAD"),
2682        format!("ref: refs/heads/{initial_branch}\n"),
2683    )?;
2684
2685    let work_tree_abs = fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
2686    let git_dir_abs = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
2687    let config_content = format!(
2688        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n\tworktree = {}\n",
2689        work_tree_abs.display()
2690    );
2691    fs::write(git_dir.join("config"), config_content)?;
2692    fs::write(
2693        git_dir.join("description"),
2694        "Unnamed repository; edit this file 'description' to name the repository.\n",
2695    )?;
2696
2697    let gitfile = work_tree.join(".git");
2698    fs::write(&gitfile, format!("gitdir: {}\n", git_dir_abs.display()))?;
2699
2700    Repository::open(git_dir, Some(work_tree))
2701}
2702
2703/// Recursively copy template files from `src` to `dst`.
2704fn copy_template(src: &Path, dst: &Path) -> Result<()> {
2705    for entry in fs::read_dir(src)? {
2706        let entry = entry?;
2707        let src_path = entry.path();
2708        let dst_path = dst.join(entry.file_name());
2709        if src_path.is_dir() {
2710            fs::create_dir_all(&dst_path)?;
2711            copy_template(&src_path, &dst_path)?;
2712        } else {
2713            fs::copy(&src_path, &dst_path)?;
2714        }
2715    }
2716    Ok(())
2717}
2718
2719/// Parse `GIT_CEILING_DIRECTORIES` into a list of absolute paths and whether
2720/// symlink resolution should be skipped.
2721///
2722/// The variable is colon-separated (`:`) on Unix.  Empty entries and
2723/// non-absolute paths are silently skipped, matching Git's behaviour.
2724///
2725/// A leading colon (`:path1:path2`) disables symlink resolution for all
2726/// ceiling paths AND the cwd used for comparison (Git `resolve_symlinks` flag).
2727fn parse_ceiling_directories() -> (Vec<PathBuf>, bool) {
2728    let raw = match env::var("GIT_CEILING_DIRECTORIES") {
2729        Ok(val) => val,
2730        Err(_) => return (Vec::new(), false),
2731    };
2732    if raw.is_empty() {
2733        return (Vec::new(), false);
2734    }
2735    // A leading colon means "don't resolve symlinks".
2736    let (no_resolve, effective) = if raw.starts_with(':') {
2737        (true, &raw[1..])
2738    } else {
2739        (false, raw.as_str())
2740    };
2741    let paths = effective
2742        .split(':')
2743        .filter(|s| !s.is_empty())
2744        .filter_map(|s| {
2745            let p = PathBuf::from(s);
2746            if !p.is_absolute() {
2747                return None;
2748            }
2749            if no_resolve {
2750                // Strip trailing slashes for consistent comparison but don't resolve symlinks.
2751                let s = s.trim_end_matches('/');
2752                Some(PathBuf::from(s))
2753            } else {
2754                // Canonicalize to resolve symlinks; fall back to the raw path
2755                // (with trailing slashes stripped) when the directory doesn't exist.
2756                Some(p.canonicalize().unwrap_or_else(|_| {
2757                    let s = s.trim_end_matches('/');
2758                    PathBuf::from(s)
2759                }))
2760            }
2761        })
2762        .collect();
2763    (paths, no_resolve)
2764}
2765
2766/// Validate the repository format version from config text.
2767/// Returns Ok if the format is supported, Err with message if not.
2768pub fn validate_repo_config(config_text: &str) -> std::result::Result<(), String> {
2769    let mut version: u32 = 0;
2770    let mut in_core = false;
2771    for line in config_text.lines() {
2772        let trimmed = line.trim();
2773        if trimmed.starts_with('[') {
2774            in_core = trimmed.to_lowercase().starts_with("[core");
2775            continue;
2776        }
2777        if in_core {
2778            if let Some(rest) = trimmed.strip_prefix("repositoryformatversion") {
2779                let val = rest.trim_start_matches([' ', '=']).trim();
2780                if let Ok(v) = val.parse::<u32>() {
2781                    version = v;
2782                }
2783            }
2784        }
2785    }
2786    if version >= 2 {
2787        return Err(format!("unknown repository format version: {version}"));
2788    }
2789    Ok(())
2790}