From 508a603e7def2a0e36ee5e056aa327665474c430 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:00:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E6=98=BE=E5=BC=8F=20G?= =?UTF-8?q?it=20=E7=9B=AE=E6=A0=87=E5=88=A4=E5=AE=9A=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20v0.14.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 8 +- .codex-plugin/plugin.json | 2 +- .zcode-plugin/plugin.json | 2 +- docs/current-architecture.md | 3 +- kimi.plugin.json | 2 +- .../refactor-codeguard-architecture/design.md | 6 +- .../specs/execution-kernel/spec.md | 13 +++ .../refactor-codeguard-architecture/tasks.md | 2 +- .../verification.md | 8 ++ scripts/codeguard/git_context.py | 16 ++-- scripts/codeguard/git_guard_application.py | 15 ++-- scripts/codeguard/git_staging.py | 9 +- tests/run_all.py | 10 ++- tests/test_git_guard_application.py | 84 +++++++++++++++++++ 14 files changed, 154 insertions(+), 26 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index bc5ccf0..abd7b7c 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.14.2" + "ref": "v0.14.3" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.2", + "version": "0.14.3", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.2/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.3/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Trustworthy code checks and Java impact analysis", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.2/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.3/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0eefcf4..3eca08e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.2+codex.20260923", + "version": "0.14.3+codex.20260923", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 4aec264..7969d05 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.2", + "version": "0.14.3", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "description_i18n": { "en": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 932b669..6c28c6d 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,6 +27,7 @@ flowchart TD ``` `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 +Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 ## 模块所有权 @@ -66,4 +67,4 @@ python3 scripts/vendor/skill_vendor.py check openspec validate refactor-codeguard-architecture --strict ``` -真实 Maven/Gradle 大型项目、联网漏洞数据库、57 种工具链、Windows 与三宿主已安装运行均需要独立证据。临时 Git 快照不是执行沙箱;构建和扫描命令以当前用户权限运行。当前重构的任务进度和本地证据见 `openspec/changes/refactor-codeguard-architecture/verification.md`。v0.14.1 的源码、CI、tag、Release 和市场已核对;其后工作树变更仍需独立发布,宿主安装运行尚未验收。 +真实 Maven/Gradle 大型项目、联网漏洞数据库、57 种工具链、Windows 与三宿主已安装运行均需要独立证据。临时 Git 快照不是执行沙箱;构建和扫描命令以当前用户权限运行。当前重构的任务进度和各阶段发布证据见 `openspec/changes/refactor-codeguard-architecture/verification.md`;不能以某一阶段的源码、CI、tag、Release 或市场记录替代后续改动的发布与宿主验收。 diff --git a/kimi.plugin.json b/kimi.plugin.json index c5dcc6c..c420316 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.2", + "version": "0.14.3", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI" diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index b15ed10..64f6dfc 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -72,7 +72,11 @@ flowchart TD `path_policy` 是扫描排除表、产物识别与拟入库路径规则的单一来源;`scope` 和 `gate_lib` 只兼容导出/调用。策略接收已经确定的路径,不读取 Git,更不会执行建议中的移除操作。Git 快照采集仍在 git_snapshot,反馈格式仍在 reporting。 -这不是完整 Shell 解释器:分隔符/引用/动态拼接和复杂相对 cd 仍有既有边界。本批保留非 Git cd 后回退调用方 cwd 的历史策略,但删去“等价于 Shell 语义”的错误说明;不凭模块拆分宣称修好了这些定位边界。后续门禁应用拆分与命令上下文完善须分别用真实 Git 场景验证。 +这不是完整 Shell 解释器:分隔符/引用/动态拼接和复杂相对 cd 仍有既有边界。显式 `cd` 或 +`git -C` 指向非 Git 目标时,解析器现在返回独立的目标故障,硬门禁阻断而不回退调用者仓; +无显式目标且 cwd 非仓时继续保留 workspace 一层子仓兜底。仓库定位和拟暂存解析共用入口 +传入的 cwd,不再分别读取进程全局工作目录。Git 根查询故障也保留阻断结论;这些只覆盖 +支持的静态命令形态,不能宣称模拟完整 Shell 控制流。 参考:pre-commit 的 [Language Protocol 与通用执行](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/lang_base.py)、[Python 适配器](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/languages/python.py)。只借鉴边界设计,不复制安装环境逻辑或新增插件生态协议。 diff --git a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md index cfef1fd..3068c99 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -194,11 +194,24 @@ CLI 呈现 MUST 各有单一所有者。只有有效报告且执行状态可解 路径并集。显式 pathspec MUST 交由只读 Git 查询成组解析,保留目录、glob、literal、exclude、 引号路径及 -u/-f 的选择差异;展开后的文件名不得再作为 glob 解释。观察 MUST 不改写实际 index 或工作树;不支持的动态/交互式形态必须明确未验证,不得冒充准确暂存面。 +仓库定位与拟暂存解析 MUST 使用同一个显式调用目录,不能一处用传入的 cwd、另一处读取 +门禁进程的全局工作目录。 +显式 `cd` 或 `git -C` 已给出目标时,若该目标不是可解析的 Git 工作树,MUST 阻断 +该 Git 副作用命令并报告目标不可确定;不得回退调用者仓或扫调用者一层子仓。 +只有命令未给出显式目标且调用目录不是 Git 仓时,才保留既有 workspace 子仓兜底策略。 #### Scenario: Two repositories have different staging commands - **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 - **THEN** B 的未暂存及未跟踪文件不进入拟提交检查面 +#### Scenario: An explicit Git target is not a repository +- **WHEN** 命令从 Git 仓调用,但在 `cd` 到非 Git 目录后运行 `git push`,或使用无效的 `git -C` 目标 +- **THEN** 门禁以明确诊断阻断,而不是检查调用者仓或用 workspace 兜底;后续显式 `git -C` 指向有效仓时仍能正确绑定该仓 + +#### Scenario: Explicit invocation directory binds staging intent +- **WHEN** 应用服务收到的调用目录与门禁进程当前目录不同,命令在调用目录中 `git add` 并提交 +- **THEN** 拟暂存路径按传入调用目录的仓与文件解析,不遗漏额外路径,也不纳入进程目录的仓 + #### Scenario: Exclusion and literal pathspecs remain exact - **WHEN** git add 指定包含与排除 pathspec,或字面文件名包含星号 - **THEN** 快照只覆盖 Git 实际匹配的文件,排除项及同名 glob 邻居不会被额外暂存 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 33bb545..5e21fad 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -34,5 +34,5 @@ - [x] 5.2 四个历史 skipped 已全部恢复;Dockerfile CLI 接入统一报告解析与扫描应用,14 项真实进程边界测试通过;基线比较按诊断内容和次数核验并要求基线实际 FAIL,三项误豁免用例先红后绿。完整 433 项单测零跳过、142 项回归零失败。 - [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 - [ ] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.1 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但本 change 后续改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.2 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但本 change 后续改动仍需独立发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index bbb2ac0..c3b92b0 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -178,3 +178,11 @@ - 新增 RED:UserPromptSubmit 与 PostToolUse 在 stdout 刷新失败时,旧入口仍登记完成;Git 已知拦截的输出写入失败向外抛异常,若由入口顶层 fail-open 捕获则可能放行;刷新失败还会缓存未交付拦截。现在所有确认都在刷新后;已知拦截输出故障保留 exit 2、不缓存,含无 tool_use_id 路径和缓存重放。对普通软反馈仍保持原 fail-open 协议。 - 定向测试 **26/26**,完整单测 **465/465、0 skipped**,真实 Hook 回归 **142/0/0**;ruff、架构依赖/循环、本 change strict、语言 schema **57 项/11 规则**、vendor 离线和在线以及 diff whitespace 通过。vendor lock 仍为 v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`,受管技能与 lock 未修改。 - 本批只证明本地输出时序与故障注入;stdout 刷新不等于宿主最终消费,可能在重试时重复反馈。三宿主已安装运行、Windows、联网漏洞库和全部语言工具链仍需独立验收;本批在此记录时尚未 bump/提交/发布。 + +## 第十七批实际验证:显式 Git 目标与调用目录 + +- v0.14.2 已经插件 PR #48、市场 PR #5 合并到双仓 `main`;插件 PR/main 的 `skills-check` 通过,不可变 tag/Release 指向合并提交 `2cdf816d3a5924155480dd24a535d61ebab47e01`,市场 Codex/ZCode/Kimi ref 与双语导航为 0.14.2。该远端证据只覆盖第十六批及此前源码,本批仍需独立发布。 +- 临时 CodeGraph 快照同步后追踪 `evaluate_git_command → resolve_project_roots → repository_root` 与 `staging_intent`。发现显式 `cd` 到非 Git 目录后执行 commit/push 会回退调用者仓;显式 `git -C` 无效目标会落到 workspace 兜底;应用传入 cwd 仅用于仓库定位,拟暂存解析却读取进程全局 cwd。分别用真实临时 Git 仓和独立调用目录复现:前两类错误放行/泛化诊断,第三类遗漏新增 Python 文件。 +- 当前 `GitTargetError` 将显式目标不可绑定与无显式目标的 workspace 兜底区分;前者在应用边界返回 exit 2 和具体路径,不再检查错误仓。Git 根观察的 `SnapshotError` 同样不交给顶层 fail-open。拟暂存解析接受可选显式 cwd,并由应用传入同一调用目录;旧无参导入/调用仍可使用进程 cwd。真实 PreToolUse 子进程证明无效目标阻断,`git -C` 有效目标仍覆盖非 Git cd。 +- TDD RED 包含错误放行、错误兜底、遗漏 pathspec 与根观察异常;GREEN 后完整单测 **470/470、0 skipped**,真实 Hook 回归 **143/0/0**。ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 通过;受管技能与 lock 未改。 +- 这不是 Shell 求值器:条件链、动态拼接、命令替换、复杂引用和链内环境变化仍是明确边界。三宿主现场、Windows、联网漏洞库与全部语言工具链未因此获得验收。本批在此记录时尚未 bump/提交/发布。 diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py index 0093489..105f4b9 100644 --- a/scripts/codeguard/git_context.py +++ b/scripts/codeguard/git_context.py @@ -23,6 +23,9 @@ _INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") +class GitTargetError(ValueError): + """显式 Git 目标无法绑定时拒绝猜测其它仓库。""" + def _is_git_repo(p: Path) -> bool: """只读查询 Git 仓边界;工具不可用时交由调用者降级。""" @@ -34,7 +37,7 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: 链式发布(cd plugins && git commit && cd minimax && git push)操作 多个仓库——每个 git 段的边界 = `git -C ` 显式指定的仓,否则取它 - 之前最近的 cd(且必须是 git 仓);无 cd 则用 cwd(需是 git 仓)。 + 之前最近的 cd(且必须是 git 仓);无 cd 才用 cwd(需是 git 仓)。 去重保序,找不到任何边界返回 []。 **必须与 is_guarded 同源判定**(`_git_side_effect_sub` 归一化后判子命令): @@ -46,6 +49,7 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: command = _flatten_substitutions(command) roots: list[Path] = [] last_cd: Path | None = None + explicit_cd = False caller_dir = (cwd or Path.cwd()).resolve() current_dir = caller_dir for seg in re.split(r"&&|\|\||;|\n", command): @@ -56,7 +60,7 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: if m: target = (current_dir / m.group(1).strip("\"'")).resolve() current_dir = target - # 兼容旧降级策略:非 Git 目录后回退调用方 cwd;不是完整 shell 求值。 + explicit_cd = True last_cd = target if target.is_dir() and _is_git_repo(target) else None continue if _git_side_effect_sub(seg) is None: @@ -66,14 +70,16 @@ def resolve_project_roots(command: str, cwd: Path | None = None) -> list[Path]: c_path = (current_dir / c_path).resolve() if c_path is not None: root = c_path if c_path.is_dir() and _is_git_repo(c_path) else None + if root is None: + raise GitTargetError(f"git -C 显式目标不是可解析的 Git 工作树:{c_path}") else: - root = last_cd or (caller_dir if _is_git_repo(caller_dir) else None) + if explicit_cd and last_cd is None: + raise GitTargetError(f"cd 显式目标不是可解析的 Git 工作树:{current_dir}") + root = last_cd if explicit_cd else (caller_dir if _is_git_repo(caller_dir) else None) if root: root = repository_root(root) if root not in roots: roots.append(root) - # 有意行为(勿"修掉"):cd 到非 git 目录后 last_cd=None,git 段回退用 - # 调用方 cwd。这是历史降级行为,不应声称等价于 shell 的 cd 语义。 return roots diff --git a/scripts/codeguard/git_guard_application.py b/scripts/codeguard/git_guard_application.py index fdcc4ad..cd41cbf 100644 --- a/scripts/codeguard/git_guard_application.py +++ b/scripts/codeguard/git_guard_application.py @@ -8,6 +8,7 @@ from git_snapshot import SnapshotError from .gate import run_gate +from .git_context import GitTargetError, resolve_project_roots, staging_intent from .git_context import ( _fallback_roots as fallback_roots, ) @@ -17,10 +18,6 @@ from .git_context import ( _guarded_mode as guarded_mode, ) -from .git_context import ( - resolve_project_roots, - staging_intent, -) from .git_syntax import _collect_subs as collect_subs from .git_syntax import chain_skip_gate, inline_skip_gate from .hook_state import record_skip_event @@ -48,7 +45,13 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d return GitGuardResult() cfg = load_config() - roots = resolve_project_roots(command, cwd=cwd) + try: + roots = resolve_project_roots(command, cwd=cwd) + except (GitTargetError, SnapshotError) as exc: + return GitGuardResult( + 2, + stderr=f"[codeguard] Git 目标 UNVERIFIED:{exc}。请确认命令实际工作目录后重新提交。", + ) fallback_note: str | None = None if not roots: fallback = fallback_roots(cwd) @@ -93,7 +96,7 @@ def evaluate_git_command(command: str, *, cwd: Path, load_config: Callable[[], d reports: list[str] = [] for project_root in roots: try: - lanes, extra = staging_intent(command, project_root=project_root) + lanes, extra = staging_intent(command, project_root=project_root, cwd=cwd) except SnapshotError as exc: contexts.append( f"codeguard: git UNVERIFIED:{project_root} 无法准确预测暂存面:{exc};" diff --git a/scripts/codeguard/git_staging.py b/scripts/codeguard/git_staging.py index c45d989..5577f4b 100644 --- a/scripts/codeguard/git_staging.py +++ b/scripts/codeguard/git_staging.py @@ -30,14 +30,15 @@ def resolve_pathspecs(cwd: Path, paths: list[str], *, tracked_only=False, return sorted({os.fsdecode(name) for name in git(cwd, *args, "--", *paths).split(b"\0") if name}) -def staging_intent(command: str, project_root: Path | None = None) -> tuple[tuple[str, ...], list[str]]: +def staging_intent(command: str, project_root: Path | None = None, *, + cwd: Path | None = None) -> tuple[tuple[str, ...], list[str]]: """逐段绑定 cd/-C 与仓库;返回 lanes 和已解析的仓根相对字面路径。 保留旧无 project_root 的调用面;硬门禁必须传目标仓,以免多仓范围串扰。 不执行 add,不模拟动态 Shell、交互暂存或 pathspec-from-file;不能证明时抛 SnapshotError,由 Hook 以既有 fail-open 协议明示未验证。 """ - cwd = Path.cwd().resolve() + current_dir = (cwd or Path.cwd()).resolve() wanted = Path(project_root).resolve() if project_root is not None else None lanes = {"staged"} extra = set() @@ -49,7 +50,7 @@ def staging_intent(command: str, project_root: Path | None = None) -> tuple[tupl if not words: continue if words[0] == "cd" and len(words) == 2: - cwd = (cwd / words[1]).resolve() + current_dir = (current_dir / words[1]).resolve() continue tokens, c_path = _analyze_segment(segment) if len(tokens) < 2 or tokens[0] != "git" or tokens[1] not in ("add", "commit"): @@ -57,7 +58,7 @@ def staging_intent(command: str, project_root: Path | None = None) -> tuple[tupl sub, rest = tokens[1], tokens[2:] if sub == "commit" and not any(flag in ("-a", "-am", "--all") for flag in rest): continue - location = (cwd / c_path).resolve() if c_path is not None else cwd + location = (current_dir / c_path).resolve() if c_path is not None else current_dir repo = repository_root(location) if wanted is not None and repo != wanted: continue diff --git a/tests/run_all.py b/tests/run_all.py index ce27db4..bb09017 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -307,8 +307,16 @@ def test_unit(): ok("安全报告同构:首行综述不重复", sfirst not in "\n".join(srep.splitlines()[1:])) # 多 cd 边界解析 - roots = guard.resolve_project_roots(f"cd /tmp && x && cd {PLUGIN} && git commit -m t && cd /tmp && git push") + roots = guard.resolve_project_roots(f"cd /tmp && x && cd {PLUGIN} && git commit -m t && cd {PLUGIN} && git push") ok("边界=git 段前最近的 cd", PLUGIN in roots and Path("/tmp") not in roots) + from codeguard.git_context import GitTargetError + try: + guard.resolve_project_roots(f"cd {PLUGIN} && git commit -m t && cd /tmp && git push") + except GitTargetError: + invalid_target_blocked = True + else: + invalid_target_blocked = False + ok("显式非仓目标不回退调用者仓", invalid_target_blocked) ok("无 git 段返回空", guard.resolve_project_roots("ls -la && echo done") == []) ok("is_guarded 词法匹配不误伤 echo", not guard.is_guarded('echo "git push 是危险命令"')) ok("is_guarded 命中真实 git push", guard.is_guarded("cd r && git push origin main")) diff --git a/tests/test_git_guard_application.py b/tests/test_git_guard_application.py index 9e578df..65e0c4c 100644 --- a/tests/test_git_guard_application.py +++ b/tests/test_git_guard_application.py @@ -3,6 +3,9 @@ import contextlib import io +import json +import os +import subprocess import sys import tempfile import unittest @@ -16,6 +19,87 @@ class GitGuardApplicationTests(unittest.TestCase): + def test_explicit_non_git_target_never_falls_back_to_callers_repo(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-explicit-target-") as tmp: + base = Path(tmp).resolve() + repo, plain = base / "repo", base / "plain" + repo.mkdir() + plain.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + for command in (f"cd {plain} && git push origin main", + f"git -C {plain} commit -m x"): + with self.subTest(command=command), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as run_gate, \ + patch.object(git_guard_application, "fallback_roots", return_value=[]) as fallback, \ + patch.object(git_guard_application, "check_commit_safety", return_value=[]): + result = git_guard_application.evaluate_git_command( + command, cwd=repo, load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn(str(plain), result.stderr) + self.assertIn("目标", result.stderr) + run_gate.assert_not_called() + fallback.assert_not_called() + + def test_explicit_git_c_overrides_non_git_cd_target(self): + from codeguard.git_context import resolve_project_roots + + with tempfile.TemporaryDirectory(prefix="cg-explicit-override-") as tmp: + base = Path(tmp).resolve() + repo, plain = base / "repo", base / "plain" + repo.mkdir() + plain.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + roots = resolve_project_roots( + f"cd {plain} && git -C {repo} commit -m x", cwd=repo) + self.assertEqual([repo], roots) + + def test_staging_intent_uses_explicit_callers_cwd(self): + from codeguard import git_guard_application + + with tempfile.TemporaryDirectory(prefix="cg-staging-cwd-") as tmp: + repo = Path(tmp).resolve() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + (repo / "new.py").write_text("print(1)\n", encoding="utf-8") + with patch.object(Path, "cwd", return_value=repo.parent), \ + patch.object(git_guard_application, "run_gate", return_value=([], [])) as run_gate, \ + patch.object(git_guard_application, "check_commit_safety", return_value=[]): + result = git_guard_application.evaluate_git_command( + "git add '*.py' && git commit -m x", cwd=repo, load_config=dict) + self.assertEqual(0, result.exit_code) + self.assertEqual(["new.py"], run_gate.call_args.kwargs["extra"]) + + def test_git_root_observation_failure_is_not_fail_open(self): + from codeguard import git_guard_application + from git_snapshot import SnapshotError + + with patch.object(git_guard_application, "resolve_project_roots", + side_effect=SnapshotError("rev-parse failed")), \ + patch.object(git_guard_application, "fallback_roots") as fallback: + result = git_guard_application.evaluate_git_command( + "git commit -m x", cwd=Path.cwd(), load_config=dict) + self.assertEqual(2, result.exit_code) + self.assertIn("rev-parse failed", result.stderr) + fallback.assert_not_called() + + def test_real_hook_blocks_explicit_non_git_target(self): + with tempfile.TemporaryDirectory(prefix="cg-hook-target-") as tmp: + base = Path(tmp).resolve() + repo, plain = base / "repo", base / "plain" + repo.mkdir() + plain.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + payload = {"tool_name": "Bash", "tool_use_id": "explicit-target", + "tool_input": {"command": f"cd {plain} && git push origin main"}} + result = subprocess.run( + [sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=repo, capture_output=True, text=True, + env={**os.environ, "CODEGUARD_HOME": str(base / "state")}, timeout=15, check=False) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn(str(plain), result.stderr) + self.assertIn("目标 UNVERIFIED", result.stderr) + def test_no_git_repository_blocks_with_diagnostic_without_printing(self): from codeguard import git_guard_application