git_ignore_tool/
git.rs

1//! Git repository utilities for path detection and resolution
2
3use anyhow::{bail, Context};
4use std::{
5    env,
6    path::{Path, PathBuf},
7    process::Command,
8    sync::OnceLock,
9};
10
11/// Cache for git directory path
12static GIT_DIR_CACHE: OnceLock<PathBuf> = OnceLock::new();
13
14/// Cache for repository root path
15static REPO_ROOT_CACHE: OnceLock<PathBuf> = OnceLock::new();
16
17/// Execute git command and return stdout
18fn run_git_command(args: &[&str]) -> anyhow::Result<String> {
19    let output = Command::new("git")
20        .args(args)
21        .output()
22        .with_context(|| "Git not found in PATH")?;
23
24    if !output.status.success() {
25        let stderr = String::from_utf8_lossy(&output.stderr);
26        let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
27
28        bail!(
29            "Not in a git repository (cwd: {}): {}",
30            cwd.display(),
31            stderr.trim()
32        );
33    }
34
35    let stdout = String::from_utf8_lossy(&output.stdout);
36    let result = stdout.trim();
37
38    if result.is_empty() {
39        bail!("Git command returned empty output: git {}", args.join(" "));
40    }
41
42    Ok(result.to_string())
43}
44
45/// Validate that git returned a reasonable path
46fn validate_git_path(path: &Path) -> anyhow::Result<PathBuf> {
47    let resolved = path
48        .canonicalize()
49        .with_context(|| format!("Invalid path returned by git: {}", path.display()))?;
50
51    Ok(resolved)
52}
53
54/// Get the absolute path to the git directory (.git folder or file)
55pub fn get_git_dir() -> anyhow::Result<PathBuf> {
56    if let Some(cached) = GIT_DIR_CACHE.get() {
57        return Ok(cached.clone());
58    }
59
60    let output = run_git_command(&["rev-parse", "--absolute-git-dir"])
61        .context("Failed to find git directory")?;
62    let path = PathBuf::from(output);
63    let validated = validate_git_path(&path)?;
64
65    // Only cache if we succeed
66    let _ = GIT_DIR_CACHE.set(validated.clone());
67    Ok(validated)
68}
69
70/// Get the absolute path to the repository root
71pub fn get_repo_root() -> anyhow::Result<PathBuf> {
72    if let Some(cached) = REPO_ROOT_CACHE.get() {
73        return Ok(cached.clone());
74    }
75
76    let output = run_git_command(&["rev-parse", "--show-toplevel"])
77        .context("Failed to find repository root")?;
78    let path = PathBuf::from(output);
79    let validated = validate_git_path(&path)?;
80
81    // Only cache if we succeed
82    let _ = REPO_ROOT_CACHE.set(validated.clone());
83    Ok(validated)
84}
85
86/// Get path to global gitignore file
87pub fn get_global_gitignore_path() -> Option<PathBuf> {
88    // Try to get configured global gitignore
89    if let Ok(output) = run_git_command(&["config", "--global", "core.excludesfile"]) {
90        let path = PathBuf::from(output);
91        let expanded = if path.starts_with("~") {
92            if let Some(home) = env::var_os("HOME") {
93                PathBuf::from(home).join(path.strip_prefix("~").unwrap())
94            } else {
95                return None;
96            }
97        } else if !path.is_absolute() {
98            if let Some(home) = env::var_os("HOME") {
99                PathBuf::from(home).join(&path)
100            } else {
101                return None;
102            }
103        } else {
104            path
105        };
106
107        if expanded.exists() {
108            return Some(expanded);
109        }
110    }
111
112    // Check default locations
113    if let Some(xdg_config) = env::var_os("XDG_CONFIG_HOME") {
114        let path = PathBuf::from(xdg_config).join("git").join("ignore");
115        if path.exists() {
116            return Some(path);
117        }
118    }
119
120    if let Some(home) = env::var_os("HOME") {
121        let home_path = PathBuf::from(&home);
122
123        let path = home_path.join(".config").join("git").join("ignore");
124        if path.exists() {
125            return Some(path);
126        }
127
128        let path = home_path.join(".gitignore_global");
129        if path.exists() {
130            return Some(path);
131        }
132
133        let path = home_path.join(".gitignore");
134        if path.exists() {
135            return Some(path);
136        }
137    }
138
139    None
140}
141
142/// Get path to repository's .git/info/exclude file
143pub fn get_exclude_file_path() -> anyhow::Result<PathBuf> {
144    let git_dir = get_git_dir()?;
145    Ok(git_dir.join("info").join("exclude"))
146}
147
148/// Get path to repository's .gitignore file
149pub fn get_gitignore_path() -> anyhow::Result<PathBuf> {
150    let repo_root = get_repo_root()?;
151    Ok(repo_root.join(".gitignore"))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::env;
158
159    #[test]
160    fn test_run_git_command_failure() {
161        let result = run_git_command(&["nonexistent-command"]);
162        assert!(result.is_err());
163    }
164
165    #[test]
166    fn test_validate_git_path() {
167        let current_dir = env::current_dir().unwrap();
168        let result = validate_git_path(&current_dir);
169        assert!(result.is_ok());
170
171        let invalid_path = Path::new("/nonexistent/path/that/should/not/exist");
172        let result = validate_git_path(invalid_path);
173        assert!(result.is_err());
174    }
175
176    #[test]
177    fn test_get_global_gitignore_path() {
178        // This test might fail if no global gitignore is configured
179        // but should not panic
180        let _ = get_global_gitignore_path();
181    }
182}