Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/utils/binary_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@
".vbs", # VBScript
".reg", # Windows Registry
".desktop",
# Godot
".godot",
".gd",
".gd.uid",
".tscn",
}
)

Expand Down Expand Up @@ -183,6 +188,9 @@
".db",
".sqlite",
".sqlite3",
".pdb",
".pyd",
".o",
}
)

Expand Down
177 changes: 177 additions & 0 deletions src/workspace/exclusion_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""统一排除管理器 —— 合并 gitignore、用户 ignore、默认排除规则.

区分两类排除:
- 性能排除: 缓存/构建产物等不影响安全的目录
- 安全排除: 隐私/凭据文件等不应被 AI 访问的路径
"""

from __future__ import annotations

import os
import re
from pathlib import Path
from typing import ClassVar

from src.workspace.gitignore_loader import is_ignored_by_gitignore, load_gitignore


class ExclusionManager:
"""排除规则统一管理器

聚合三类排除源:
1. 默认排除(内置的缓存/构建/IDE 目录)
2. .gitignore 规则(如项目中有 .gitignore 文件)
3. 用户临时 ignore 参数

Args:
workspace_root: 工作区根目录
"""

# 性能排除 —— 缓存、构建产物、IDE 配置等
PERFORMANCE_EXCLUSIONS: frozenset[str] = frozenset(
{
".git",
"__pycache__",
"node_modules",
".venv",
"venv",
"dist",
"build",
".idea",
".vscode",
".ruff_cache",
".pytest_cache",
".mypy_cache",
".hypothesis",
"htmlcov",
".coverage",
"*.pyc",
"*.pyo",
".eggs",
"*.egg-info",
".tox",
".nox",
".svn",
".hg",
".bzr",
"target", # Rust build
".next", # Next.js build
".nuxt", # Nuxt build
".output", # Nuxt output
}
)

# 安全排除 —— 需要精确匹配的敏感文件正则(与 SECURITY_EXCLUSIONS 合并后的唯一来源)
# 注意: (^|/) 前缀表示匹配路径开始或目录分隔符后; .* 前缀表示匹配任意位置的文件扩展名
SENSITIVE_FILE_PATTERNS: ClassVar[list[str]] = [
r"(^|/)\.env$",
r"(^|/)\.env\..+$",
r".*\.pem$",
r"(^|/)credentials\..*$",
r".*\.key$",
r".*\.cert$",
r"(^|/)id_rsa$",
r"(^|/)id_ed25519$",
r".*\.cred$",
r".*\.secret$",
r"(^|/)\.ManualAid[/\\].*\.db$",
]

def __init__(self, workspace_root: str | Path):
self._workspace_root = Path(workspace_root).resolve()
# 从 .gitignore 加载
self._raw_gitignore_patterns: list[str] = []
self._gitignore_exclude_res: list[re.Pattern] = []
self._gitignore_negate_res: list[re.Pattern] = []

self._reload_gitignore()

def _reload_gitignore(self) -> None:
"""(重新)加载 .gitignore."""
raw, exclude_res, negate_res = load_gitignore(self._workspace_root)
self._raw_gitignore_patterns = raw
self._gitignore_exclude_res = exclude_res
self._gitignore_negate_res = negate_res

def _check_performance_exclusion(self, rel_path_str: str) -> bool:
"""检查路径是否匹配性能排除规则(基于目录名)"""
# 将路径拆分为各层, 检查每层是否在排除集合中
parts = rel_path_str.replace(os.sep, "/").split("/")
for part in parts:
# 检查部分匹配: "node_modules" 或通配匹配
if part in self.PERFORMANCE_EXCLUSIONS:
return True
# 检查 *.xxx 模式
for exclude in self.PERFORMANCE_EXCLUSIONS:
if exclude.startswith("*.") and part.endswith(exclude[1:]):
return True
return False

def should_exclude_path(self, path: Path) -> bool:
"""检查路径是否应被排除(全面检查)

依次检查: 默认排除目录名 → gitignore 规则 → 否定规则

Args:
path: 文件的绝对路径

Returns:
True 表示应排除
"""
try:
rel_path = path.relative_to(self._workspace_root)
except ValueError:
# 在工作区外, 不在这里处理(由 PathValidator 处理)
return False

rel_str = str(rel_path).replace(os.sep, "/")

# 1. 性能排除: 检查所有父目录
if self._check_performance_exclusion(rel_str):
return True

# 2. gitignore 排除
return is_ignored_by_gitignore(rel_str, self._gitignore_exclude_res, self._gitignore_negate_res)

def merge_ignore_regexes(self, user_ignore: list[str] | None = None) -> list[re.Pattern]:
"""合并默认排除 + gitignore + 用户 ignore 为正则列表

用于 search_content 等需要正则匹配排除的场景

敏感文件由 PathValidator 在写入/读取时拦截,搜索场景不额外过滤

Args:
user_ignore: 用户传入的忽略正则列表

Returns:
编译后的正则列表
"""
result: list[re.Pattern] = []

# 默认排除目录名 → 正则
for excl in self.PERFORMANCE_EXCLUSIONS:
# 处理 *.pyc 类模式
if excl.startswith("*."):
pat = excl[1:] # .pyc
result.append(re.compile(re.escape(pat) + "$"))
else:
# 匹配路径中的此目录名
result.append(re.compile(r"(^|/)" + re.escape(excl) + r"(/|$)"))

# gitignore 排除正则
result.extend(self._gitignore_exclude_res)

# 用户传入的 ignore 正则
if user_ignore:
for ign in user_ignore:
try:
result.append(re.compile(ign))
except re.error:
continue

return result

@property
def excluded_dir_names(self) -> set[str]:
"""获取所有排除目录名集合(用于快速 in 检查)"""
return {d for d in self.PERFORMANCE_EXCLUSIONS if not d.startswith("*")}
178 changes: 178 additions & 0 deletions src/workspace/gitignore_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Parse .gitignore files and convert patterns to regex for exclusion matching.

已知局限性:
- 不支持嵌套 .gitignore(仅读取根目录下的 .gitignore)
- 不支持行尾 \\ 续行
- 不支持 gitignore 扩展语法中的字符类(如 [abc] 会被错误转义)
- 否定模式的优先级处理与真实 Git 不一致: 当前实现将所有否定模式提升为最高优先级,
而真实 Git 按行号顺序逐条处理(后出现的规则覆盖先出现的).
当前行为对于 AI 工具场景偏安全(宁可少排除), 故保留此简化实现.
"""

from __future__ import annotations

import os
import re
from pathlib import Path


def _convert_gitignore_to_regex(pattern: str) -> str | None:
"""将 .gitignore 模式转换为正则表达式.

Args:
pattern: .gitignore 模式(如 *.log, build/, /foo)

Returns:
对应的正则表达式字符串, 如果模式无效则返回 None
"""
# 保留原始模式用于锚定判断
original = pattern
is_dir_only = pattern.endswith("/")
if is_dir_only:
pattern = pattern.rstrip("/")

# 处理否定模式(仅用于判断是否为目录模式, 不处理逻辑)
if pattern.startswith("!"):
pattern = pattern[1:]

# 转义正则特殊字符, 再处理 gitignore 通配符
# 先处理 ** (多级通配符)
parts = []
i = 0
while i < len(pattern):
if pattern[i : i + 2] == "**":
parts.append(".*")
i += 2
elif pattern[i] == "*":
# 单级通配符, 不匹配路径分隔符
parts.append(r"[^/]*")
i += 1
elif pattern[i] == "?":
parts.append(r"[^/]")
i += 1
elif pattern[i] in ".+^${}()|[]\\":
parts.append("\\" + pattern[i])
i += 1
else:
parts.append(pattern[i])
i += 1

regex_str = "".join(parts)

# 锚定: / 开头表示从根目录匹配, 否则匹配任意路径
if original.startswith("/"):
regex_str = "^" + regex_str[1:] # 去掉开头的 /
elif original.startswith("!"):
# 处理否定模式 - 保持锚定逻辑不变
regex_str = "^" + regex_str[1:] if original[1:].startswith("/") else "(^|/)" + regex_str
else:
regex_str = "(^|/)" + regex_str

if is_dir_only:
regex_str += "(/.*)?$"
else:
regex_str += "$"

return regex_str


def parse_gitignore(gitignore_path: str | Path) -> list[str]:
"""解析 .gitignore 文件, 返回非否定排除模式列表.

Args:
gitignore_path: .gitignore 文件路径

Returns:
排除模式列表(目录名/通配符等原始 gitignore 格式)
"""
patterns: list[str] = []
gitignore_path = Path(gitignore_path)

if not gitignore_path.exists():
return patterns

try:
text = gitignore_path.read_text(encoding="utf-8")
except Exception:
return patterns

for line in text.splitlines():
stripped = line.strip()

# 跳过空行和注释
if not stripped or stripped.startswith("#"):
continue

# 保留否定模式供外部处理, 返回原始行
patterns.append(stripped)

return patterns


def compile_gitignore_patterns(patterns: list[str]) -> tuple[list[re.Pattern], list[re.Pattern]]:
"""将 gitignore 模式编译为正则表达式.

Args:
patterns: 原始 gitignore 模式列表

Returns:
(排除正则列表, 否定排除正则列表) 的元组
"""
exclude_res: list[re.Pattern] = []
negate_res: list[re.Pattern] = []

for pattern in patterns:
if pattern.startswith("!"):
# 否定模式: 取消排除
negate_regex = _convert_gitignore_to_regex(pattern)
if negate_regex:
try:
negate_res.append(re.compile(negate_regex))
except re.error:
continue
else:
regex = _convert_gitignore_to_regex(pattern)
if regex:
try:
exclude_res.append(re.compile(regex))
except re.error:
continue

return exclude_res, negate_res


def is_ignored_by_gitignore(path: str | Path, exclude_res: list[re.Pattern], negate_res: list[re.Pattern]) -> bool:
"""检查路径是否被 .gitignore 规则忽略.

Args:
path: 要检查的相对路径(字符串形式)
exclude_res: 排除正则列表
negate_res: 否定排除正则列表

Returns:
是否应该被忽略
"""
path_str = str(path).replace(os.sep, "/")

# 先检查否定模式(优先级更高)
for negate_re in negate_res:
if negate_re.search(path_str):
return False

# 再检查排除模式
return any(exclude_re.search(path_str) for exclude_re in exclude_res)


def load_gitignore(workspace_root: str | Path) -> tuple[list[str], list[re.Pattern], list[re.Pattern]]:
"""从工作区根目录加载 .gitignore.

Args:
workspace_root: 工作区根目录

Returns:
(原始模式列表, 排除正则列表, 否定排除正则列表)
"""
gitignore_path = Path(workspace_root) / ".gitignore"
raw_patterns = parse_gitignore(gitignore_path)
exclude_res, negate_res = compile_gitignore_patterns(raw_patterns)
return raw_patterns, exclude_res, negate_res
Loading