Skip to main content

dotenv_space/utils/
git.rs

1//! Git repository utilities.
2//!
3//! Thin wrappers around `git` CLI commands and `.gitignore` file manipulation.
4//! All functions that shell out to `git` gracefully return `false` or an error
5//! if git is not installed or the current directory is not a repository.
6//!
7//! # Future work
8//!
9//! - Replace CLI shelling with the `git2` crate for faster, dependency-free
10//!   operation (no requirement for git to be in `$PATH`).
11//! - Add `staged_files()` to list files currently in the git index.
12//! - Add `last_commit_touching(file)` for the doctor command to surface when
13//!   a `.env` file was last accidentally committed.
14
15use anyhow::{anyhow, Result};
16use std::process::Command;
17
18/// Return `true` if `file` is listed in `.gitignore`.
19///
20/// Reads `.gitignore` in the current working directory. Returns `false` (not
21/// an error) if `.gitignore` does not exist.
22///
23/// # Caveats
24///
25/// This is a simple line-by-line text search. It does not evaluate glob
26/// patterns, negation rules (`!pattern`), or directory-specific ignores.
27/// For a future revision, consider using `git check-ignore -v <file>` instead.
28pub fn is_in_gitignore(file: &str) -> Result<bool> {
29    let gitignore = match std::fs::read_to_string(".gitignore") {
30        Ok(content) => content,
31        // If .gitignore does not exist the file is definitely not ignored.
32        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
33        Err(e) => return Err(e.into()),
34    };
35
36    Ok(gitignore.lines().any(|line| {
37        let trimmed = line.trim();
38        trimmed == file || trimmed.starts_with(file)
39    }))
40}
41
42/// Append `file` to `.gitignore`, creating it if necessary.
43///
44/// A blank line and a `# Environment variables` comment are prepended so the
45/// entry is easy to find on manual inspection.
46pub fn add_to_gitignore(file: &str) -> Result<()> {
47    use std::io::Write;
48
49    let mut gitignore = std::fs::OpenOptions::new()
50        .create(true)
51        .append(true)
52        .open(".gitignore")?;
53
54    writeln!(gitignore, "\n# Environment variables")?;
55    writeln!(gitignore, "{}", file)?;
56
57    Ok(())
58}
59
60/// Return `true` if `file` is tracked by the Git index.
61///
62/// Uses `git ls-files --error-unmatch` which exits non-zero for untracked
63/// files. Returns `false` if git is not available or the directory is not a
64/// repository.
65pub fn is_tracked(file: &str) -> bool {
66    Command::new("git")
67        .args(["ls-files", "--error-unmatch", file])
68        .output()
69        .map(|o| o.status.success())
70        .unwrap_or(false)
71}
72
73/// Search git history for commits that introduced `pattern`.
74///
75/// Runs `git log -S <pattern> --all` and returns a list of
76/// `"<hash> <subject>"` strings, one per matching commit.
77///
78/// # Errors
79///
80/// Returns an error if git is not available, the directory is not a
81/// repository, or the command exits non-zero for any other reason.
82pub fn scan_history(pattern: &str) -> Result<Vec<String>> {
83    let output = Command::new("git")
84        .args(["log", "-S", pattern, "--all", "--pretty=format:%H %s"])
85        .output()?;
86
87    if !output.status.success() {
88        return Err(anyhow!(
89            "git log failed: {}",
90            String::from_utf8_lossy(&output.stderr).trim()
91        ));
92    }
93
94    let stdout = String::from_utf8(output.stdout)?;
95    Ok(stdout
96        .lines()
97        .filter(|s| !s.is_empty())
98        .map(|s| s.to_string())
99        .collect())
100}
101
102/// Return the name of the current git branch.
103///
104/// # Errors
105///
106/// Returns an error if the current directory is not a git repository or git
107/// is not installed.
108pub fn current_branch() -> Result<String> {
109    let output = Command::new("git")
110        .args(["branch", "--show-current"])
111        .output()?;
112
113    if !output.status.success() {
114        return Err(anyhow!("Not a git repository or git is not installed"));
115    }
116
117    Ok(String::from_utf8(output.stdout)?.trim().to_string())
118}
119
120/// Return `true` if the working directory has no uncommitted changes.
121///
122/// Uses `git status --porcelain` — an empty output means a clean tree.
123/// Returns `false` if git is not available or the directory is not a
124/// repository.
125pub fn is_clean() -> bool {
126    Command::new("git")
127        .args(["status", "--porcelain"])
128        .output()
129        .map(|o| o.stdout.is_empty())
130        .unwrap_or(false)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_is_in_gitignore_missing_file_returns_false() {
139        // .gitignore may or may not exist in the test environment.
140        // We only assert that the function does not panic or return an Err
141        // when the file is absent — the Ok(bool) contract must hold.
142        let result = is_in_gitignore("some_file_that_is_not_there");
143        assert!(result.is_ok());
144    }
145
146    #[test]
147    fn test_is_in_gitignore_finds_entry() {
148        use std::io::Write;
149        use tempfile::tempdir;
150
151        let dir = tempdir().unwrap();
152        let gitignore_path = dir.path().join(".gitignore");
153        let mut f = std::fs::File::create(&gitignore_path).unwrap();
154        writeln!(f, ".env").unwrap();
155        writeln!(f, "*.log").unwrap();
156
157        // Read directly rather than relying on cwd for a hermetic test.
158        let content = std::fs::read_to_string(&gitignore_path).unwrap();
159        let found = content.lines().any(|line| {
160            let t = line.trim();
161            t == ".env" || t.starts_with(".env")
162        });
163        assert!(found);
164    }
165}