use std::path::{Path, PathBuf};
use crate::crypto::key::MasterKey;
use crate::crypto::keyfile;
use crate::{Error, Result};
pub const CONFIG_FILE: &str = ".git-xcrypt";
pub const KEY_ENVELOPE_DIR: &str = ".git-xcrypt-keys";
pub const ATTRIBUTES_FILE: &str = ".gitattributes";
pub const DRIVER: &str = "git-xcrypt";
#[derive(Debug)]
pub struct Repo {
git_dir: PathBuf,
common_dir: PathBuf,
work_tree: PathBuf,
}
impl Repo {
pub fn discover(start: &Path) -> Result<Self> {
let (path, _trust) = gix_discover::upwards(start)
.map_err(|err| Error::Config(format!("not inside a git repository: {err}")))?;
let (git_dir, work_tree) = path.into_repository_and_work_tree_directories();
let work_tree = work_tree.ok_or_else(|| {
Error::Config("this is a bare repository, so there is nothing to encrypt".into())
})?;
let git_dir = absolute(&git_dir);
Ok(Self {
common_dir: common_dir(&git_dir),
git_dir,
work_tree: absolute(&work_tree),
})
}
pub fn discover_from_cwd() -> Result<Self> {
let cwd = std::env::current_dir()?;
Self::discover(&cwd)
}
#[must_use]
pub fn git_dir(&self) -> &Path {
&self.git_dir
}
#[must_use]
pub fn work_tree(&self) -> &Path {
&self.work_tree
}
#[must_use]
pub fn common_dir(&self) -> &Path {
&self.common_dir
}
#[must_use]
pub fn key_path(&self) -> PathBuf {
self.common_dir.join(DRIVER).join("keys").join("default")
}
#[must_use]
pub fn config_path(&self) -> PathBuf {
self.common_dir.join("config")
}
#[must_use]
pub fn xcrypt_config_path(&self) -> PathBuf {
self.work_tree.join(CONFIG_FILE)
}
#[must_use]
pub fn attributes_path(&self) -> PathBuf {
self.work_tree.join(ATTRIBUTES_FILE)
}
#[must_use]
pub fn has_key(&self) -> bool {
self.key_path().is_file()
}
pub fn load_key(&self) -> Result<MasterKey> {
keyfile::read(&self.key_path())
}
#[must_use]
pub fn relative<'a>(&self, path: &'a Path) -> Option<&'a Path> {
path.strip_prefix(&self.work_tree).ok()
}
#[must_use]
pub fn work_trees(&self) -> Vec<PathBuf> {
let mut trees = vec![self.work_tree.clone()];
for entry in std::fs::read_dir(self.common_dir.join("worktrees"))
.into_iter()
.flatten()
.flatten()
{
let registration = entry.path();
let Ok(text) = std::fs::read_to_string(registration.join("gitdir")) else {
continue;
};
let pointer = Path::new(text.trim_end_matches(['\n', '\r']));
if pointer.as_os_str().is_empty() {
continue;
}
let absolute = if pointer.is_absolute() {
pointer.to_path_buf()
} else {
lexically_normal(®istration.join(pointer))
};
if let Some(checkout) = absolute.parent() {
trees.push(checkout.to_path_buf());
}
}
if let Some(main) = self.main_work_tree() {
trees.push(main);
}
trees
}
fn main_work_tree(&self) -> Option<PathBuf> {
let config = crate::git::config::open_local(&self.config_path()).ok()?;
if crate::git::config::get(&config, "core.bare")
.as_deref()
.is_some_and(crate::git::config::is_true)
{
return None;
}
if let Some(declared) = crate::git::config::get(&config, "core.worktree")
&& !declared.is_empty()
{
let path = Path::new(&declared);
return Some(if path.is_absolute() {
path.to_path_buf()
} else {
lexically_normal(&self.common_dir.join(path))
});
}
(self.common_dir.file_name() == Some(std::ffi::OsStr::new(".git")))
.then(|| self.common_dir.parent().map(Path::to_path_buf))
.flatten()
}
}
fn common_dir(git_dir: &Path) -> PathBuf {
let Ok(text) = std::fs::read_to_string(git_dir.join("commondir")) else {
return git_dir.to_path_buf();
};
let target = Path::new(text.trim_end_matches(['\n', '\r']));
if target.as_os_str().is_empty() {
return git_dir.to_path_buf();
}
if target.is_absolute() {
return target.to_path_buf();
}
lexically_normal(&git_dir.join(target))
}
#[must_use]
pub fn lexically_normal(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other),
}
}
out
}
#[must_use]
pub fn working_tree_path(name: &[u8]) -> PathBuf {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
PathBuf::from(std::ffi::OsStr::from_bytes(name))
}
#[cfg(not(unix))]
{
PathBuf::from(String::from_utf8_lossy(name).into_owned())
}
}
#[cfg(windows)]
pub(crate) const NATIVE_SEPARATOR: char = '\\';
#[cfg(not(windows))]
pub(crate) const NATIVE_SEPARATOR: char = '/';
#[must_use]
pub fn git_spelling(path: &Path) -> String {
with_separator(&path.display().to_string(), NATIVE_SEPARATOR)
}
pub(crate) fn with_separator(rendered: &str, separator: char) -> String {
if separator == '/' {
return rendered.to_string();
}
rendered.replace(separator, "/")
}
fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_built_from_components_is_spelled_the_way_git_spells_it() {
let joined = Path::new("secrets").join("db.env");
assert_eq!(
with_separator("secrets\\db.env", '\\'),
"secrets/db.env",
"a message must not show a spelling `git status` never prints"
);
assert_eq!(with_separator("a\\b\\c.env", '\\'), "a/b/c.env");
assert_eq!(with_separator("secrets/db.env", '/'), "secrets/db.env");
assert_eq!(git_spelling(&joined), "secrets/db.env");
}
}