1use anyhow::{bail, Context};
4use std::{
5 env,
6 path::{Path, PathBuf},
7 process::Command,
8 sync::OnceLock,
9};
10
11static GIT_DIR_CACHE: OnceLock<PathBuf> = OnceLock::new();
13
14static REPO_ROOT_CACHE: OnceLock<PathBuf> = OnceLock::new();
16
17fn 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
45fn 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
54pub 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 let _ = GIT_DIR_CACHE.set(validated.clone());
67 Ok(validated)
68}
69
70pub 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 let _ = REPO_ROOT_CACHE.set(validated.clone());
83 Ok(validated)
84}
85
86pub fn get_global_gitignore_path() -> Option<PathBuf> {
88 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 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
142pub 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
148pub 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(¤t_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 let _ = get_global_gitignore_path();
181 }
182}