winx_code_agent/utils/
repo.rs1use crate::errors::Result;
12use crate::utils::display_tree::DirectoryTree;
13use ignore::WalkBuilder;
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18const MAX_ENTRIES_CHECK: usize = 100_000;
20const MAX_WALK_DEPTH: usize = 11;
22const MAX_COMMITS_WALK: usize = 500;
24
25pub fn get_repo_context(path: &Path) -> Result<(String, Vec<String>)> {
30 let context_dir = context_dir(path);
31 let is_git_repo = find_git_root(&context_dir).is_some();
32
33 let mut all_files = get_all_files_max_depth(&context_dir, is_git_repo);
34 all_files.sort(); let dynamic_max_files =
37 if is_git_repo { calculate_dynamic_file_limit(all_files.len()) } else { 50 };
38
39 let existing: HashSet<&str> = all_files.iter().map(String::as_str).collect();
40
41 let recent_git_files = if is_git_repo {
42 let count = std::cmp::max(10, dynamic_max_files / 5);
43 get_recent_git_files(&context_dir, count, &existing)
44 } else {
45 Vec::new()
46 };
47
48 let ranked = rank_files(&all_files);
49 let active = crate::utils::workspace_stats::active_files_for_context(&context_dir);
50
51 let mut top_files: Vec<String> = Vec::new();
53 let mut seen: HashSet<String> = HashSet::new();
54 let push = |file: String, top: &mut Vec<String>, seen: &mut HashSet<String>| {
55 if existing.contains(file.as_str()) && seen.insert(file.clone()) {
56 top.push(file);
57 }
58 };
59
60 for file in active {
61 push(file, &mut top_files, &mut seen);
62 }
63 for file in recent_git_files {
64 push(file, &mut top_files, &mut seen);
65 }
66 if top_files.len() < dynamic_max_files {
67 for file in ranked {
68 if top_files.len() >= dynamic_max_files {
69 break;
70 }
71 if seen.insert(file.clone()) {
72 top_files.push(file);
73 }
74 }
75 }
76
77 let mut tree = DirectoryTree::new(&context_dir);
78 for file in top_files.iter().take(dynamic_max_files) {
79 tree.expand(file);
80 }
81
82 Ok((tree.display(), top_files))
83}
84
85fn context_dir(path: &Path) -> PathBuf {
88 if let Some(git_root) = find_git_root(path) {
89 return git_root;
90 }
91 if path.is_file() {
92 path.parent().unwrap_or(path).to_path_buf()
93 } else {
94 path.to_path_buf()
95 }
96}
97
98fn find_git_root(path: &Path) -> Option<PathBuf> {
100 let start = if path.is_file() { path.parent()? } else { path };
101 let mut current = Some(start);
102 while let Some(dir) = current {
103 if dir.join(".git").exists() {
104 return Some(dir.to_path_buf());
105 }
106 current = dir.parent();
107 }
108 None
109}
110
111fn get_all_files_max_depth(root: &Path, is_git_repo: bool) -> Vec<String> {
118 let walker = WalkBuilder::new(root)
119 .max_depth(Some(MAX_WALK_DEPTH))
120 .hidden(false)
121 .parents(true)
122 .ignore(false)
123 .git_ignore(is_git_repo)
124 .git_global(is_git_repo)
125 .git_exclude(is_git_repo)
126 .require_git(true)
127 .filter_entry(|entry| !matches!(entry.file_name().to_str(), Some(".git" | ".winx")))
128 .build();
129
130 let mut files = Vec::new();
131 for entry in walker.flatten() {
132 if files.len() >= MAX_ENTRIES_CHECK {
133 break;
134 }
135 if entry.file_type().is_some_and(|file_type| file_type.is_file()) {
136 if let Ok(relative) = entry.path().strip_prefix(root) {
137 files.push(crate::utils::path::display_relative(relative));
138 }
139 }
140 }
141 files
142}
143
144pub fn walk_workspace_files(root: &Path) -> Vec<PathBuf> {
150 let is_git_repo = find_git_root(root).is_some();
151 let walker = WalkBuilder::new(root)
152 .max_depth(Some(MAX_WALK_DEPTH))
153 .hidden(false)
154 .parents(true)
155 .ignore(false)
156 .git_ignore(is_git_repo)
157 .git_global(is_git_repo)
158 .git_exclude(is_git_repo)
159 .require_git(true)
160 .filter_entry(|entry| !matches!(entry.file_name().to_str(), Some(".git" | ".winx")))
161 .build();
162
163 let mut files = Vec::new();
164 for entry in walker.flatten() {
165 if files.len() >= MAX_ENTRIES_CHECK {
166 break;
167 }
168 if entry.file_type().is_some_and(|file_type| file_type.is_file()) {
169 files.push(entry.path().to_path_buf());
170 }
171 }
172 files
173}
174
175fn get_recent_git_files(root: &Path, count: usize, existing: &HashSet<&str>) -> Vec<String> {
178 let output = Command::new("git")
179 .arg("-C")
180 .arg(root)
181 .args([
182 "log",
183 "--name-only",
184 "--no-merges",
185 "--topo-order",
186 "--format=",
187 "-n",
188 &MAX_COMMITS_WALK.to_string(),
189 ])
190 .output();
191
192 let Ok(output) = output else {
193 return Vec::new();
194 };
195 if !output.status.success() {
196 return Vec::new();
197 }
198
199 let mut recent = Vec::new();
200 let mut seen = HashSet::new();
201 for line in String::from_utf8_lossy(&output.stdout).lines().map(str::trim) {
202 if line.is_empty() || !existing.contains(line) {
203 continue;
204 }
205 if seen.insert(line.to_string()) {
206 recent.push(line.to_string());
207 if recent.len() >= count {
208 break;
209 }
210 }
211 }
212 recent
213}
214
215fn calculate_dynamic_file_limit(total_files: usize) -> usize {
217 const MIN_FILES: usize = 50;
218 const MAX_FILES: usize = 400;
219 const FULL_SCALE_FILES: usize = 30_000;
220 if total_files <= MIN_FILES {
221 return MIN_FILES;
222 }
223 let numerator = (total_files - MIN_FILES).saturating_mul(MAX_FILES - MIN_FILES);
224 let denominator = FULL_SCALE_FILES - MIN_FILES;
225 MIN_FILES.saturating_add(numerator / denominator).min(MAX_FILES)
226}
227
228fn rank_files(all_files: &[String]) -> Vec<String> {
231 if let Some(scores) = crate::utils::path_prob::score_paths(all_files) {
232 let mut indexed: Vec<(usize, f64)> = scores.into_iter().enumerate().collect();
233 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
235 return indexed.into_iter().map(|(index, _)| all_files[index].clone()).collect();
236 }
237
238 let mut files = all_files.to_vec();
239 files.sort_by_key(|path| (heuristic_score(path), path.clone()));
240 files
241}
242
243const IMPORTANT_NAMES: &[&str] = &[
244 "Cargo.toml",
245 "README.md",
246 "AGENTS.md",
247 "package.json",
248 "pnpm-workspace.yaml",
249 "pyproject.toml",
250 "go.mod",
251 "Dockerfile",
252 "docker-compose.yml",
253];
254
255fn heuristic_score(path: &str) -> usize {
257 let not_important = usize::from(!IMPORTANT_NAMES.contains(&path));
258 let depth = path.matches('/').count();
259 let test_penalty = usize::from(path.contains("test") || path.contains("spec"));
260 not_important * 10 + depth + test_penalty
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::process::Command;
267 use tempfile::TempDir;
268
269 fn git(repo: &Path, args: &[&str]) -> Result<()> {
270 let output = Command::new("git").arg("-C").arg(repo).args(args).output()?;
271 if output.status.success() {
272 return Ok(());
273 }
274 Err(crate::errors::WinxError::CommandExecutionError(
275 String::from_utf8_lossy(&output.stderr).into_owned(),
276 ))
277 }
278
279 #[test]
280 fn builds_repo_context_from_files() -> Result<()> {
281 let temp_dir = TempDir::new()?;
282 std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\nname='x'\n")?;
283 std::fs::create_dir(temp_dir.path().join("src"))?;
284 std::fs::write(temp_dir.path().join("src/lib.rs"), "pub fn x() {}\n")?;
285
286 let (context, files) = get_repo_context(temp_dir.path())?;
287 assert!(context.contains("Cargo.toml"));
288 assert!(files.iter().any(|file| file == "src/lib.rs"));
289 Ok(())
290 }
291
292 #[test]
293 fn dynamic_limit_scales_between_bounds() {
294 assert_eq!(calculate_dynamic_file_limit(10), 50);
295 assert_eq!(calculate_dynamic_file_limit(50), 50);
296 assert!(calculate_dynamic_file_limit(1000) > 50);
297 assert_eq!(calculate_dynamic_file_limit(1_000_000), 400);
298 }
299
300 #[test]
301 fn respects_gitignore_in_git_repo() -> Result<()> {
302 let temp_dir = TempDir::new()?;
303 let root = temp_dir.path();
304 std::fs::create_dir(root.join(".git"))?; std::fs::write(root.join(".gitignore"), "ignored.txt\n")?;
306 std::fs::write(root.join("ignored.txt"), "secret\n")?;
307 std::fs::write(root.join("kept.rs"), "fn x() {}\n")?;
308
309 let files = get_all_files_max_depth(root, true);
310 assert!(files.iter().any(|file| file == "kept.rs"));
311 assert!(!files.iter().any(|file| file == "ignored.txt"), "gitignore must hide ignored.txt");
312 Ok(())
313 }
314
315 #[test]
316 fn reserved_winx_directory_never_enters_context_or_code_navigation() -> Result<()> {
317 let temp_dir = TempDir::new()?;
318 let root = temp_dir.path();
319 std::fs::create_dir_all(root.join(".winx/tmp"))?;
320 std::fs::write(root.join(".winx/tmp/carrier.rs"), "fn encoded_source() {}\n")?;
321 std::fs::write(root.join("kept.rs"), "fn real_source() {}\n")?;
322
323 let context_files = get_all_files_max_depth(root, false);
324 assert!(context_files.iter().any(|file| file == "kept.rs"));
325 assert!(!context_files.iter().any(|file| file.contains(".winx")));
326
327 let navigation_files = walk_workspace_files(root);
328 assert!(navigation_files.iter().any(|file| file.ends_with("kept.rs")));
329 assert!(!navigation_files
330 .iter()
331 .any(|file| file.components().any(|part| part.as_os_str() == ".winx")));
332 Ok(())
333 }
334
335 #[test]
336 fn linked_worktree_uses_its_own_root() -> Result<()> {
337 let main_repo = TempDir::new()?;
338 git(main_repo.path(), &["init", "-q"])?;
339 std::fs::write(main_repo.path().join("main-only.txt"), "main\n")?;
340 git(main_repo.path(), &["add", "main-only.txt"])?;
341 git(
342 main_repo.path(),
343 &[
344 "-c",
345 "user.name=Winx Test",
346 "-c",
347 "user.email=winx@example.invalid",
348 "commit",
349 "-qm",
350 "initial",
351 ],
352 )?;
353
354 let worktree_parent = TempDir::new()?;
355 let worktree = worktree_parent.path().join("linked");
356 let worktree_arg = worktree.to_string_lossy().into_owned();
357 git(main_repo.path(), &["worktree", "add", "-q", "-b", "linked-test", &worktree_arg])?;
358 std::fs::write(worktree.join("worktree-only.txt"), "linked\n")?;
359
360 let (context, files) = get_repo_context(&worktree)?;
361 assert!(context.starts_with(&format!("{}/", worktree.display())));
362 assert!(files.iter().any(|file| file == "worktree-only.txt"));
363 Ok(())
364 }
365}