Skip to main content

winx_code_agent/utils/
repo.rs

1//! Repository context, ported for parity with wcgw's `repo_context.py`.
2//!
3//! The flow mirrors wcgw exactly:
4//! 1. Walk the workspace (gitignore-aware) collecting candidate files.
5//! 2. For git repos, pull recently-changed files from history (topological).
6//! 3. Rank every file with the embedded path-probability model.
7//! 4. Build the shown set: active files first, then recent git files, then the
8//!    statistically-ranked remainder, up to a size that scales with the repo.
9//! 5. Render it as a partially-expanded directory tree.
10
11use 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
18/// Stop scanning once we've seen this many filesystem entries (wcgw parity).
19const MAX_ENTRIES_CHECK: usize = 100_000;
20/// Roughly "10 directory levels deep" — walk depth counts files too, so +1.
21const MAX_WALK_DEPTH: usize = 11;
22/// How far back through git history to look for recently-touched files.
23const MAX_COMMITS_WALK: usize = 500;
24
25/// Build the workspace context string and the list of shown files.
26///
27/// The returned string is a partially-expanded directory tree, byte-for-byte in
28/// the same spirit as wcgw's `DirectoryTree.display()`.
29pub 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(); // deterministic order so score ties resolve stably
35
36    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    // Compose the shown set: active → recent → ranked remainder (no dups).
52    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
85/// The directory wcgw would treat as the context root: the git toplevel if any,
86/// otherwise the path itself (or its parent when a file is passed).
87fn 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
98/// Walk up from `path` looking for a `.git` directory; returns the repo root.
99fn 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
111/// Collect candidate files relative to `root`.
112///
113/// gitignore filtering is only applied inside a git repo (`require_git`), matching
114/// wcgw which passes `repo=None` — and thus never ignores anything — for plain
115/// folders. Hidden files are kept (wcgw shows dotfiles unless gitignored), but
116/// `.git` and Winx's reserved `.winx` artifact directory are always pruned.
117fn 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
144/// Walk `root` for files, gitignore-aware (only inside a git repo), keeping
145/// dotfiles but always pruning `.git` and Winx's reserved `.winx` artifact
146/// directory, capped at
147/// `MAX_ENTRIES_CHECK` entries and `MAX_WALK_DEPTH` deep. Returns absolute paths.
148/// Shared by the read-only code-navigation paths (`CodeMap` outline/references).
149pub 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
175/// Recently-changed files from git history, newest first, topological order,
176/// merges skipped — the CLI mirror of wcgw's pygit2 revwalk.
177fn 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
215/// Scale the number of shown files with repo size (wcgw: 50..=400 linearly).
216fn 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
228/// Order files best-first. Uses the embedded path-probability model; if it
229/// can't be loaded, falls back to a simple importance/depth heuristic.
230fn 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        // Higher log-prob first; stable sort keeps the alphabetical order on ties.
234        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
255/// Fallback ranking when the ML model is unavailable. Lower is better.
256fn 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"))?; // mark as git repo
305        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}