use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
use cap_std::fs::Dir;
const MARKER_BEGIN: &str = "# >>> git-cloak (do not edit) >>>";
const MARKER_END: &str = "# <<< git-cloak <<<";
#[derive(Debug, Error)]
pub enum GitUtilError {
#[error("not a git repository")]
NotARepo,
#[error("bare repositories are not supported")]
BareRepo,
#[error("failed to discover repository: {0}")]
Discover(#[from] gix::discover::Error),
#[error("I/O error at {path}: {source}")]
Io { path: PathBuf, source: io::Error },
}
pub fn repo_root(dir: &Path) -> Result<PathBuf, GitUtilError> {
let repo = gix::discover(dir)?;
let workdir = repo.workdir().ok_or(GitUtilError::BareRepo)?;
Ok(workdir.to_owned())
}
pub fn exclude_path(dir: &Path) -> Result<PathBuf, GitUtilError> {
let repo = gix::discover(dir)?;
let common = repo.common_dir().to_owned();
Ok(common.join("info").join("exclude"))
}
pub fn ensure_excluded(
dir: &Dir,
rel_path: &Path,
abs_context: &Path,
entries: &[&str],
) -> Result<(), GitUtilError> {
let io_err = |e: io::Error| GitUtilError::Io {
path: abs_context.to_owned(),
source: e,
};
let content = match dir.read_to_string(rel_path) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(io_err(e)),
};
let (before, existing, after) = parse_marker_block(&content);
let mut managed: Vec<String> = existing
.iter()
.map(|s| s.to_string())
.collect();
for &entry in entries {
let line = format!("/{entry}");
if !managed.contains(&line) {
managed.push(line);
}
}
let new_content = rebuild_content(before, &managed, after);
if let Some(parent) = rel_path.parent() {
if !parent.as_os_str().is_empty() {
dir.create_dir_all(parent).map_err(io_err)?;
}
}
dir.write(rel_path, new_content).map_err(io_err)
}
pub fn remove_excluded(
dir: &Dir,
rel_path: &Path,
abs_context: &Path,
entries: &[&str],
) -> Result<(), GitUtilError> {
let io_err = |e: io::Error| GitUtilError::Io {
path: abs_context.to_owned(),
source: e,
};
let content = match dir.read_to_string(rel_path) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(io_err(e)),
};
let (before, existing, after) = parse_marker_block(&content);
let to_remove: Vec<String> = entries.iter().map(|e| format!("/{e}")).collect();
let managed: Vec<String> = existing
.into_iter()
.map(|s| s.to_string())
.filter(|line| !to_remove.contains(line))
.collect();
let new_content = rebuild_content(before, &managed, after);
dir.write(rel_path, new_content).map_err(io_err)
}
fn parse_marker_block(content: &str) -> (&str, Vec<&str>, &str) {
let begin = content.find(MARKER_BEGIN);
let end = content.find(MARKER_END);
match (begin, end) {
(Some(b), Some(e)) if b < e => {
let before = &content[..b];
let block_start = b + MARKER_BEGIN.len();
let block_start = content[block_start..]
.find('\n')
.map(|i| block_start + i + 1)
.unwrap_or(block_start);
let block_content = &content[block_start..e];
let entries: Vec<&str> = block_content
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let after_end = e + MARKER_END.len();
let after_end = content[after_end..]
.starts_with('\n')
.then(|| after_end + 1)
.unwrap_or(after_end);
let after = &content[after_end..];
(before, entries, after)
}
_ => (content, Vec::new(), ""),
}
}
fn rebuild_content(before: &str, managed: &[String], after: &str) -> String {
let mut out = String::new();
out.push_str(before);
if !managed.is_empty() {
out.push_str(MARKER_BEGIN);
out.push('\n');
for line in managed {
out.push_str(line);
out.push('\n');
}
out.push_str(MARKER_END);
out.push('\n');
}
out.push_str(after);
out
}
#[cfg(test)]
mod tests {
use super::*;
use cap_std::ambient_authority;
use tempfile::TempDir;
fn open_dir(path: &Path) -> Dir {
Dir::open_ambient_dir(path, ambient_authority()).unwrap()
}
#[test]
fn ensure_excluded_creates_block() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("info/exclude");
let abs = tmp.path().join(rel);
ensure_excluded(&dir, rel, &abs, &["AGENTS.md"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert!(content.contains(MARKER_BEGIN));
assert!(content.contains("/AGENTS.md"));
assert!(content.contains(MARKER_END));
}
#[test]
fn ensure_excluded_is_idempotent() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("exclude");
let abs = tmp.path().join(rel);
ensure_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
ensure_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert_eq!(content.matches("/a.txt").count(), 1);
}
#[test]
fn ensure_excluded_adds_new_entry() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("exclude");
let abs = tmp.path().join(rel);
ensure_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
ensure_excluded(&dir, rel, &abs, &["b.txt"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert!(content.contains("/a.txt"));
assert!(content.contains("/b.txt"));
}
#[test]
fn ensure_excluded_preserves_existing_content() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("exclude");
let abs = tmp.path().join(rel);
dir.write(rel, "# my custom rules\n*.log\n").unwrap();
ensure_excluded(&dir, rel, &abs, &["secret.txt"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert!(content.contains("# my custom rules"));
assert!(content.contains("*.log"));
assert!(content.contains("/secret.txt"));
}
#[test]
fn remove_excluded_removes_entry() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("exclude");
let abs = tmp.path().join(rel);
ensure_excluded(&dir, rel, &abs, &["a.txt", "b.txt"]).unwrap();
remove_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert!(!content.contains("/a.txt"));
assert!(content.contains("/b.txt"));
}
#[test]
fn remove_excluded_removes_block_when_empty() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("exclude");
let abs = tmp.path().join(rel);
dir.write(rel, "# before\n").unwrap();
ensure_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
remove_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
let content = dir.read_to_string(rel).unwrap();
assert!(!content.contains(MARKER_BEGIN));
assert!(!content.contains(MARKER_END));
assert!(content.contains("# before"));
}
#[test]
fn remove_excluded_noop_on_missing_file() {
let tmp = TempDir::new().unwrap();
let dir = open_dir(tmp.path());
let rel = Path::new("nonexistent");
let abs = tmp.path().join(rel);
remove_excluded(&dir, rel, &abs, &["a.txt"]).unwrap();
}
#[test]
fn parse_marker_block_no_block() {
let content = "# some stuff\n*.log\n";
let (before, entries, after) = parse_marker_block(content);
assert_eq!(before, content);
assert!(entries.is_empty());
assert_eq!(after, "");
}
#[test]
fn parse_marker_block_with_block() {
let content = format!(
"# before\n{MARKER_BEGIN}\n/a.txt\n/b.txt\n{MARKER_END}\n# after\n"
);
let (before, entries, after) = parse_marker_block(&content);
assert_eq!(before, "# before\n");
assert_eq!(entries, vec!["/a.txt", "/b.txt"]);
assert_eq!(after, "# after\n");
}
}