git-cloak 0.0.1

The Invisible Layer for Your Repositories - Manage private, untracked files across Git clones.
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 },
}

/// Discover the repository work directory (absolute).
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())
}

/// Return the path to `.git/info/exclude` (handles worktrees via `common_dir()`).
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"))
}

/// Idempotently add `entries` to the exclude file inside a marker block.
/// Each entry is prefixed with `/` for root-relative gitignore semantics.
///
/// `dir`: a Dir handle that contains the exclude file.
/// `rel_path`: the relative path to the exclude file within `dir`.
/// `abs_context`: absolute path for error messages.
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)
}

/// Idempotently remove `entries` from the marker block. Removes the block if empty.
///
/// `dir`: a Dir handle that contains the exclude file.
/// `rel_path`: the relative path to the exclude file within `dir`.
/// `abs_context`: absolute path for error messages.
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)
}

/// Split file content into (before_block, block_entries, after_block).
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();
            // Find the newline after MARKER_BEGIN
            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();
            // Skip the newline after MARKER_END if present
            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(), ""),
    }
}

/// Rebuild the exclude file content. Omits the marker block entirely if `managed` is empty.
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;

    /// Helper: open a Dir handle for a 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");
    }
}