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}