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(®istration.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}