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: 4 additions & 4 deletions loopx/canary/premerge.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,14 @@ def _path_matches(path: str, tokens: tuple[str, ...]) -> bool:


def _dedupe(values: list[str]) -> list[str]:
"""Deduplicate path identities without rewriting Git-provided values."""
seen: set[str] = set()
deduped: list[str] = []
for value in values:
normalized = _norm_path(value)
if not normalized or normalized in seen:
if not value or value in seen:
continue
seen.add(normalized)
deduped.append(normalized)
seen.add(value)
deduped.append(value)
return deduped


Expand Down
128 changes: 112 additions & 16 deletions loopx/cli_commands/canary.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ def _dedupe_preserving_order(values: list[str]) -> list[str]:
return deduped


def _dedupe_exact_paths(values: list[str]) -> list[str]:
"""Deduplicate Git-derived paths without rewriting their identity."""
seen: set[str] = set()
deduped: list[str] = []
for value in values:
if not value or value in seen:
continue
seen.add(value)
deduped.append(value)
return deduped


def _run_git_name_only(repo_root: Path, args: list[str]) -> dict[str, Any]:
# `-z` frames each pathname on NUL. LF framing is only unambiguous while
# `core.quotePath` escapes non-ASCII pathnames, and a repository may turn
Expand All @@ -79,17 +91,31 @@ def _run_git_name_only(repo_root: Path, args: list[str]) -> dict[str, Any]:
completed = subprocess.run(
command,
check=False,
text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
capture_output=True,
)
files = [name for name in completed.stdout.split("\0") if name]
records = [record for record in completed.stdout.split(b"\0") if record]
files: list[str] = []
undecodable_count = 0
for record in records:
try:
files.append(record.decode("utf-8"))
except UnicodeDecodeError:
undecodable_count += 1

path_error = None
if undecodable_count:
path_error = (
f"Git returned {undecodable_count} changed pathname(s) that are not valid UTF-8; "
"canary cannot preserve their identity in the selector output"
)
return {
"ok": completed.returncode == 0,
"ok": completed.returncode == 0 and path_error is None,
"returncode": completed.returncode,
"command": command,
"changed_files": files,
"stderr_tail": completed.stderr[-800:],
"changed_files": files if path_error is None else [],
"undecodable_path_count": undecodable_count,
"path_error": path_error,
"stderr_tail": completed.stderr.decode("utf-8", "replace")[-800:],
}


Expand Down Expand Up @@ -121,7 +147,7 @@ def collect_git_diff_changed_files(
"unstaged": _run_git_name_only(repo_root, ["diff", "--name-only"]),
"untracked": _run_git_name_only(repo_root, ["ls-files", "--others", "--exclude-standard"]),
}
changed_files = _dedupe_preserving_order(
changed_files = _dedupe_exact_paths(
[
file
for source in sources.values()
Expand All @@ -138,36 +164,84 @@ def collect_git_diff_changed_files(
"source": name,
"returncode": source.get("returncode"),
"stderr_tail": source.get("stderr_tail"),
**({"error": source["path_error"]} if source.get("path_error") else {}),
}
for name, source in sources.items()
if not source.get("ok")
]
undecodable_path_count = sum(
int(source.get("undecodable_path_count") or 0)
for source in sources.values()
)
path_error = (
"Git returned changed pathnames that are not valid UTF-8; canary "
"cannot preserve their identity in the selector output"
if undecodable_path_count
else None
)
return {
"ok": bool(successful_sources),
"ok": bool(successful_sources) and path_error is None,
"base_ref": base_ref,
"repo_root": str(repo_root),
"changed_files": changed_files,
"changed_files": [] if path_error is not None else changed_files,
"changed_file_count": len(changed_files),
"successful_sources": successful_sources,
"undecodable_path_count": undecodable_path_count,
**({"error": path_error} if path_error else {}),
"warnings": warnings,
}


def _resolve_canary_changed_files(args: argparse.Namespace) -> tuple[list[str], dict[str, Any] | None]:
changed_files = list(args.changed_file or [])
def _resolve_canary_changed_files(
args: argparse.Namespace,
*,
normalize_manual: bool = False,
) -> tuple[list[str], dict[str, Any] | None]:
manual_files = list(args.changed_file or [])
if normalize_manual:
manual_files = [path.strip().replace("\\", "/") for path in manual_files]
changed_files = _dedupe_preserving_order(manual_files)
git_diff_selector = None
if bool(getattr(args, "from_git_diff", False)):
repo_root = _resolve_git_repo_root(Path.cwd())
git_diff_selector = collect_git_diff_changed_files(
repo_root=repo_root,
base_ref=str(getattr(args, "git_diff_base", "origin/main") or "origin/main"),
)
changed_files.extend(
derived = [
file
for file in (git_diff_selector.get("changed_files") or [])
if isinstance(file, str)
)
return _dedupe_preserving_order(changed_files), git_diff_selector
]
changed_files = _dedupe_exact_paths([*changed_files, *derived])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 保留传入 premerge 的合法 Git 路径身份

这里的精确去重在 CLI 层是正确的,但这份列表进入 build_premerge_validation_gate 后会被 _dedupe / _norm_path 再次 strip 和替换反斜杠。真实 Git 对照中,同时存在 fixtures/trailing.py 和同名尾部多一个空格的文件时,selector 两个都列出,premerge --from-git-diff --no-execute 仍退出 0 且下游只收到一个;字面反斜杠也被当作目录分隔符。请让实际 gate/catalog 消费原始身份,单独保留分类的规范化,并让回归测试断言 premerge 自己消费的列表。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 5d89a2431c7769cecb611aa07c71cf0d89e27c7a 修复。

premerge._dedupe 不再用 _norm_path 重写身份,原始 Git 路径直接进入 classification、catalog 选择、direct checks、risk profile 和 boundary 扫描;_path_matches 仍只把规范化用于分类匹配。显式 --changed-file 的反斜杠归一化保留在 premerge 的 CLI 入口,Git 来源路径不经过该步骤。

新增真实 Git 回归:同一夹具包含字面反斜杠、CR、前导空格、fixtures/trailing.py 与同名尾部多一个空格的文件,以及 docs/ 下的 boundary 路径。断言 selector、premerge 顶层 changed_files、classification、catalog_run.selection_inputs.changed_files、boundary_run.selection_inputs.changed_files 与 scan_paths 均为 6 条原名。该回归在旧 head 上会看到 docs/back/slash.txt 和尾部空格被合并,修复后通过。

本地验证:tests/canary/test_git_path_framing.py 7 passed,test_repository_root.py 与 test_premerge_public_boundary_run.py 30 passed,Ruff 与 git diff --check 通过。远端 CI 当前仍在排队,结果以实际检查状态为准。

return changed_files, git_diff_selector


def _git_diff_selector_error(git_diff_selector: dict[str, Any] | None) -> str | None:
if git_diff_selector is None or git_diff_selector.get("ok"):
return None
return str(
git_diff_selector.get("error")
or "Git changed-path selection failed; refusing to return an incomplete path set"
)


def _reject_git_diff_selector(
git_diff_selector: dict[str, Any] | None,
args: argparse.Namespace,
print_payload: PrintPayload,
format_selector: FormatSelector,
) -> bool:
error = _git_diff_selector_error(git_diff_selector)
if error is None or git_diff_selector is None:
return False
payload = {
"ok": False,
"error": error,
"selector_sources": {"git_diff": git_diff_selector},
}
print_payload(payload, format_selector(args), lambda value: str(value["error"]) + "\n")
return True


def _attach_selector_sources(
Expand All @@ -178,6 +252,10 @@ def _attach_selector_sources(
if git_diff_selector is None:
return
payload["selector_sources"] = {"git_diff": git_diff_selector}
error = _git_diff_selector_error(git_diff_selector)
if error is not None:
payload["ok"] = False
payload["error"] = error


def _print_smoke_suite_progress(event: dict[str, Any]) -> None:
Expand Down Expand Up @@ -587,6 +665,10 @@ def handle_canary_command(
renderer = render_exact_release_commit_qualification_markdown
elif args.canary_command == "plan":
changed_files, git_diff_selector = _resolve_canary_changed_files(args)
if _reject_git_diff_selector(
git_diff_selector, args, print_payload, output_format
):
return 1
payload = build_catalog_canary_plan(
catalog_path=args.catalog,
changed_files=changed_files,
Expand All @@ -601,6 +683,10 @@ def handle_canary_command(
renderer = render_catalog_canary_plan_markdown
elif args.canary_command == "run":
changed_files, git_diff_selector = _resolve_canary_changed_files(args)
if _reject_git_diff_selector(
git_diff_selector, args, print_payload, output_format
):
return 1
payload = build_catalog_canary_run(
repo_root=_resolve_git_repo_root(Path.cwd()),
catalog_path=args.catalog,
Expand All @@ -619,6 +705,10 @@ def handle_canary_command(
renderer = render_catalog_canary_run_markdown
elif args.canary_command == "smoke-suite":
changed_files, git_diff_selector = _resolve_canary_changed_files(args)
if _reject_git_diff_selector(
git_diff_selector, args, print_payload, output_format
):
return 1
payload = build_canary_smoke_suite_run(
repo_root=_resolve_git_repo_root(Path.cwd()),
suite=str(args.suite or "default-public"),
Expand Down Expand Up @@ -660,7 +750,13 @@ def handle_canary_command(
)
renderer = render_quality_surface_catalog_audit_markdown
elif args.canary_command == "premerge":
changed_files, git_diff_selector = _resolve_canary_changed_files(args)
changed_files, git_diff_selector = _resolve_canary_changed_files(
args, normalize_manual=True
)
if _reject_git_diff_selector(
git_diff_selector, args, print_payload, output_format
):
return 1
target_repo_root = _resolve_git_repo_root(Path.cwd())
try:
catalog_path = resolve_canary_catalog_path(args.catalog, repo_root=target_repo_root)
Expand Down
Loading
Loading