Skip to main content

git_xcrypt/git/
repo.rs

1//! Locating the repository and reading its state — without spawning `git`.
2//!
3//! Spawning is not an option: git starts a fresh filter process per operation
4//! and the binary is required to be self-contained, so every answer here comes
5//! from a library.
6
7use std::path::{Path, PathBuf};
8
9use crate::crypto::key::MasterKey;
10use crate::crypto::keyfile;
11use crate::{Error, Result};
12
13/// Name of the versioned configuration file listing what to encrypt.
14pub const CONFIG_FILE: &str = ".git-xcrypt";
15
16/// Directory holding key envelopes, once recipients exist. Never encrypted.
17pub const KEY_ENVELOPE_DIR: &str = ".git-xcrypt-keys";
18
19/// The attributes file git actually reads.
20pub const ATTRIBUTES_FILE: &str = ".gitattributes";
21
22/// Name of the filter driver as registered in `.git/config`.
23pub const DRIVER: &str = "git-xcrypt";
24
25/// A discovered repository.
26#[derive(Debug)]
27pub struct Repo {
28    git_dir: PathBuf,
29    common_dir: PathBuf,
30    work_tree: PathBuf,
31}
32
33impl Repo {
34    /// Finds the repository containing `start`, walking upwards.
35    ///
36    /// # Errors
37    ///
38    /// [`Error::Config`] when there is no repository, or when it is bare —
39    /// a bare repository has no working tree, so there is nothing to filter.
40    pub fn discover(start: &Path) -> Result<Self> {
41        let (path, _trust) = gix_discover::upwards(start)
42            .map_err(|err| Error::Config(format!("not inside a git repository: {err}")))?;
43        let (git_dir, work_tree) = path.into_repository_and_work_tree_directories();
44        let work_tree = work_tree.ok_or_else(|| {
45            Error::Config("this is a bare repository, so there is nothing to encrypt".into())
46        })?;
47
48        let git_dir = absolute(&git_dir);
49        Ok(Self {
50            common_dir: common_dir(&git_dir),
51            git_dir,
52            work_tree: absolute(&work_tree),
53        })
54    }
55
56    /// Finds the repository containing the current directory.
57    ///
58    /// # Errors
59    ///
60    /// As [`Repo::discover`], plus [`Error::Io`] if the current directory is gone.
61    pub fn discover_from_cwd() -> Result<Self> {
62        let cwd = std::env::current_dir()?;
63        Self::discover(&cwd)
64    }
65
66    /// The `.git` directory.
67    #[must_use]
68    pub fn git_dir(&self) -> &Path {
69        &self.git_dir
70    }
71
72    /// The working tree root.
73    #[must_use]
74    pub fn work_tree(&self) -> &Path {
75        &self.work_tree
76    }
77
78    /// The directory shared by every worktree — the real `.git`.
79    ///
80    /// The same as [`Repo::git_dir`] outside a linked worktree.
81    #[must_use]
82    pub fn common_dir(&self) -> &Path {
83        &self.common_dir
84    }
85
86    /// Where the repository key lives. Never versioned, never committed.
87    ///
88    /// In the common directory, so every linked worktree of a repository reads
89    /// the same key — the alternative is a per-worktree key, which cannot
90    /// decrypt what the other worktrees committed.
91    #[must_use]
92    pub fn key_path(&self) -> PathBuf {
93        self.common_dir.join(DRIVER).join("keys").join("default")
94    }
95
96    /// The config file git actually reads for this repository.
97    ///
98    /// In the common directory, again: a linked worktree's own git dir has a
99    /// `config` file, but git ignores it unless `extensions.worktreeConfig` is
100    /// set. Registering the driver there left git with no filter at all —
101    /// measured on git 2.55, `git add` on a secret from a linked worktree exited
102    /// 0 and stored the plaintext.
103    #[must_use]
104    pub fn config_path(&self) -> PathBuf {
105        self.common_dir.join("config")
106    }
107
108    /// The versioned list of what to encrypt.
109    #[must_use]
110    pub fn xcrypt_config_path(&self) -> PathBuf {
111        self.work_tree.join(CONFIG_FILE)
112    }
113
114    /// The attributes file git reads.
115    #[must_use]
116    pub fn attributes_path(&self) -> PathBuf {
117        self.work_tree.join(ATTRIBUTES_FILE)
118    }
119
120    /// Whether a repository key is present.
121    #[must_use]
122    pub fn has_key(&self) -> bool {
123        self.key_path().is_file()
124    }
125
126    /// Loads the repository key.
127    ///
128    /// # Errors
129    ///
130    /// [`Error::NoKey`] when the repository is locked or was never initialised.
131    pub fn load_key(&self) -> Result<MasterKey> {
132        keyfile::read(&self.key_path())
133    }
134
135    /// Turns a path inside the working tree into a repository-relative one.
136    ///
137    /// Returns `None` for a path outside the working tree, which is how callers
138    /// refuse to act on something that is not part of this repository.
139    #[must_use]
140    pub fn relative<'a>(&self, path: &'a Path) -> Option<&'a Path> {
141        path.strip_prefix(&self.work_tree).ok()
142    }
143
144    /// Every checkout of this repository: this one, the main one, and every
145    /// linked worktree.
146    ///
147    /// [`Repo::work_tree`] answers for the checkout a command was run from, and
148    /// that is not the same question. A key written into a *sibling* checkout is
149    /// as committable as one written into this one — measured on git 2.55,
150    /// `export-key ../linked/k.key` from the main checkout put the key in the
151    /// linked worktree's `git status` as `?? k.key`, one `git add -A` from a
152    /// commit. `lock` already had to know this geometry to avoid stranding a
153    /// sibling; the refusal in `export-key` needs the same list.
154    ///
155    /// Best effort on the pointers, and that is the honest limit: a worktree
156    /// whose registration this cannot read is not in the list, so a caller uses
157    /// it to *widen* a refusal, never to prove a path is safe.
158    #[must_use]
159    pub fn work_trees(&self) -> Vec<PathBuf> {
160        let mut trees = vec![self.work_tree.clone()];
161
162        // `worktrees/<name>/gitdir` names the `.git` *file* in the checkout, so
163        // the checkout itself is its parent. A relative pointer is measured from
164        // the registration, which is where git measures it from.
165        for entry in std::fs::read_dir(self.common_dir.join("worktrees"))
166            .into_iter()
167            .flatten()
168            .flatten()
169        {
170            let registration = entry.path();
171            let Ok(text) = std::fs::read_to_string(registration.join("gitdir")) else {
172                continue;
173            };
174            let pointer = Path::new(text.trim_end_matches(['\n', '\r']));
175            if pointer.as_os_str().is_empty() {
176                continue;
177            }
178            let absolute = if pointer.is_absolute() {
179                pointer.to_path_buf()
180            } else {
181                lexically_normal(&registration.join(pointer))
182            };
183            if let Some(checkout) = absolute.parent() {
184                trees.push(checkout.to_path_buf());
185            }
186        }
187
188        if let Some(main) = self.main_work_tree() {
189            trees.push(main);
190        }
191        trees
192    }
193
194    /// Where the main checkout is, when this is a linked worktree.
195    ///
196    /// Not "the parent of the common directory": with `git init
197    /// --separate-git-dir` the common directory is somewhere else entirely and
198    /// is not called `.git`. Git finds the checkout through `core.worktree`
199    /// there, so this does too.
200    fn main_work_tree(&self) -> Option<PathBuf> {
201        let config = crate::git::config::open_local(&self.config_path()).ok()?;
202        if crate::git::config::get(&config, "core.bare")
203            .as_deref()
204            .is_some_and(crate::git::config::is_true)
205        {
206            return None;
207        }
208
209        if let Some(declared) = crate::git::config::get(&config, "core.worktree")
210            && !declared.is_empty()
211        {
212            let path = Path::new(&declared);
213            return Some(if path.is_absolute() {
214                path.to_path_buf()
215            } else {
216                lexically_normal(&self.common_dir.join(path))
217            });
218        }
219
220        (self.common_dir.file_name() == Some(std::ffi::OsStr::new(".git")))
221            .then(|| self.common_dir.parent().map(Path::to_path_buf))
222            .flatten()
223    }
224}
225
226/// The directory every worktree of this repository shares.
227///
228/// A linked worktree's git dir is `.git/worktrees/<name>`, and it names the real
229/// one in a `commondir` file. Everything that belongs to the repository rather
230/// than to one checkout — the key, the filter registration — lives there.
231fn common_dir(git_dir: &Path) -> PathBuf {
232    let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) else {
233        return git_dir.to_path_buf();
234    };
235    let target = Path::new(text.trim_end_matches(['\n', '\r']));
236    if target.as_os_str().is_empty() {
237        return git_dir.to_path_buf();
238    }
239    if target.is_absolute() {
240        return target.to_path_buf();
241    }
242    lexically_normal(&git_dir.join(target))
243}
244
245/// Resolves `.` and `..` without touching the filesystem.
246///
247/// `commondir` holds a relative path such as `../..`, and leaving it in place
248/// would make every message name a path no user recognises. `export-key` needs
249/// the same thing for a destination that does not exist yet, which is why this
250/// is public: a path it cannot resolve is a path it cannot prove lies outside
251/// the repository.
252///
253/// Only safe on a path whose components are known not to be symlinks — popping
254/// on `..` is what a symlink would make wrong.
255#[must_use]
256pub fn lexically_normal(path: &Path) -> PathBuf {
257    let mut out = PathBuf::new();
258    for component in path.components() {
259        match component {
260            std::path::Component::CurDir => {}
261            std::path::Component::ParentDir => {
262                if !out.pop() {
263                    out.push("..");
264                }
265            }
266            other => out.push(other),
267        }
268    }
269    out
270}
271
272/// The working-tree path an index entry names, without decoding it.
273///
274/// The index spells paths as raw bytes with forward slashes, and on Unix that is
275/// what a filename is — going through a lossy `String` would turn any byte that
276/// is not UTF-8 into U+FFFD and open a file that does not exist, or worse, a
277/// different one. The same mistake was found and fixed on the filter path in the
278/// S-01 review.
279///
280/// Shared rather than spelled once per caller: `status` reads working-tree files
281/// by index name and `lock` proves them closed by index name, and two answers to
282/// "which file does this entry mean" is one answer too many. Messages take a
283/// different route — they may be lossy, and they say so.
284#[must_use]
285pub fn working_tree_path(name: &[u8]) -> PathBuf {
286    #[cfg(unix)]
287    {
288        use std::os::unix::ffi::OsStrExt as _;
289        PathBuf::from(std::ffi::OsStr::from_bytes(name))
290    }
291    #[cfg(not(unix))]
292    {
293        // Windows filenames are UTF-16 and git spells them as UTF-8 here, so
294        // there is no lossless byte route and nothing is lost by this one.
295        PathBuf::from(String::from_utf8_lossy(name).into_owned())
296    }
297}
298
299/// The separator this platform's [`Path`] renders, and the only character it is
300/// safe to rewrite into git's spelling.
301///
302/// On Unix a backslash is an ordinary character in a file name, so rewriting one
303/// there would name a *different* file — which is the same class of bug as
304/// decoding a path lossily.
305/// Shared with `init`, which has to spell the filter command git runs the same
306/// way for the same reason: two answers to "which character is a separator here"
307/// is one answer too many.
308#[cfg(windows)]
309pub(crate) const NATIVE_SEPARATOR: char = '\\';
310#[cfg(not(windows))]
311pub(crate) const NATIVE_SEPARATOR: char = '/';
312
313/// Renders a path the way git spells one: forward slashes on every platform.
314///
315/// Git prints forward slashes on Windows too — `git status`, `git ls-files`,
316/// `git diff --name-only`, all of them — and so do the index, the pattern
317/// matcher and `.gitattributes` in this crate. A message that tells the user to
318/// `git add` a file has to spell it the way the `git status` next to it will, or
319/// the two do not look like the same file.
320///
321/// For a path *inside* the repository, and for an attribute source a reader is
322/// meant to paste back into git. An absolute filesystem path the user is to hand
323/// to their shell — a key file, a git directory, a destination for `export-key` —
324/// keeps its native separators and must not come through here.
325#[must_use]
326pub fn git_spelling(path: &Path) -> String {
327    with_separator(&path.display().to_string(), NATIVE_SEPARATOR)
328}
329
330/// The platform-independent core, so both spellings are testable from either
331/// platform. A no-op when the native separator already is git's.
332pub(crate) fn with_separator(rendered: &str, separator: char) -> String {
333    if separator == '/' {
334        return rendered.to_string();
335    }
336    rendered.replace(separator, "/")
337}
338
339/// Makes a path absolute without touching the filesystem when it already is.
340///
341/// `canonicalize` would resolve symlinks, which changes what the user sees in
342/// messages and would make a repository reached through a symlink report a
343/// different root than the one they typed.
344fn absolute(path: &Path) -> PathBuf {
345    if path.is_absolute() {
346        return path.to_path_buf();
347    }
348    std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    /// The Windows half of [`git_spelling`], exercised from any platform.
356    ///
357    /// The developing machine is not Windows, so the branch that matters most
358    /// would otherwise be covered by CI alone — and it was CI that found the
359    /// message this helper exists to fix.
360    #[test]
361    fn a_path_built_from_components_is_spelled_the_way_git_spells_it() {
362        let joined = Path::new("secrets").join("db.env");
363
364        // What Windows renders, put through the same core the Windows build uses.
365        assert_eq!(
366            with_separator("secrets\\db.env", '\\'),
367            "secrets/db.env",
368            "a message must not show a spelling `git status` never prints"
369        );
370        assert_eq!(with_separator("a\\b\\c.env", '\\'), "a/b/c.env");
371
372        // And on a platform whose separator already is git's, nothing moves.
373        assert_eq!(with_separator("secrets/db.env", '/'), "secrets/db.env");
374
375        // Whichever platform this runs on, the public entry point agrees.
376        assert_eq!(git_spelling(&joined), "secrets/db.env");
377    }
378}