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