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;
19use std::env;
20use std::fs;
21use std::fs::OpenOptions;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use crate::error::{Error, Result};
26use crate::odb::Odb;
27
28/// A handle to an open Git repository.
29#[derive(Debug)]
30pub struct Repository {
31    /// Absolute path to the git directory (`.git/` or bare repo root).
32    pub git_dir: PathBuf,
33    /// Absolute path to the working tree, or `None` for bare repos.
34    pub work_tree: Option<PathBuf>,
35    /// Loose object database.
36    pub odb: Odb,
37    /// Discovery provenance: true when opened via `GIT_DIR` env or explicit API.
38    ///
39    /// This suppresses safe.bareRepository implicit checks.
40    pub explicit_git_dir: bool,
41}
42
43impl Repository {
44    /// Open a repository from an explicit git-dir and optional work-tree.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`Error::NotARepository`] if `git_dir` does not look like a
49    /// valid git directory (missing `objects/`, `HEAD`, etc.).
50    pub fn open(git_dir: &Path, work_tree: Option<&Path>) -> Result<Self> {
51        let git_dir = git_dir
52            .canonicalize()
53            .map_err(|_| Error::NotARepository(git_dir.display().to_string()))?;
54
55        validate_repository_format(&git_dir)?;
56
57        // Check HEAD exists or is a symlink (linked worktrees have a symlink HEAD)
58        let head_path = git_dir.join("HEAD");
59        if !head_path.exists() && !head_path.is_symlink() {
60            return Err(Error::NotARepository(git_dir.display().to_string()));
61        }
62
63        // For git worktrees the `objects/` directory lives in the common git
64        // directory pointed to by the `commondir` file.
65        let objects_dir = if git_dir.join("objects").exists() {
66            git_dir.join("objects")
67        } else if let Some(common_dir) = resolve_common_dir(&git_dir) {
68            common_dir.join("objects")
69        } else {
70            return Err(Error::NotARepository(git_dir.display().to_string()));
71        };
72
73        if !objects_dir.exists() {
74            return Err(Error::NotARepository(git_dir.display().to_string()));
75        }
76
77        let work_tree = match work_tree {
78            Some(p) => Some(
79                p.canonicalize()
80                    .map_err(|_| Error::PathError(p.display().to_string()))?,
81            ),
82            None => None,
83        };
84
85        let odb = if let Some(ref wt) = work_tree {
86            Odb::with_work_tree(&objects_dir, wt)
87        } else {
88            Odb::new(&objects_dir)
89        };
90
91        Ok(Self {
92            git_dir,
93            work_tree,
94            odb,
95            explicit_git_dir: false,
96        })
97    }
98
99    /// Discover the repository starting from `start` (defaults to cwd if `None`).
100    ///
101    /// Checks `GIT_DIR` first; if set, uses it directly.  Otherwise walks up
102    /// the directory tree looking for `.git` (regular directory or gitfile).
103    ///
104    /// # Errors
105    ///
106    /// Returns [`Error::NotARepository`] if no repository can be found.
107    pub fn discover(start: Option<&Path>) -> Result<Self> {
108        // GIT_DIR override
109        if let Ok(dir) = env::var("GIT_DIR") {
110            let git_dir = PathBuf::from(&dir);
111            let work_tree = env::var("GIT_WORK_TREE").ok().map(PathBuf::from);
112            if work_tree.is_some() {
113                let mut repo = Self::open(&git_dir, work_tree.as_deref())?;
114                repo.explicit_git_dir = true;
115                return Ok(repo);
116            }
117            // When GIT_DIR is set without GIT_WORK_TREE, Git treats the
118            // current directory as the work tree for non-bare repositories.
119            let mut repo = Self::open(&git_dir, None)?;
120            if repo.work_tree.is_none() {
121                // Check core.bare config
122                let config_path = repo.git_dir.join("config");
123                let is_bare = if config_path.exists() {
124                    fs::read_to_string(&config_path)
125                        .ok()
126                        .and_then(|c| {
127                            c.lines()
128                                .find(|l| {
129                                    let trimmed = l.trim();
130                                    trimmed.starts_with("bare") && trimmed.contains("true")
131                                })
132                                .map(|_| true)
133                        })
134                        .unwrap_or(false)
135                } else {
136                    false
137                };
138                if !is_bare {
139                    let cwd = env::current_dir()?;
140                    repo.work_tree = Some(cwd.canonicalize().unwrap_or(cwd));
141                }
142            }
143            repo.explicit_git_dir = true;
144            return Ok(repo);
145        }
146
147        // If GIT_WORK_TREE is set without GIT_DIR, we still need to honor it
148        // after discovery.
149        let env_work_tree = env::var("GIT_WORK_TREE").ok().map(PathBuf::from);
150
151        let cwd = env::current_dir()?;
152        let start = start.unwrap_or(&cwd);
153        let start = if start.is_absolute() {
154            start.to_path_buf()
155        } else {
156            cwd.join(start)
157        };
158
159        // Parse GIT_CEILING_DIRECTORIES — colon-separated list of absolute
160        // directory paths that limit upward repository discovery.
161        let ceiling_dirs = parse_ceiling_directories();
162
163        let mut current = start.as_path();
164        let mut first = true;
165        loop {
166            // On the first iteration we always check the starting directory.
167            // On subsequent iterations, check whether this parent directory is
168            // blocked by a ceiling entry before probing for .git.
169            if !first && is_ceiling_blocked(current, &ceiling_dirs) {
170                break;
171            }
172            first = false;
173
174            if let Some(mut repo) = try_open_at(current)? {
175                // Override work_tree with GIT_WORK_TREE env if set
176                if let Some(ref wt) = env_work_tree {
177                    repo.work_tree = Some(wt.canonicalize().unwrap_or_else(|_| wt.clone()));
178                }
179                repo.enforce_safe_directory()?;
180                return Ok(repo);
181            }
182            match current.parent() {
183                Some(p) => current = p,
184                None => break,
185            }
186        }
187
188        Err(Error::NotARepository(start.display().to_string()))
189    }
190
191    /// Path to the index file.
192    #[must_use]
193    pub fn index_path(&self) -> PathBuf {
194        self.git_dir.join("index")
195    }
196
197    /// Path to the `refs/` directory.
198    #[must_use]
199    pub fn refs_dir(&self) -> PathBuf {
200        self.git_dir.join("refs")
201    }
202
203    /// Path to `HEAD`.
204    #[must_use]
205    pub fn head_path(&self) -> PathBuf {
206        self.git_dir.join("HEAD")
207    }
208
209    /// Whether this is a bare repository (no working tree).
210    #[must_use]
211    pub fn is_bare(&self) -> bool {
212        // Check core.bare first - it overrides work_tree detection
213        let config_path = self.git_dir.join("config");
214        if let Ok(content) = std::fs::read_to_string(&config_path) {
215            let mut in_core = false;
216            for line in content.lines() {
217                let t = line.trim();
218                if t.starts_with('[') {
219                    in_core = t.eq_ignore_ascii_case("[core]");
220                    continue;
221                }
222                if in_core {
223                    if let Some((k, v)) = t.split_once('=') {
224                        if k.trim().eq_ignore_ascii_case("bare") {
225                            if v.trim().eq_ignore_ascii_case("true") {
226                                return true;
227                            } else if v.trim().eq_ignore_ascii_case("false") {
228                                return false;
229                            }
230                        }
231                    }
232                }
233            }
234        }
235        if self.work_tree.is_some() {
236            return false;
237        }
238        // Check core.bare in the repo config.  A .git directory of a
239        // non-bare repo has objects/ and HEAD but core.bare=false.
240        let config_path = self.git_dir.join("config");
241        if let Ok(content) = std::fs::read_to_string(&config_path) {
242            let mut in_core = false;
243            for line in content.lines() {
244                let t = line.trim();
245                if t.starts_with('[') {
246                    in_core = t.eq_ignore_ascii_case("[core]");
247                    continue;
248                }
249                if in_core {
250                    if let Some((k, v)) = t.split_once('=') {
251                        if k.trim().eq_ignore_ascii_case("bare") {
252                            return v.trim().eq_ignore_ascii_case("true");
253                        }
254                    }
255                }
256            }
257        }
258        // No core.bare setting — if work_tree is None, assume bare
259        true
260    }
261
262    /// Read an object, transparently following replace refs.
263    ///
264    /// If `refs/replace/<hex>` exists for the requested OID and
265    /// `GIT_NO_REPLACE_OBJECTS` is **not** set, this reads the
266    /// replacement object instead.  Otherwise it behaves identically
267    /// to `self.odb.read(oid)`.
268    pub fn read_replaced(&self, oid: &crate::objects::ObjectId) -> Result<crate::objects::Object> {
269        if std::env::var_os("GIT_NO_REPLACE_OBJECTS").is_some() {
270            return self.odb.read(oid);
271        }
272        let replace_base = std::env::var("GIT_REPLACE_REF_BASE")
273            .ok()
274            .filter(|s| !s.is_empty())
275            .unwrap_or_else(|| "refs/replace/".to_owned());
276        let replace_base = if replace_base.ends_with('/') {
277            replace_base
278        } else {
279            format!("{replace_base}/")
280        };
281        let replace_ref = self
282            .git_dir
283            .join(format!("{}{}", replace_base, oid.to_hex()));
284        if replace_ref.is_file() {
285            if let Ok(content) = std::fs::read_to_string(&replace_ref) {
286                let hex = content.trim();
287                if let Ok(replacement_oid) = hex.parse::<crate::objects::ObjectId>() {
288                    if let Ok(obj) = self.odb.read(&replacement_oid) {
289                        return Ok(obj);
290                    }
291                }
292            }
293        }
294        self.odb.read(oid)
295    }
296}
297
298/// Resolve the common git directory for linked worktrees.
299fn resolve_common_dir(git_dir: &Path) -> Option<PathBuf> {
300    let common_raw = fs::read_to_string(git_dir.join("commondir")).ok()?;
301    let common_rel = common_raw.trim();
302    if common_rel.is_empty() {
303        return None;
304    }
305    let common_dir = if Path::new(common_rel).is_absolute() {
306        PathBuf::from(common_rel)
307    } else {
308        git_dir.join(common_rel)
309    };
310    Some(common_dir.canonicalize().unwrap_or(common_dir))
311}
312
313/// Determine the config file path for a repository or linked worktree.
314fn repository_config_path(git_dir: &Path) -> Option<PathBuf> {
315    let local = git_dir.join("config");
316    if local.exists() {
317        return Some(local);
318    }
319    let common = resolve_common_dir(git_dir)?;
320    let shared = common.join("config");
321    if shared.exists() {
322        Some(shared)
323    } else {
324        None
325    }
326}
327
328/// Validate core repository format/version compatibility.
329///
330/// Supports repository format versions 0 and 1, with extension handling that
331/// matches Git's compatibility expectations in upstream repo-version tests.
332/// Public wrapper for validate_repository_format.
333pub fn validate_repo_format(git_dir: &Path) -> Result<()> {
334    validate_repository_format(git_dir)
335}
336
337fn validate_repository_format(git_dir: &Path) -> Result<()> {
338    let Some(config_path) = repository_config_path(git_dir) else {
339        return Ok(());
340    };
341
342    let content = fs::read_to_string(&config_path).map_err(Error::Io)?;
343    let mut in_core = false;
344    let mut in_extensions = false;
345    let mut repo_version = 0u32;
346    let mut extensions = BTreeSet::new();
347
348    for raw_line in content.lines() {
349        let mut line = raw_line.trim();
350        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
351            continue;
352        }
353
354        if line.starts_with('[') {
355            let Some(end_idx) = line.find(']') else {
356                return Err(Error::ConfigError(format!(
357                    "invalid config in {}",
358                    config_path.display()
359                )));
360            };
361
362            let section = line[1..end_idx].trim();
363            let section_name = section
364                .split_whitespace()
365                .next()
366                .unwrap_or_default()
367                .to_ascii_lowercase();
368            in_core = section_name == "core";
369            in_extensions = section_name == "extensions";
370
371            let remainder = line[end_idx + 1..].trim();
372            if remainder.is_empty() || remainder.starts_with('#') || remainder.starts_with(';') {
373                continue;
374            }
375            line = remainder;
376        }
377
378        if in_core {
379            if let Some((key, value)) = line.split_once('=') {
380                if key.trim().eq_ignore_ascii_case("repositoryformatversion") {
381                    repo_version = value.trim().parse::<u32>().map_err(|_| {
382                        Error::ConfigError(format!(
383                            "invalid core.repositoryformatversion in {}",
384                            config_path.display()
385                        ))
386                    })?;
387                }
388            }
389        }
390
391        if in_extensions {
392            let key = if let Some((key, _)) = line.split_once('=') {
393                key.trim()
394            } else {
395                line
396            };
397            if !key.is_empty() {
398                extensions.insert(key.to_ascii_lowercase());
399            }
400        }
401    }
402
403    if repo_version > 1 {
404        return Err(Error::UnsupportedRepositoryFormatVersion(repo_version));
405    }
406
407    for extension in extensions {
408        if repo_version == 0 {
409            if extension.ends_with("-v1") {
410                return Err(Error::UnsupportedRepositoryExtension(extension));
411            }
412            continue;
413        }
414
415        if matches!(
416            extension.as_str(),
417            "noop"
418                | "noop-v1"
419                | "preciousobjects"
420                | "partialclone"
421                | "worktreeconfig"
422                | "objectformat"
423                | "compatobjectformat"
424                | "refstorage"
425        ) {
426            continue;
427        }
428
429        return Err(Error::UnsupportedRepositoryExtension(extension));
430    }
431
432    Ok(())
433}
434
435/// Try to open a repository rooted exactly at `dir`.
436///
437/// Returns `Ok(None)` when `dir` is not a repository root (the caller should
438/// walk up); returns `Err` on a structural problem.
439fn try_open_at(dir: &Path) -> Result<Option<Repository>> {
440    let dot_git = dir.join(".git");
441
442    // Check for special file types (FIFO, socket, etc.) — reject them
443    // instead of walking up to a parent repository.
444    #[cfg(unix)]
445    {
446        use std::os::unix::fs::FileTypeExt;
447        if let Ok(meta) = fs::symlink_metadata(&dot_git) {
448            let ft = meta.file_type();
449            if ft.is_fifo() || ft.is_socket() || ft.is_block_device() || ft.is_char_device() {
450                return Err(Error::NotARepository(format!(
451                    "invalid gitfile format: {} is not a regular file",
452                    dot_git.display()
453                )));
454            }
455            if ft.is_symlink() {
456                if let Ok(target_meta) = fs::metadata(&dot_git) {
457                    let tft = target_meta.file_type();
458                    if tft.is_fifo()
459                        || tft.is_socket()
460                        || tft.is_block_device()
461                        || tft.is_char_device()
462                    {
463                        return Err(Error::NotARepository(format!(
464                            "invalid gitfile format: {} is not a regular file",
465                            dot_git.display()
466                        )));
467                    }
468                }
469            }
470        }
471    }
472
473    if dot_git.is_file() {
474        // gitfile indirection: file contains "gitdir: <path>"
475        let content =
476            fs::read_to_string(&dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
477        let git_dir = parse_gitfile(&content, dir)?;
478        let repo = Repository::open(&git_dir, Some(dir))?;
479        return Ok(Some(repo));
480    }
481
482    if dot_git.is_dir() {
483        // If .git is a symlink to a directory, resolve the symlink target
484        // for validation but keep the original .git path for user-facing output
485        // (matches real git behavior: `rev-parse --git-dir` shows `.git`).
486        let open_path = if dot_git.is_symlink() {
487            // Resolve the symlink target for validation
488            dot_git.read_link().unwrap_or_else(|_| dot_git.clone())
489        } else {
490            dot_git.clone()
491        };
492        // Try to open; if the directory is empty or invalid, continue
493        // walking up (e.g. an empty .git/ directory should be ignored).
494        match Repository::open(&open_path, Some(dir)) {
495            Ok(mut repo) => {
496                // Restore the original path so rev-parse shows .git not the
497                // resolved symlink target.
498                if dot_git.is_symlink() {
499                    let abs_dot_git = if dot_git.is_absolute() {
500                        dot_git
501                    } else {
502                        dir.join(".git")
503                    };
504                    repo.git_dir = abs_dot_git;
505                }
506                return Ok(Some(repo));
507            }
508            Err(Error::NotARepository(_)) => return Ok(None),
509            Err(e) => return Err(e),
510        }
511    }
512
513    // Linked-worktree gitdir/admin directories contain HEAD and commondir,
514    // and can be opened as repositories even without a local objects/ dir.
515    if dir.join("HEAD").is_file() && dir.join("commondir").is_file() {
516        maybe_trace_implicit_bare_repository(dir);
517        let repo = Repository::open(dir, None)?;
518        return Ok(Some(repo));
519    }
520
521    // Check if `dir` itself is a bare repo (has objects/ and HEAD directly)
522    if dir.join("objects").is_dir() && dir.join("HEAD").is_file() {
523        maybe_trace_implicit_bare_repository(dir);
524        // Check safe.bareRepository policy before opening bare repos.
525        // When set to "explicit", implicit bare repo discovery is forbidden
526        // unless GIT_DIR was set (handled earlier in discover()).
527        if !is_inside_dot_git(dir) {
528            if let Ok(cfg) = crate::config::ConfigSet::load(None, true) {
529                if let Some(val) = cfg.get("safe.bareRepository") {
530                    if val.eq_ignore_ascii_case("explicit") {
531                        return Err(Error::ForbiddenBareRepository(dir.display().to_string()));
532                    }
533                }
534            }
535        }
536        let repo = Repository::open(dir, None)?;
537        return Ok(Some(repo));
538    }
539
540    Ok(None)
541}
542
543fn is_inside_dot_git(path: &Path) -> bool {
544    path.components().any(|c| c.as_os_str() == ".git")
545}
546
547fn maybe_trace_implicit_bare_repository(dir: &Path) {
548    let path = match std::env::var("GIT_TRACE2_PERF") {
549        Ok(p) if !p.is_empty() => p,
550        _ => return,
551    };
552
553    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
554        let _ = writeln!(file, "setup: implicit-bare-repository:{}", dir.display());
555    }
556}
557
558impl Repository {
559    /// Enforce `safe.directory` ownership checks, matching upstream behavior.
560    ///
561    /// When `GIT_TEST_ASSUME_DIFFERENT_OWNER=1`, ownership is considered unsafe
562    /// unless a matching `safe.directory` value is configured in system/global/
563    /// command scopes (repository-local config is ignored).
564    pub fn enforce_safe_directory(&self) -> Result<()> {
565        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
566            .ok()
567            .map(|v| {
568                let lower = v.to_ascii_lowercase();
569                v == "1" || lower == "true" || lower == "yes" || lower == "on"
570            })
571            .unwrap_or(false);
572        if !assume_different {
573            return Ok(());
574        }
575
576        if self.explicit_git_dir {
577            return Ok(());
578        }
579
580        // In normal discovery, ownership is checked against worktree paths
581        // unless invocation starts inside the gitdir, in which case gitdir is
582        // checked.
583        let checked = if let Some(wt) = &self.work_tree {
584            let cwd = std::env::current_dir().ok();
585            if let Some(cwd) = cwd {
586                if cwd
587                    .canonicalize()
588                    .ok()
589                    .is_some_and(|c| c.starts_with(&self.git_dir))
590                {
591                    self.git_dir
592                        .canonicalize()
593                        .unwrap_or_else(|_| self.git_dir.clone())
594                } else {
595                    wt.canonicalize().unwrap_or_else(|_| wt.clone())
596                }
597            } else {
598                wt.canonicalize().unwrap_or_else(|_| wt.clone())
599            }
600        } else {
601            self.git_dir
602                .canonicalize()
603                .unwrap_or_else(|_| self.git_dir.clone())
604        };
605
606        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
607            eprintln!(
608                "debug-safe-directory checked={} git_dir={} work_tree={:?} cwd={:?}",
609                checked.display(),
610                self.git_dir.display(),
611                self.work_tree,
612                std::env::current_dir().ok()
613            );
614        }
615        self.enforce_safe_directory_checked(&checked)
616    }
617
618    /// Enforce safe.directory checks using the repository git-dir path.
619    ///
620    /// Used by operations that explicitly open another repository by path
621    /// (e.g. local clone source).
622    pub fn enforce_safe_directory_git_dir(&self) -> Result<()> {
623        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
624            .ok()
625            .map(|v| {
626                let lower = v.to_ascii_lowercase();
627                v == "1" || lower == "true" || lower == "yes" || lower == "on"
628            })
629            .unwrap_or(false);
630        if !assume_different {
631            return Ok(());
632        }
633        let checked = self
634            .git_dir
635            .canonicalize()
636            .unwrap_or_else(|_| self.git_dir.clone());
637        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
638            eprintln!(
639                "debug-safe-directory(gitdir) checked={} git_dir={} work_tree={:?}",
640                checked.display(),
641                self.git_dir.display(),
642                self.work_tree
643            );
644        }
645        self.enforce_safe_directory_checked(&checked)
646    }
647
648    /// Enforce safe.directory checks against an explicit checked path.
649    pub fn enforce_safe_directory_git_dir_with_path(&self, checked: &Path) -> Result<()> {
650        let assume_different = std::env::var("GIT_TEST_ASSUME_DIFFERENT_OWNER")
651            .ok()
652            .map(|v| {
653                let lower = v.to_ascii_lowercase();
654                v == "1" || lower == "true" || lower == "yes" || lower == "on"
655            })
656            .unwrap_or(false);
657        if !assume_different {
658            return Ok(());
659        }
660        self.enforce_safe_directory_checked(checked)
661    }
662
663    fn enforce_safe_directory_checked(&self, checked: &Path) -> Result<()> {
664        let cfg = crate::config::ConfigSet::load(Some(&self.git_dir), true)
665            .unwrap_or_else(|_| crate::config::ConfigSet::new());
666        let mut values: Vec<String> = Vec::new();
667        for e in cfg.entries() {
668            if e.key == "safe.directory"
669                && e.scope != crate::config::ConfigScope::Local
670                && e.scope != crate::config::ConfigScope::Worktree
671            {
672                values.push(e.value.clone().unwrap_or_else(|| "true".to_owned()));
673            }
674        }
675
676        // Last empty assignment resets the list.
677        let mut effective: Vec<String> = Vec::new();
678        for v in values {
679            if v.is_empty() {
680                effective.clear();
681            } else {
682                effective.push(v);
683            }
684        }
685
686        let checked_s = checked.to_string_lossy().to_string();
687        if std::env::var("GRIT_DEBUG_SAFE_DIR").is_ok() {
688            eprintln!("debug-safe-directory values={:?}", effective);
689        }
690        if effective
691            .iter()
692            .any(|v| safe_directory_matches(v, &checked_s))
693        {
694            return Ok(());
695        }
696
697        Err(Error::DubiousOwnership(checked_s))
698    }
699}
700
701fn normalize_fs_path(raw: &str) -> String {
702    use std::path::Component;
703    let p = std::path::Path::new(raw);
704    let mut parts: Vec<String> = Vec::new();
705    let mut absolute = false;
706    for c in p.components() {
707        match c {
708            Component::RootDir => {
709                absolute = true;
710                parts.clear();
711            }
712            Component::CurDir => {}
713            Component::ParentDir => {
714                if !parts.is_empty() {
715                    parts.pop();
716                }
717            }
718            Component::Normal(s) => parts.push(s.to_string_lossy().to_string()),
719            Component::Prefix(_) => {}
720        }
721    }
722    let mut out = if absolute {
723        String::from("/")
724    } else {
725        String::new()
726    };
727    out.push_str(&parts.join("/"));
728    out
729}
730
731fn safe_directory_matches(config_value: &str, checked: &str) -> bool {
732    if config_value == "*" {
733        return true;
734    }
735    if config_value == "." {
736        // CWD only.
737        if let Ok(cwd) = std::env::current_dir() {
738            let cwd_s = normalize_fs_path(&cwd.to_string_lossy());
739            let checked_s = normalize_fs_path(checked);
740            return cwd_s == checked_s;
741        }
742        return false;
743    }
744
745    let canonicalize_or_normalize = |raw: &str| -> String {
746        let p = std::path::Path::new(raw);
747        if p.exists() {
748            p.canonicalize()
749                .map(|c| c.to_string_lossy().to_string())
750                .map(|s| normalize_fs_path(&s))
751                .unwrap_or_else(|_| normalize_fs_path(raw))
752        } else {
753            normalize_fs_path(raw)
754        }
755    };
756
757    let config_norm = canonicalize_or_normalize(config_value);
758    let checked_norm = normalize_fs_path(checked);
759
760    if config_norm.ends_with("/*") {
761        let prefix_raw = &config_norm[..config_norm.len() - 2];
762        let prefix_norm = canonicalize_or_normalize(prefix_raw);
763        let mut prefix = prefix_norm;
764        if !prefix.ends_with('/') {
765            prefix.push('/');
766        }
767        return checked_norm.starts_with(&prefix);
768    }
769
770    config_norm == checked_norm
771}
772
773/// Parse a gitfile's `"gitdir: <path>"` line.
774fn parse_gitfile(content: &str, base: &Path) -> Result<PathBuf> {
775    for line in content.lines() {
776        if let Some(rest) = line.strip_prefix("gitdir:") {
777            let rel = rest.trim();
778            let path = if Path::new(rel).is_absolute() {
779                PathBuf::from(rel)
780            } else {
781                base.join(rel)
782            };
783            if !path.exists() {
784                return Err(Error::NotARepository(path.display().to_string()));
785            }
786            return Ok(path);
787        }
788    }
789    Err(Error::NotARepository("invalid gitfile format".to_owned()))
790}
791
792/// Initialise a new Git repository at the given path.
793///
794/// Creates the standard directory skeleton (objects/, refs/heads/, refs/tags/,
795/// info/, hooks/) and a default `HEAD` pointing to `refs/heads/<initial_branch>`.
796///
797/// # Parameters
798///
799/// - `path` — root directory to initialise (created if absent).
800/// - `bare` — if true, `path` itself becomes the git-dir; otherwise `path/.git`.
801/// - `initial_branch` — branch name for `HEAD` (e.g. `"main"`).
802/// - `template_dir` — optional template directory; if `None`, a minimal skeleton
803///   is created.
804///
805/// # Errors
806///
807/// Returns [`Error::Io`] on filesystem failures.
808pub fn init_repository(
809    path: &Path,
810    bare: bool,
811    initial_branch: &str,
812    template_dir: Option<&Path>,
813) -> Result<Repository> {
814    let git_dir = if bare {
815        path.to_path_buf()
816    } else {
817        path.join(".git")
818    };
819
820    // Create directory structure
821    for sub in &[
822        "objects",
823        "objects/info",
824        "objects/pack",
825        "refs",
826        "refs/heads",
827        "refs/tags",
828        "info",
829        "hooks",
830    ] {
831        fs::create_dir_all(git_dir.join(sub))?;
832    }
833
834    // Copy template files if a template dir was given
835    if let Some(tmpl) = template_dir {
836        if tmpl.is_dir() {
837            copy_template(tmpl, &git_dir)?;
838        }
839    }
840
841    // Write HEAD
842    let head_content = format!("ref: refs/heads/{initial_branch}\n");
843    fs::write(git_dir.join("HEAD"), head_content)?;
844
845    // Write config (minimal)
846    let config_content = if bare {
847        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n"
848    } else {
849        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n"
850    };
851    fs::write(git_dir.join("config"), config_content)?;
852
853    // Write description
854    fs::write(
855        git_dir.join("description"),
856        "Unnamed repository; edit this file 'description' to name the repository.\n",
857    )?;
858
859    let work_tree = if bare { None } else { Some(path) };
860    Repository::open(&git_dir, work_tree)
861}
862
863/// Initialise a repository whose git directory is separate from the work tree.
864///
865/// Creates `git_dir` with the usual layout, writes `work_tree/.git` as a gitfile
866/// pointing at `git_dir`, and sets `core.worktree` in `git_dir/config`.
867pub fn init_repository_separate(
868    work_tree: &Path,
869    git_dir: &Path,
870    initial_branch: &str,
871    template_dir: Option<&Path>,
872) -> Result<Repository> {
873    fs::create_dir_all(work_tree)?;
874    if git_dir.exists() {
875        return Err(Error::PathError(format!(
876            "git directory '{}' already exists",
877            git_dir.display()
878        )));
879    }
880
881    for sub in &[
882        "objects",
883        "objects/info",
884        "objects/pack",
885        "refs",
886        "refs/heads",
887        "refs/tags",
888        "info",
889        "hooks",
890    ] {
891        fs::create_dir_all(git_dir.join(sub))?;
892    }
893
894    if let Some(tmpl) = template_dir {
895        if tmpl.is_dir() {
896            copy_template(tmpl, git_dir)?;
897        }
898    }
899
900    fs::write(
901        git_dir.join("HEAD"),
902        format!("ref: refs/heads/{initial_branch}\n"),
903    )?;
904
905    let work_tree_abs = fs::canonicalize(work_tree).unwrap_or_else(|_| work_tree.to_path_buf());
906    let git_dir_abs = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
907    let config_content = format!(
908        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n\tworktree = {}\n",
909        work_tree_abs.display()
910    );
911    fs::write(git_dir.join("config"), config_content)?;
912    fs::write(
913        git_dir.join("description"),
914        "Unnamed repository; edit this file 'description' to name the repository.\n",
915    )?;
916
917    let gitfile = work_tree.join(".git");
918    fs::write(&gitfile, format!("gitdir: {}\n", git_dir_abs.display()))?;
919
920    Repository::open(git_dir, Some(work_tree))
921}
922
923/// Recursively copy template files from `src` to `dst`.
924fn copy_template(src: &Path, dst: &Path) -> Result<()> {
925    for entry in fs::read_dir(src)? {
926        let entry = entry?;
927        let src_path = entry.path();
928        let dst_path = dst.join(entry.file_name());
929        if src_path.is_dir() {
930            fs::create_dir_all(&dst_path)?;
931            copy_template(&src_path, &dst_path)?;
932        } else {
933            fs::copy(&src_path, &dst_path)?;
934        }
935    }
936    Ok(())
937}
938
939/// Parse `GIT_CEILING_DIRECTORIES` into a list of canonical absolute paths.
940///
941/// The variable is colon-separated (`:`) on Unix.  Empty entries and
942/// non-absolute paths are silently skipped, matching Git's behaviour.
943fn parse_ceiling_directories() -> Vec<PathBuf> {
944    let raw = match env::var("GIT_CEILING_DIRECTORIES") {
945        Ok(val) => val,
946        Err(_) => return Vec::new(),
947    };
948    if raw.is_empty() {
949        return Vec::new();
950    }
951    raw.split(':')
952        .filter(|s| !s.is_empty())
953        .filter_map(|s| {
954            let p = PathBuf::from(s);
955            if !p.is_absolute() {
956                return None;
957            }
958            // Canonicalize to resolve symlinks; fall back to the raw path
959            // (with trailing slashes stripped) when the directory doesn't exist.
960            Some(p.canonicalize().unwrap_or_else(|_| {
961                // Strip trailing slashes for consistent comparison
962                let s = s.trim_end_matches('/');
963                PathBuf::from(s)
964            }))
965        })
966        .collect()
967}
968
969/// Check whether `dir` is blocked by any ceiling directory.
970///
971/// A ceiling directory `C` prevents looking at `C` itself and any of its
972/// ancestors during the upward walk.  Directories strictly below `C` are
973/// not blocked — i.e. if `dir` is a child of `C`, the walk may still look
974/// there.
975///
976/// In path terms: `dir` is blocked when the ceiling IS `dir` or IS a
977/// descendant of `dir` (meaning `ceil.starts_with(dir)`).
978fn is_ceiling_blocked(dir: &Path, ceilings: &[PathBuf]) -> bool {
979    if ceilings.is_empty() {
980        return false;
981    }
982    // Canonicalize `dir` for reliable comparison; if it fails (e.g. the path
983    // doesn't exist) fall back to the raw path.
984    let canon = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
985    for ceil in ceilings {
986        // Block when the walk has reached exactly the ceiling directory.
987        // Git's semantics: the ceiling prevents looking at the ceiling
988        // itself and anything above it.  Since we walk upward, once we hit
989        // the ceiling we stop.
990        if canon == *ceil {
991            return true;
992        }
993    }
994    false
995}
996
997/// Validate the repository format version from config text.
998/// Returns Ok if the format is supported, Err with message if not.
999pub fn validate_repo_config(config_text: &str) -> std::result::Result<(), String> {
1000    let mut version: u32 = 0;
1001    let mut in_core = false;
1002    for line in config_text.lines() {
1003        let trimmed = line.trim();
1004        if trimmed.starts_with('[') {
1005            in_core = trimmed.to_lowercase().starts_with("[core");
1006            continue;
1007        }
1008        if in_core {
1009            if let Some(rest) = trimmed.strip_prefix("repositoryformatversion") {
1010                let val = rest.trim_start_matches([' ', '=']).trim();
1011                if let Ok(v) = val.parse::<u32>() {
1012                    version = v;
1013                }
1014            }
1015        }
1016    }
1017    if version >= 2 {
1018        return Err(format!("unknown repository format version: {version}"));
1019    }
1020    Ok(())
1021}