Skip to main content

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 git common directory path
15static GIT_COMMON_DIR_CACHE: OnceLock<PathBuf> = OnceLock::new();
16
17/// Cache for repository root path
18static REPO_ROOT_CACHE: OnceLock<PathBuf> = OnceLock::new();
19
20/// Execute git command and return stdout
21fn run_git_command(args: &[&str]) -> anyhow::Result<String> {
22    let output = Command::new("git")
23        .args(args)
24        .output()
25        .with_context(|| "Git not found in PATH")?;
26
27    if !output.status.success() {
28        let stderr = String::from_utf8_lossy(&output.stderr);
29        let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
30
31        bail!(
32            "Not in a git repository (cwd: {}): {}",
33            cwd.display(),
34            stderr.trim()
35        );
36    }
37
38    let stdout = String::from_utf8_lossy(&output.stdout);
39    let result = stdout.trim();
40
41    if result.is_empty() {
42        bail!("Git command returned empty output: git {}", args.join(" "));
43    }
44
45    Ok(result.to_string())
46}
47
48/// Validate that git returned a reasonable path
49fn validate_git_path(path: &Path) -> anyhow::Result<PathBuf> {
50    let resolved = path
51        .canonicalize()
52        .with_context(|| format!("Invalid path returned by git: {}", path.display()))?;
53
54    Ok(resolved)
55}
56
57/// Resolve a path-valued `git rev-parse <rev_parse_arg>` invocation, caching
58/// the validated result.
59fn cached_git_path(
60    cache: &OnceLock<PathBuf>,
61    rev_parse_arg: &str,
62    error_context: &'static str,
63) -> anyhow::Result<PathBuf> {
64    if let Some(cached) = cache.get() {
65        return Ok(cached.clone());
66    }
67
68    let output = run_git_command(&["rev-parse", rev_parse_arg]).context(error_context)?;
69    let path = PathBuf::from(output);
70    let validated = validate_git_path(&path)?;
71
72    // Only cache if we succeed
73    let _ = cache.set(validated.clone());
74    Ok(validated)
75}
76
77/// Get the absolute path to the git directory (.git folder or file).
78///
79/// In a linked worktree, this is the worktree-private administrative
80/// directory (`.git/worktrees/<name>`), not the directory shared with the
81/// main repository and its other worktrees. For a file git reads from the
82/// shared location regardless of which worktree is active (e.g.
83/// `info/exclude`), use `get_git_common_dir` instead.
84pub fn get_git_dir() -> anyhow::Result<PathBuf> {
85    cached_git_path(
86        &GIT_DIR_CACHE,
87        "--absolute-git-dir",
88        "Failed to find git directory",
89    )
90}
91
92/// Get the absolute path to the git common directory.
93///
94/// In a linked worktree, `get_git_dir` returns the worktree-private
95/// administrative directory (`.git/worktrees/<name>`), but git reads
96/// `info/exclude` from the common directory shared by the main repository
97/// and all of its worktrees. Callers that need `info/exclude` must resolve
98/// it against this path rather than `get_git_dir`.
99pub fn get_git_common_dir() -> anyhow::Result<PathBuf> {
100    cached_git_path(
101        &GIT_COMMON_DIR_CACHE,
102        "--git-common-dir",
103        "Failed to find git common directory",
104    )
105}
106
107/// Get the absolute path to the repository root
108pub fn get_repo_root() -> anyhow::Result<PathBuf> {
109    cached_git_path(
110        &REPO_ROOT_CACHE,
111        "--show-toplevel",
112        "Failed to find repository root",
113    )
114}
115
116/// Get path to global gitignore file
117pub fn get_global_gitignore_path() -> Option<PathBuf> {
118    // Try to get configured global gitignore
119    if let Ok(output) = run_git_command(&["config", "--global", "core.excludesfile"]) {
120        let path = PathBuf::from(output);
121        let expanded = if path.starts_with("~") {
122            let home = env::var_os("HOME")?;
123            PathBuf::from(home).join(path.strip_prefix("~").unwrap())
124        } else if !path.is_absolute() {
125            let home = env::var_os("HOME")?;
126            PathBuf::from(home).join(&path)
127        } else {
128            path
129        };
130
131        if expanded.exists() {
132            return Some(expanded);
133        }
134    }
135
136    // Check default locations
137    if let Some(xdg_config) = env::var_os("XDG_CONFIG_HOME") {
138        let path = PathBuf::from(xdg_config).join("git").join("ignore");
139        if path.exists() {
140            return Some(path);
141        }
142    }
143
144    if let Some(home) = env::var_os("HOME") {
145        let home_path = PathBuf::from(&home);
146
147        let path = home_path.join(".config").join("git").join("ignore");
148        if path.exists() {
149            return Some(path);
150        }
151
152        let path = home_path.join(".gitignore_global");
153        if path.exists() {
154            return Some(path);
155        }
156
157        let path = home_path.join(".gitignore");
158        if path.exists() {
159            return Some(path);
160        }
161    }
162
163    None
164}
165
166/// Get path to repository's .git/info/exclude file
167pub fn get_exclude_file_path() -> anyhow::Result<PathBuf> {
168    let git_common_dir = get_git_common_dir()?;
169    Ok(git_common_dir.join("info").join("exclude"))
170}
171
172/// Get path to repository's .gitignore file
173pub fn get_gitignore_path() -> anyhow::Result<PathBuf> {
174    let repo_root = get_repo_root()?;
175    Ok(repo_root.join(".gitignore"))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::env;
182
183    #[test]
184    fn test_run_git_command_failure() {
185        let result = run_git_command(&["nonexistent-command"]);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_validate_git_path() {
191        let current_dir = env::current_dir().unwrap();
192        let result = validate_git_path(&current_dir);
193        assert!(result.is_ok());
194
195        let invalid_path = Path::new("/nonexistent/path/that/should/not/exist");
196        let result = validate_git_path(invalid_path);
197        assert!(result.is_err());
198    }
199
200    #[test]
201    fn test_get_global_gitignore_path() {
202        // This test might fail if no global gitignore is configured
203        // but should not panic
204        let _ = get_global_gitignore_path();
205    }
206}