Skip to main content

resq_cli/
gitignore.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17// Gitignore parsing and utilities.
18//
19// Provides functions for parsing .gitignore files and matching
20// paths against ignore patterns.
21
22use std::collections::HashSet;
23use std::fs;
24use std::path::Path;
25
26/// Fallback exclude dirs when `.gitignore` is missing or unreadable.
27const FALLBACK_EXCLUDES: &[&str] = &[
28    "node_modules",
29    ".git",
30    "dist",
31    "build",
32    ".next",
33    "target",
34    "__pycache__",
35    ".venv",
36    "venv",
37    "vendor",
38    ".turbo",
39    "coverage",
40];
41
42/// Parse `.gitignore` from `root` and return a list of simple directory/file
43/// names to exclude during traversal.
44///
45/// Strategy (matches the TS `parseGitignore` in `sync-turbo-env.ts`):
46/// - Read `.gitignore`, split into lines
47/// - Strip comments (`#`) and blank lines
48/// - Normalize: remove leading `/` and trailing `/`
49/// - Drop negations (`!`) and wildcard patterns (`*`) — too complex for
50///   simple component-based matching; these are already handled by git itself
51/// - Always include `.git` and `node_modules` as safety nets
52pub fn parse_gitignore(root: &Path) -> Vec<String> {
53    let gitignore_path = root.join(".gitignore");
54
55    let content = match fs::read_to_string(&gitignore_path) {
56        Ok(c) => c,
57        Err(_) => {
58            return FALLBACK_EXCLUDES.iter().map(|s| (*s).to_string()).collect();
59        }
60    };
61
62    let mut seen = HashSet::new();
63    let mut excludes: Vec<String> = Vec::new();
64
65    for raw_line in content.lines() {
66        let line = raw_line.trim();
67
68        // Skip empty lines and comments
69        if line.is_empty() || line.starts_with('#') {
70            continue;
71        }
72
73        // Skip negation patterns and wildcard patterns
74        if line.starts_with('!') || line.contains('*') {
75            continue;
76        }
77
78        // Normalize: strip leading `/` and trailing `/`
79        let normalized = line.trim_start_matches('/').trim_end_matches('/');
80
81        if normalized.is_empty() {
82            continue;
83        }
84
85        // Only keep simple names (no path separators) for component matching
86        if !normalized.contains('/') && seen.insert(normalized.to_string()) {
87            excludes.push(normalized.to_string());
88        }
89    }
90
91    // Always ensure these are present
92    for must_have in &[".git", "node_modules"] {
93        if seen.insert((*must_have).to_string()) {
94            excludes.push((*must_have).to_string());
95        }
96    }
97
98    excludes
99}
100
101/// Check whether `path` should be skipped based on its directory components
102/// matching any entry in `excludes`.
103pub fn should_skip_path(path: &Path, excludes: &[String]) -> bool {
104    for component in path.components() {
105        let name = component.as_os_str().to_string_lossy();
106        if excludes.iter().any(|ex| name == *ex) {
107            return true;
108        }
109    }
110    false
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::path::PathBuf;
117    use tempfile::tempdir;
118
119    #[test]
120    fn test_parse_gitignore_basic() {
121        let dir = tempdir().unwrap();
122        fs::write(
123            dir.path().join(".gitignore"),
124            "# comment\nnode_modules/\ndist\n\n*.log\n!important\ntarget/\n",
125        )
126        .unwrap();
127
128        let excludes = parse_gitignore(dir.path());
129        assert!(excludes.contains(&"node_modules".to_string()));
130        assert!(excludes.contains(&"dist".to_string()));
131        assert!(excludes.contains(&"target".to_string()));
132        assert!(excludes.contains(&".git".to_string())); // always present
133                                                         // Wildcards and negations should be excluded
134        assert!(!excludes.iter().any(|e| e.contains('*')));
135        assert!(!excludes.iter().any(|e| e.starts_with('!')));
136    }
137
138    #[test]
139    fn test_parse_gitignore_missing_file() {
140        let dir = tempdir().unwrap();
141        let excludes = parse_gitignore(dir.path());
142        // Should return fallbacks
143        assert!(excludes.contains(&"node_modules".to_string()));
144        assert!(excludes.contains(&".git".to_string()));
145        assert!(excludes.contains(&"dist".to_string()));
146    }
147
148    #[test]
149    fn test_should_skip_path() {
150        let excludes = vec!["node_modules".to_string(), ".git".to_string()];
151
152        assert!(should_skip_path(
153            &PathBuf::from("src/node_modules/foo.js"),
154            &excludes
155        ));
156        assert!(should_skip_path(&PathBuf::from(".git/config"), &excludes));
157        assert!(!should_skip_path(&PathBuf::from("src/main.rs"), &excludes));
158    }
159
160    #[test]
161    fn test_deduplication() {
162        let dir = tempdir().unwrap();
163        fs::write(
164            dir.path().join(".gitignore"),
165            "node_modules\nnode_modules/\nnode_modules\n",
166        )
167        .unwrap();
168
169        let excludes = parse_gitignore(dir.path());
170        let count = excludes.iter().filter(|e| *e == "node_modules").count();
171        assert_eq!(count, 1, "node_modules should appear exactly once");
172    }
173}