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); only
116/// the `.git` directory itself is 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| entry.file_name() != ".git")
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(relative.to_string_lossy().to_string());
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 the `.git` directory, capped at
146/// `MAX_ENTRIES_CHECK` entries and `MAX_WALK_DEPTH` deep. Returns absolute paths.
147/// Shared by the read-only code-navigation paths (`CodeMap` outline/references).
148pub fn walk_workspace_files(root: &Path) -> Vec<PathBuf> {
149    let is_git_repo = find_git_root(root).is_some();
150    let walker = WalkBuilder::new(root)
151        .max_depth(Some(MAX_WALK_DEPTH))
152        .hidden(false)
153        .parents(true)
154        .ignore(false)
155        .git_ignore(is_git_repo)
156        .git_global(is_git_repo)
157        .git_exclude(is_git_repo)
158        .require_git(true)
159        .filter_entry(|entry| entry.file_name() != ".git")
160        .build();
161
162    let mut files = Vec::new();
163    for entry in walker.flatten() {
164        if files.len() >= MAX_ENTRIES_CHECK {
165            break;
166        }
167        if entry.file_type().is_some_and(|file_type| file_type.is_file()) {
168            files.push(entry.path().to_path_buf());
169        }
170    }
171    files
172}
173
174/// Recently-changed files from git history, newest first, topological order,
175/// merges skipped — the CLI mirror of wcgw's pygit2 revwalk.
176fn get_recent_git_files(root: &Path, count: usize, existing: &HashSet<&str>) -> Vec<String> {
177    let output = Command::new("git")
178        .arg("-C")
179        .arg(root)
180        .args([
181            "log",
182            "--name-only",
183            "--no-merges",
184            "--topo-order",
185            "--format=",
186            "-n",
187            &MAX_COMMITS_WALK.to_string(),
188        ])
189        .output();
190
191    let Ok(output) = output else {
192        return Vec::new();
193    };
194    if !output.status.success() {
195        return Vec::new();
196    }
197
198    let mut recent = Vec::new();
199    let mut seen = HashSet::new();
200    for line in String::from_utf8_lossy(&output.stdout).lines().map(str::trim) {
201        if line.is_empty() || !existing.contains(line) {
202            continue;
203        }
204        if seen.insert(line.to_string()) {
205            recent.push(line.to_string());
206            if recent.len() >= count {
207                break;
208            }
209        }
210    }
211    recent
212}
213
214/// Scale the number of shown files with repo size (wcgw: 50..=400 linearly).
215fn calculate_dynamic_file_limit(total_files: usize) -> usize {
216    const MIN_FILES: usize = 50;
217    const MAX_FILES: usize = 400;
218    const FULL_SCALE_FILES: usize = 30_000;
219    if total_files <= MIN_FILES {
220        return MIN_FILES;
221    }
222    let numerator = (total_files - MIN_FILES).saturating_mul(MAX_FILES - MIN_FILES);
223    let denominator = FULL_SCALE_FILES - MIN_FILES;
224    MIN_FILES.saturating_add(numerator / denominator).min(MAX_FILES)
225}
226
227/// Order files best-first. Uses the embedded path-probability model; if it
228/// can't be loaded, falls back to a simple importance/depth heuristic.
229fn rank_files(all_files: &[String]) -> Vec<String> {
230    if let Some(scores) = crate::utils::path_prob::score_paths(all_files) {
231        let mut indexed: Vec<(usize, f64)> = scores.into_iter().enumerate().collect();
232        // Higher log-prob first; stable sort keeps the alphabetical order on ties.
233        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
234        return indexed.into_iter().map(|(index, _)| all_files[index].clone()).collect();
235    }
236
237    let mut files = all_files.to_vec();
238    files.sort_by_key(|path| (heuristic_score(path), path.clone()));
239    files
240}
241
242const IMPORTANT_NAMES: &[&str] = &[
243    "Cargo.toml",
244    "README.md",
245    "AGENTS.md",
246    "package.json",
247    "pnpm-workspace.yaml",
248    "pyproject.toml",
249    "go.mod",
250    "Dockerfile",
251    "docker-compose.yml",
252];
253
254/// Fallback ranking when the ML model is unavailable. Lower is better.
255fn heuristic_score(path: &str) -> usize {
256    let not_important = usize::from(!IMPORTANT_NAMES.contains(&path));
257    let depth = path.matches('/').count();
258    let test_penalty = usize::from(path.contains("test") || path.contains("spec"));
259    not_important * 10 + depth + test_penalty
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::process::Command;
266    use tempfile::TempDir;
267
268    fn git(repo: &Path, args: &[&str]) -> Result<()> {
269        let output = Command::new("git").arg("-C").arg(repo).args(args).output()?;
270        if output.status.success() {
271            return Ok(());
272        }
273        Err(crate::errors::WinxError::CommandExecutionError(
274            String::from_utf8_lossy(&output.stderr).into_owned(),
275        ))
276    }
277
278    #[test]
279    fn builds_repo_context_from_files() -> Result<()> {
280        let temp_dir = TempDir::new()?;
281        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\nname='x'\n")?;
282        std::fs::create_dir(temp_dir.path().join("src"))?;
283        std::fs::write(temp_dir.path().join("src/lib.rs"), "pub fn x() {}\n")?;
284
285        let (context, files) = get_repo_context(temp_dir.path())?;
286        assert!(context.contains("Cargo.toml"));
287        assert!(files.iter().any(|file| file == "src/lib.rs"));
288        Ok(())
289    }
290
291    #[test]
292    fn dynamic_limit_scales_between_bounds() {
293        assert_eq!(calculate_dynamic_file_limit(10), 50);
294        assert_eq!(calculate_dynamic_file_limit(50), 50);
295        assert!(calculate_dynamic_file_limit(1000) > 50);
296        assert_eq!(calculate_dynamic_file_limit(1_000_000), 400);
297    }
298
299    #[test]
300    fn respects_gitignore_in_git_repo() -> Result<()> {
301        let temp_dir = TempDir::new()?;
302        let root = temp_dir.path();
303        std::fs::create_dir(root.join(".git"))?; // mark as git repo
304        std::fs::write(root.join(".gitignore"), "ignored.txt\n")?;
305        std::fs::write(root.join("ignored.txt"), "secret\n")?;
306        std::fs::write(root.join("kept.rs"), "fn x() {}\n")?;
307
308        let files = get_all_files_max_depth(root, true);
309        assert!(files.iter().any(|file| file == "kept.rs"));
310        assert!(!files.iter().any(|file| file == "ignored.txt"), "gitignore must hide ignored.txt");
311        Ok(())
312    }
313
314    #[test]
315    fn linked_worktree_uses_its_own_root() -> Result<()> {
316        let main_repo = TempDir::new()?;
317        git(main_repo.path(), &["init", "-q"])?;
318        std::fs::write(main_repo.path().join("main-only.txt"), "main\n")?;
319        git(main_repo.path(), &["add", "main-only.txt"])?;
320        git(
321            main_repo.path(),
322            &[
323                "-c",
324                "user.name=Winx Test",
325                "-c",
326                "user.email=winx@example.invalid",
327                "commit",
328                "-qm",
329                "initial",
330            ],
331        )?;
332
333        let worktree_parent = TempDir::new()?;
334        let worktree = worktree_parent.path().join("linked");
335        let worktree_arg = worktree.to_string_lossy().into_owned();
336        git(main_repo.path(), &["worktree", "add", "-q", "-b", "linked-test", &worktree_arg])?;
337        std::fs::write(worktree.join("worktree-only.txt"), "linked\n")?;
338
339        let (context, files) = get_repo_context(&worktree)?;
340        assert!(context.starts_with(&format!("{}/", worktree.display())));
341        assert!(files.iter().any(|file| file == "worktree-only.txt"));
342        Ok(())
343    }
344}