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::env;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use crate::error::{Error, Result};
23use crate::odb::Odb;
24
25/// A handle to an open Git repository.
26#[derive(Debug)]
27pub struct Repository {
28    /// Absolute path to the git directory (`.git/` or bare repo root).
29    pub git_dir: PathBuf,
30    /// Absolute path to the working tree, or `None` for bare repos.
31    pub work_tree: Option<PathBuf>,
32    /// Loose object database.
33    pub odb: Odb,
34}
35
36impl Repository {
37    /// Open a repository from an explicit git-dir and optional work-tree.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`Error::NotARepository`] if `git_dir` does not look like a
42    /// valid git directory (missing `objects/`, `HEAD`, etc.).
43    pub fn open(git_dir: &Path, work_tree: Option<&Path>) -> Result<Self> {
44        let git_dir = git_dir
45            .canonicalize()
46            .map_err(|_| Error::NotARepository(git_dir.display().to_string()))?;
47
48        if !git_dir.join("HEAD").exists() {
49            return Err(Error::NotARepository(git_dir.display().to_string()));
50        }
51
52        // For git worktrees the `objects/` directory lives in the common git
53        // directory pointed to by the `commondir` file.
54        let objects_dir = if git_dir.join("objects").exists() {
55            git_dir.join("objects")
56        } else if let Ok(common_raw) = fs::read_to_string(git_dir.join("commondir")) {
57            let common_rel = common_raw.trim();
58            let common_dir = if Path::new(common_rel).is_absolute() {
59                PathBuf::from(common_rel)
60            } else {
61                git_dir.join(common_rel)
62            };
63            let common_dir = common_dir
64                .canonicalize()
65                .map_err(|_| Error::NotARepository(git_dir.display().to_string()))?;
66            common_dir.join("objects")
67        } else {
68            return Err(Error::NotARepository(git_dir.display().to_string()));
69        };
70
71        if !objects_dir.exists() {
72            return Err(Error::NotARepository(git_dir.display().to_string()));
73        }
74
75        let work_tree = match work_tree {
76            Some(p) => Some(
77                p.canonicalize()
78                    .map_err(|_| Error::PathError(p.display().to_string()))?,
79            ),
80            None => None,
81        };
82
83        let odb = Odb::new(&objects_dir);
84
85        Ok(Self {
86            git_dir,
87            work_tree,
88            odb,
89        })
90    }
91
92    /// Discover the repository starting from `start` (defaults to cwd if `None`).
93    ///
94    /// Checks `GIT_DIR` first; if set, uses it directly.  Otherwise walks up
95    /// the directory tree looking for `.git` (regular directory or gitfile).
96    ///
97    /// # Errors
98    ///
99    /// Returns [`Error::NotARepository`] if no repository can be found.
100    pub fn discover(start: Option<&Path>) -> Result<Self> {
101        // GIT_DIR override
102        if let Ok(dir) = env::var("GIT_DIR") {
103            let git_dir = PathBuf::from(dir);
104            let work_tree = env::var("GIT_WORK_TREE").ok().map(PathBuf::from);
105            return Self::open(&git_dir, work_tree.as_deref());
106        }
107
108        let cwd = env::current_dir()?;
109        let start = start.unwrap_or(&cwd);
110        let start = if start.is_absolute() {
111            start.to_path_buf()
112        } else {
113            cwd.join(start)
114        };
115
116        // Parse GIT_CEILING_DIRECTORIES — colon-separated list of absolute
117        // directory paths that limit upward repository discovery.
118        let ceiling_dirs = parse_ceiling_directories();
119
120        let mut current = start.as_path();
121        let mut first = true;
122        loop {
123            // On the first iteration we always check the starting directory.
124            // On subsequent iterations, check whether this parent directory is
125            // blocked by a ceiling entry before probing for .git.
126            if !first && is_ceiling_blocked(current, &ceiling_dirs) {
127                break;
128            }
129            first = false;
130
131            if let Some(repo) = try_open_at(current)? {
132                return Ok(repo);
133            }
134            match current.parent() {
135                Some(p) => current = p,
136                None => break,
137            }
138        }
139
140        Err(Error::NotARepository(start.display().to_string()))
141    }
142
143    /// Path to the index file.
144    #[must_use]
145    pub fn index_path(&self) -> PathBuf {
146        self.git_dir.join("index")
147    }
148
149    /// Path to the `refs/` directory.
150    #[must_use]
151    pub fn refs_dir(&self) -> PathBuf {
152        self.git_dir.join("refs")
153    }
154
155    /// Path to `HEAD`.
156    #[must_use]
157    pub fn head_path(&self) -> PathBuf {
158        self.git_dir.join("HEAD")
159    }
160
161    /// Whether this is a bare repository (no working tree).
162    #[must_use]
163    pub fn is_bare(&self) -> bool {
164        self.work_tree.is_none()
165    }
166}
167
168/// Try to open a repository rooted exactly at `dir`.
169///
170/// Returns `Ok(None)` when `dir` is not a repository root (the caller should
171/// walk up); returns `Err` on a structural problem.
172fn try_open_at(dir: &Path) -> Result<Option<Repository>> {
173    let dot_git = dir.join(".git");
174
175    if dot_git.is_file() {
176        // gitfile indirection: file contains "gitdir: <path>"
177        let content =
178            fs::read_to_string(&dot_git).map_err(|e| Error::NotARepository(e.to_string()))?;
179        let git_dir = parse_gitfile(&content, dir)?;
180        let repo = Repository::open(&git_dir, Some(dir))?;
181        return Ok(Some(repo));
182    }
183
184    if dot_git.is_dir() {
185        let repo = Repository::open(&dot_git, Some(dir))?;
186        return Ok(Some(repo));
187    }
188
189    // Check if `dir` itself is a bare repo (has objects/ and HEAD directly)
190    if dir.join("objects").is_dir() && dir.join("HEAD").is_file() {
191        let repo = Repository::open(dir, None)?;
192        return Ok(Some(repo));
193    }
194
195    Ok(None)
196}
197
198/// Parse a gitfile's `"gitdir: <path>"` line.
199fn parse_gitfile(content: &str, base: &Path) -> Result<PathBuf> {
200    for line in content.lines() {
201        if let Some(rest) = line.strip_prefix("gitdir:") {
202            let rel = rest.trim();
203            let path = if Path::new(rel).is_absolute() {
204                PathBuf::from(rel)
205            } else {
206                base.join(rel)
207            };
208            return Ok(path);
209        }
210    }
211    Err(Error::NotARepository(
212        "gitfile does not contain 'gitdir:' line".to_owned(),
213    ))
214}
215
216/// Initialise a new Git repository at the given path.
217///
218/// Creates the standard directory skeleton (objects/, refs/heads/, refs/tags/,
219/// info/, hooks/) and a default `HEAD` pointing to `refs/heads/<initial_branch>`.
220///
221/// # Parameters
222///
223/// - `path` — root directory to initialise (created if absent).
224/// - `bare` — if true, `path` itself becomes the git-dir; otherwise `path/.git`.
225/// - `initial_branch` — branch name for `HEAD` (e.g. `"main"`).
226/// - `template_dir` — optional template directory; if `None`, a minimal skeleton
227///   is created.
228///
229/// # Errors
230///
231/// Returns [`Error::Io`] on filesystem failures.
232pub fn init_repository(
233    path: &Path,
234    bare: bool,
235    initial_branch: &str,
236    template_dir: Option<&Path>,
237) -> Result<Repository> {
238    let git_dir = if bare {
239        path.to_path_buf()
240    } else {
241        path.join(".git")
242    };
243
244    // Create directory structure
245    for sub in &[
246        "objects",
247        "objects/info",
248        "objects/pack",
249        "refs",
250        "refs/heads",
251        "refs/tags",
252        "info",
253        "hooks",
254    ] {
255        fs::create_dir_all(git_dir.join(sub))?;
256    }
257
258    // Copy template files if a template dir was given
259    if let Some(tmpl) = template_dir {
260        if tmpl.is_dir() {
261            copy_template(tmpl, &git_dir)?;
262        }
263    }
264
265    // Write HEAD
266    let head_content = format!("ref: refs/heads/{initial_branch}\n");
267    fs::write(git_dir.join("HEAD"), head_content)?;
268
269    // Write config (minimal)
270    let config_content = if bare {
271        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n"
272    } else {
273        "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n\tlogallrefupdates = true\n"
274    };
275    fs::write(git_dir.join("config"), config_content)?;
276
277    // Write description
278    fs::write(
279        git_dir.join("description"),
280        "Unnamed repository; edit this file 'description' to name the repository.\n",
281    )?;
282
283    let work_tree = if bare { None } else { Some(path) };
284    Repository::open(&git_dir, work_tree)
285}
286
287/// Recursively copy template files from `src` to `dst`.
288fn copy_template(src: &Path, dst: &Path) -> Result<()> {
289    for entry in fs::read_dir(src)? {
290        let entry = entry?;
291        let src_path = entry.path();
292        let dst_path = dst.join(entry.file_name());
293        if src_path.is_dir() {
294            fs::create_dir_all(&dst_path)?;
295            copy_template(&src_path, &dst_path)?;
296        } else {
297            fs::copy(&src_path, &dst_path)?;
298        }
299    }
300    Ok(())
301}
302
303/// Parse `GIT_CEILING_DIRECTORIES` into a list of canonical absolute paths.
304///
305/// The variable is colon-separated (`:`) on Unix.  Empty entries and
306/// non-absolute paths are silently skipped, matching Git's behaviour.
307fn parse_ceiling_directories() -> Vec<PathBuf> {
308    let raw = match env::var("GIT_CEILING_DIRECTORIES") {
309        Ok(val) => val,
310        Err(_) => return Vec::new(),
311    };
312    if raw.is_empty() {
313        return Vec::new();
314    }
315    raw.split(':')
316        .filter(|s| !s.is_empty())
317        .filter_map(|s| {
318            let p = PathBuf::from(s);
319            if !p.is_absolute() {
320                return None;
321            }
322            // Canonicalize to resolve symlinks; fall back to the raw path
323            // (with trailing slashes stripped) when the directory doesn't exist.
324            Some(p.canonicalize().unwrap_or_else(|_| {
325                // Strip trailing slashes for consistent comparison
326                let s = s.trim_end_matches('/');
327                PathBuf::from(s)
328            }))
329        })
330        .collect()
331}
332
333/// Check whether `dir` is blocked by any ceiling directory.
334///
335/// A ceiling directory `C` prevents looking at `C` itself and any of its
336/// ancestors during the upward walk.  Directories strictly below `C` are
337/// not blocked — i.e. if `dir` is a child of `C`, the walk may still look
338/// there.
339///
340/// In path terms: `dir` is blocked when the ceiling IS `dir` or IS a
341/// descendant of `dir` (meaning `ceil.starts_with(dir)`).
342fn is_ceiling_blocked(dir: &Path, ceilings: &[PathBuf]) -> bool {
343    if ceilings.is_empty() {
344        return false;
345    }
346    // Canonicalize `dir` for reliable comparison; if it fails (e.g. the path
347    // doesn't exist) fall back to the raw path.
348    let canon = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
349    for ceil in ceilings {
350        // Block when the walk has reached exactly the ceiling directory.
351        // Git's semantics: the ceiling prevents looking at the ceiling
352        // itself and anything above it.  Since we walk upward, once we hit
353        // the ceiling we stop.
354        if canon == *ceil {
355            return true;
356        }
357    }
358    false
359}