Skip to content

Commit 0c9cd43

Browse files
Merge pull request #5 from AsyncFuncAI/add-local-folder-support
2 parents 616d9c9 + 3579712 commit 0c9cd43

File tree

8 files changed

+847
-263
lines changed

8 files changed

+847
-263
lines changed

npx/python/cli/local_fetcher.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""Local directory context building for code review."""
2+
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
9+
def validate_local_path(path: str) -> str:
10+
"""Resolve and validate a local directory path.
11+
12+
Args:
13+
path: Directory path (relative or absolute)
14+
15+
Returns:
16+
Absolute path as string
17+
18+
Raises:
19+
ValueError: If path doesn't exist or is not a directory
20+
"""
21+
abs_path = os.path.abspath(os.path.expanduser(path))
22+
if not os.path.isdir(abs_path):
23+
raise ValueError(f"Path is not a valid directory: {path}")
24+
return abs_path
25+
26+
27+
def is_git_repo(path: str) -> bool:
28+
"""Check if a directory is a git repository.
29+
30+
Args:
31+
path: Directory path
32+
33+
Returns:
34+
True if .git exists or git rev-parse succeeds
35+
"""
36+
git_dir = os.path.join(path, ".git")
37+
if os.path.exists(git_dir):
38+
return True
39+
40+
try:
41+
result = subprocess.run(
42+
["git", "rev-parse", "--git-dir"],
43+
cwd=path,
44+
capture_output=True,
45+
text=True,
46+
timeout=10,
47+
)
48+
return result.returncode == 0
49+
except (subprocess.TimeoutExpired, FileNotFoundError):
50+
return False
51+
52+
53+
def get_git_diff(path: str) -> Optional[str]:
54+
"""Get git diff (staged + unstaged changes).
55+
56+
Args:
57+
path: Directory path
58+
59+
Returns:
60+
Combined diff string or None if not a git repo or no changes
61+
"""
62+
if not is_git_repo(path):
63+
return None
64+
65+
try:
66+
# Get unstaged changes
67+
unstaged = subprocess.run(
68+
["git", "diff"],
69+
cwd=path,
70+
capture_output=True,
71+
text=True,
72+
timeout=10,
73+
)
74+
75+
# Get staged changes
76+
staged = subprocess.run(
77+
["git", "diff", "--staged"],
78+
cwd=path,
79+
capture_output=True,
80+
text=True,
81+
timeout=10,
82+
)
83+
84+
diff_output = (staged.stdout + unstaged.stdout).strip()
85+
86+
if not diff_output:
87+
return None
88+
89+
# Truncate if exceeds 100KB
90+
if len(diff_output) > 100 * 1024:
91+
diff_output = diff_output[:100 * 1024] + "\n... (truncated)"
92+
93+
return diff_output
94+
except (subprocess.TimeoutExpired, FileNotFoundError):
95+
return None
96+
97+
98+
def get_git_info(path: str) -> Optional[dict]:
99+
"""Get git repository information.
100+
101+
Args:
102+
path: Directory path
103+
104+
Returns:
105+
Dict with branch, commits, repo_name or None if not a git repo
106+
"""
107+
if not is_git_repo(path):
108+
return None
109+
110+
try:
111+
# Get branch name
112+
branch_result = subprocess.run(
113+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
114+
cwd=path,
115+
capture_output=True,
116+
text=True,
117+
timeout=10,
118+
)
119+
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
120+
121+
# Get recent commits
122+
commits_result = subprocess.run(
123+
["git", "log", "--oneline", "-10"],
124+
cwd=path,
125+
capture_output=True,
126+
text=True,
127+
timeout=10,
128+
)
129+
commits = commits_result.stdout.strip().split("\n") if commits_result.returncode == 0 else []
130+
131+
# Get repo name from directory or git remote
132+
repo_name = os.path.basename(path)
133+
try:
134+
remote_result = subprocess.run(
135+
["git", "config", "--get", "remote.origin.url"],
136+
cwd=path,
137+
capture_output=True,
138+
text=True,
139+
timeout=10,
140+
)
141+
if remote_result.returncode == 0:
142+
remote_url = remote_result.stdout.strip()
143+
# Extract repo name from URL
144+
repo_name = remote_url.split("/")[-1].replace(".git", "")
145+
except (subprocess.TimeoutExpired, FileNotFoundError):
146+
pass
147+
148+
return {
149+
"branch": branch,
150+
"commits": [c for c in commits if c],
151+
"repo_name": repo_name,
152+
}
153+
except (subprocess.TimeoutExpired, FileNotFoundError):
154+
return None
155+
156+
157+
def get_project_structure(path: str, max_depth: int = 3, max_files: int = 200) -> str:
158+
"""Generate a tree-like representation of the project structure.
159+
160+
Args:
161+
path: Directory path
162+
max_depth: Maximum directory depth to traverse
163+
max_files: Maximum number of files to include
164+
165+
Returns:
166+
Tree-like string representation
167+
"""
168+
ignore_patterns = {
169+
".git", "node_modules", "__pycache__", ".venv", "venv",
170+
".env", "dist", "build", ".next", ".tox", ".pytest_cache",
171+
".mypy_cache", ".coverage", "*.pyc", ".DS_Store", ".idea",
172+
".vscode", "*.egg-info", ".gradle", "target", "out",
173+
}
174+
175+
def should_ignore(name: str) -> bool:
176+
"""Check if a file/dir should be ignored."""
177+
if name.startswith("."):
178+
return True
179+
for pattern in ignore_patterns:
180+
if pattern.startswith("*"):
181+
if name.endswith(pattern[1:]):
182+
return True
183+
elif name == pattern:
184+
return True
185+
return False
186+
187+
lines = []
188+
file_count = [0] # Use list to allow modification in nested function
189+
190+
def walk_tree(dir_path: str, prefix: str = "", depth: int = 0) -> None:
191+
"""Recursively walk directory tree."""
192+
if depth > max_depth or file_count[0] >= max_files:
193+
return
194+
195+
try:
196+
entries = sorted(os.listdir(dir_path))
197+
except (PermissionError, OSError):
198+
return
199+
200+
dirs = []
201+
files = []
202+
203+
for entry in entries:
204+
if should_ignore(entry):
205+
continue
206+
full_path = os.path.join(dir_path, entry)
207+
if os.path.isdir(full_path):
208+
dirs.append(entry)
209+
else:
210+
files.append(entry)
211+
212+
# Process directories first
213+
for i, dir_name in enumerate(dirs):
214+
is_last_dir = (i == len(dirs) - 1) and len(files) == 0
215+
connector = "└── " if is_last_dir else "├── "
216+
lines.append(f"{prefix}{connector}{dir_name}/")
217+
218+
if file_count[0] < max_files:
219+
next_prefix = prefix + (" " if is_last_dir else "│ ")
220+
walk_tree(os.path.join(dir_path, dir_name), next_prefix, depth + 1)
221+
222+
# Process files
223+
for i, file_name in enumerate(files):
224+
if file_count[0] >= max_files:
225+
break
226+
is_last = i == len(files) - 1
227+
connector = "└── " if is_last else "├── "
228+
lines.append(f"{prefix}{connector}{file_name}")
229+
file_count[0] += 1
230+
231+
walk_tree(path)
232+
return "\n".join(lines) if lines else "(empty directory)"
233+
234+
235+
def build_local_context(path: str) -> str:
236+
"""Build a structured text context for local directory review.
237+
238+
Args:
239+
path: Directory path
240+
241+
Returns:
242+
Structured text context for RLM input
243+
"""
244+
abs_path = validate_local_path(path)
245+
dir_name = os.path.basename(abs_path)
246+
247+
lines = [
248+
f"# Local Repository Review: {dir_name}",
249+
"",
250+
f"**Path:** {abs_path}",
251+
]
252+
253+
# Get git info if available
254+
git_info = get_git_info(abs_path)
255+
if git_info:
256+
lines.extend([
257+
f"**Branch:** {git_info['branch']}",
258+
f"**Repository:** {git_info['repo_name']}",
259+
"",
260+
])
261+
262+
# Recent commits
263+
if git_info["commits"]:
264+
lines.extend([
265+
"## Recent Commits",
266+
"",
267+
])
268+
for commit in git_info["commits"]:
269+
lines.append(f"- {commit}")
270+
lines.append("")
271+
272+
# Git diff
273+
diff = get_git_diff(abs_path)
274+
if diff:
275+
lines.extend([
276+
"## Git Diff",
277+
"",
278+
"```diff",
279+
diff,
280+
"```",
281+
"",
282+
])
283+
else:
284+
lines.append("**Status:** Not a git repository")
285+
lines.append("")
286+
287+
# Project structure
288+
lines.extend([
289+
"## Project Structure",
290+
"",
291+
get_project_structure(abs_path),
292+
"",
293+
])
294+
295+
return "\n".join(lines)
296+

0 commit comments

Comments
 (0)