diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index 3160877284..f29ee48926 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -103,6 +103,30 @@ def _run_git_capture( return result +def _run_git_paths( + workspace: Path, + args: list[str], + *, + timeout: int = 10, +) -> subprocess.CompletedProcess[bytes]: + """Capture path output as bytes; text mode would rewrite CR and CRLF. + + Every caller frames records on NUL, and CPython's text mode translates + ``\r`` and ``\r\n`` to ``\n`` before any of that framing happens. A file + name carrying CR and one carrying LF would then decode to the same string + and collapse in the change list. Bytes keep the name Git wrote until + ``_display_git_path`` prepares the public artifact. + """ + + return subprocess.run( + ["git", *args], + cwd=workspace, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + + def _validate_branch_name(branch: str, *, field: str) -> str: value = str(branch or "").strip() if not value: @@ -153,10 +177,69 @@ def _git_current_branch(workspace: Path) -> str: def _git_status_lines(workspace: Path) -> list[str]: + """Report whether the worktree has changes; the names are not consumed.""" + result = _run_git_capture(workspace, ["status", "--porcelain"]) return [line for line in result.stdout.splitlines() if line.strip()] +def _split_git_paths(output: bytes) -> list[bytes]: + """Return one raw pathname per NUL-framed Git record. + + Every caller passes ``-z`` and captures bytes. Splitting before decoding + keeps CR, LF, CRLF and non-UTF-8 names distinct; display conversion happens + only when the public artifact is built. + """ + + return [item for item in output.split(b"\0") if item] + + +_GIT_BYTE_PATH_PREFIX = "git-bytes:" + + +def _display_git_path(path: bytes) -> tuple[str, bool]: + """Return a display string and whether the bytes are not valid UTF-8. + + Valid UTF-8 is returned literally, including names that happen to begin + with ``git-bytes:``. Invalid UTF-8 is represented by ``repr(bytes)`` for + diagnostics, but the packet must remain not ready because that marker is + not a repo-relative pathname consumers can open. The marker preserves + distinct names such as ``lossy-\\xfe.txt`` and ``lossy-\xff.txt``. + """ + + try: + return path.decode("utf-8"), False + except UnicodeDecodeError: + return f"{_GIT_BYTE_PATH_PREFIX}{path!r}", True + + +def _git_untracked_paths(workspace: Path) -> list[bytes]: + """Return every untracked file, not Git's directory summary.""" + + result = _run_git_paths( + workspace, + ["status", "--porcelain", "-z", "--untracked-files=all"], + ) + if result.returncode != 0: + raise RuntimeError("git status --porcelain -z failed") + entries = result.stdout.split(b"\0") + paths: list[bytes] = [] + index = 0 + while index < len(entries): + entry = entries[index] + index += 1 + if not entry or len(entry) < 4: + continue + status = entry[:2] + if status[0:1] in {b"R", b"C"} or status[1:2] in {b"R", b"C"}: + # The next record names the rename or copy source. + index += 1 + continue + if status == b"??": + paths.append(entry[3:]) + return paths + + def _git_ref_oid(workspace: Path, ref: str) -> str: result = _run_git_capture( workspace, ["rev-parse", "--verify", f"{ref}^{{commit}}"] @@ -215,40 +298,42 @@ def _base_snapshot(workspace: Path, base_branch: str) -> dict[str, Any]: } -def _changed_files(workspace: Path, *, base_branch: str) -> tuple[list[str], bool]: - files: list[str] = [] - truncated = False +def _changed_files( + workspace: Path, *, base_branch: str +) -> tuple[list[str], bool, int, int]: + files: list[bytes] = [] + seen: set[bytes] = set() - def add_lines(lines: list[str]) -> None: - nonlocal truncated - for raw in lines: - path = raw.strip() - if not path or path.startswith("/"): + def add_paths(paths: list[bytes]) -> None: + for path in paths: + if not path or path.startswith(b"/") or path in seen: continue - if path not in files: - files.append(path) - if len(files) >= 20: - truncated = True - return + seen.add(path) + files.append(path) if _git_branch_exists(workspace, base_branch): - result = subprocess.run( - ["git", "diff", "--name-only", f"{base_branch}...HEAD"], - cwd=workspace, - text=True, encoding="utf-8", errors="replace", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=10, + result = _run_git_paths( + workspace, ["diff", "--name-only", "-z", f"{base_branch}...HEAD"] ) if result.returncode == 0: - add_lines(result.stdout.splitlines()) - for args in (["diff", "--name-only"], ["diff", "--cached", "--name-only"]): - result = _run_git_capture(workspace, args) - add_lines(result.stdout.splitlines()) - for line in _git_status_lines(workspace): - if len(line) >= 4 and line[:2] == "??": - add_lines([line[3:]]) - return files[:20], truncated + add_paths(_split_git_paths(result.stdout)) + for args in ( + ["diff", "--name-only", "-z"], + ["diff", "--cached", "--name-only", "-z"], + ): + result = _run_git_paths(workspace, args) + if result.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} failed") + add_paths(_split_git_paths(result.stdout)) + add_paths(_git_untracked_paths(workspace)) + displayed: list[str] = [] + unrepresentable_count = 0 + for path in files: + display, unrepresentable = _display_git_path(path) + if len(displayed) < 20: + displayed.append(display) + unrepresentable_count += int(unrepresentable) + return displayed, len(files) > 20, unrepresentable_count, len(files) # Caller-validation execution (_run_caller_validation / _require_passed) is @@ -655,6 +740,8 @@ def build_issue_fix_caller_repo_branch_packet( } changed_files: list[str] = [] changed_files_truncated = False + changed_files_unrepresentable_count = 0 + changed_file_count = 0 if execute: if not workspace.exists() or not workspace.is_dir(): @@ -710,12 +797,18 @@ def build_issue_fix_caller_repo_branch_packet( timeout_seconds=timeout_seconds, ) validation_command_result["executed"] = True - changed_files, changed_files_truncated = _changed_files(workspace, base_branch=base) + ( + changed_files, + changed_files_truncated, + changed_files_unrepresentable_count, + changed_file_count, + ) = _changed_files(workspace, base_branch=base) review_ready = bool( execute and validation_command_result.get("passed") is True and len(changed_files) > 0 + and changed_files_unrepresentable_count == 0 ) artifact = { "schema_version": ISSUE_FIX_CALLER_REPO_BRANCH_ARTIFACT_SCHEMA_VERSION, @@ -732,7 +825,8 @@ def build_issue_fix_caller_repo_branch_packet( "validation": validation_command_result, "changed_files": changed_files, "changed_files_truncated": changed_files_truncated, - "changed_file_count": len(changed_files), + "changed_files_unrepresentable_count": changed_files_unrepresentable_count, + "changed_file_count": changed_file_count, } packet: dict[str, Any] = { "ok": True, @@ -762,6 +856,7 @@ def build_issue_fix_caller_repo_branch_packet( ), "files_changed": changed_files, "files_changed_truncated": changed_files_truncated, + "files_changed_unrepresentable_count": changed_files_unrepresentable_count, "validation_commands": [validation_label], "external_issue_comment_performed": False, "external_pr_created": False, @@ -943,6 +1038,39 @@ def validate_issue_fix_caller_repo_branch_packet(packet: Mapping[str, Any]) -> d errors.append("changed files must be repo-relative public-safe paths") else: errors.append("changed_files must be a list") + changed_file_count = artifact.get("changed_file_count") + changed_files_truncated = artifact.get("changed_files_truncated") + if not isinstance(changed_files_truncated, bool): + errors.append("changed_files_truncated must be a boolean") + if ( + not isinstance(changed_file_count, int) + or isinstance(changed_file_count, bool) + or changed_file_count < 0 + or (isinstance(files, list) and changed_file_count < len(files)) + or ( + isinstance(files, list) + and changed_files_truncated is False + and changed_file_count != len(files) + ) + or ( + isinstance(files, list) + and changed_files_truncated is True + and changed_file_count <= len(files) + ) + ): + errors.append("changed_file_count must cover the exact public list scope") + unrepresentable_count = artifact.get("changed_files_unrepresentable_count", 0) + if ( + not isinstance(unrepresentable_count, int) + or isinstance(unrepresentable_count, bool) + or unrepresentable_count < 0 + or ( + isinstance(changed_file_count, int) + and not isinstance(changed_file_count, bool) + and unrepresentable_count > changed_file_count + ) + ): + errors.append("changed_files_unrepresentable_count must be a bounded count") review = ( packet.get("review_packet") if isinstance(packet.get("review_packet"), Mapping) @@ -951,6 +1079,18 @@ def validate_issue_fix_caller_repo_branch_packet(packet: Mapping[str, Any]) -> d for key in ("external_issue_comment_performed", "external_pr_created", "merge_performed"): if review.get(key) is not False: errors.append(f"review packet {key} must be false") + review_unrepresentable_count = review.get( + "files_changed_unrepresentable_count", 0 + ) + if ( + not isinstance(review_unrepresentable_count, int) + or isinstance(review_unrepresentable_count, bool) + or review_unrepresentable_count < 0 + or review_unrepresentable_count != unrepresentable_count + ): + errors.append("files_changed_unrepresentable_count must match the artifact") + if unrepresentable_count and review.get("ready") is True: + errors.append("review packet is not ready while changed paths are unrepresentable") return { "schema_version": "issue_fix_caller_repo_branch_validation_v0", "ok": not errors, diff --git a/loopx/capabilities/issue_fix/docs/protocols/issue-fix-acceptance-loop-v0.md b/loopx/capabilities/issue_fix/docs/protocols/issue-fix-acceptance-loop-v0.md index 9b136084cd..afa6a09cab 100644 --- a/loopx/capabilities/issue_fix/docs/protocols/issue-fix-acceptance-loop-v0.md +++ b/loopx/capabilities/issue_fix/docs/protocols/issue-fix-acceptance-loop-v0.md @@ -56,6 +56,17 @@ branch is no longer present. The command never refreshes remote refs implicitly; callers that need new remote evidence must fetch and reconcile it explicitly. It does not expose the local repo path, validation stdout or stderr, raw issue body/comment content, external remotes, or raw git output. +Git pathnames that are valid UTF-8 are preserved literally. A pathname +with invalid UTF-8 is shown only as a `git-bytes:` diagnostic; +the artifact records `changed_files_unrepresentable_count`, and +`review_packet.ready` stays false because that diagnostic is not an +openable repo-relative UTF-8 pathname. The list fields remain capped at 20 +display entries, while `changed_file_count` and the unrepresentable count are +derived from every unique Git path across all change sources before that +projection. Untracked directories are expanded to their files explicitly, +rather than inheriting Git's directory-summary setting. Readiness therefore +does not depend on diff order or untracked-file display configuration. Reviewers must +inspect the local checkout before proceeding when the count is nonzero. Without `--execute`, the command is a dry-run plan and does not inspect or modify the local repository. diff --git a/tests/capabilities/test_issue_fix_acceptance_changed_files.py b/tests/capabilities/test_issue_fix_acceptance_changed_files.py new file mode 100644 index 0000000000..730beb9597 --- /dev/null +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.capabilities.issue_fix import acceptance_loop +from loopx.capabilities.issue_fix.acceptance_loop import ( + _changed_files, + build_issue_fix_caller_repo_branch_packet, +) +from tests.source_cli import run_source_cli + + +def _repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) + subprocess.run( + ["git", "-C", str(repo), "config", "user.email", "fixture@example.invalid"], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "config", "user.name", "Fixture"], check=True + ) + (repo / "tracked.txt").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "tracked.txt"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "base"], check=True) + subprocess.run(["git", "-C", str(repo), "checkout", "-qb", "issue"], check=True) + return repo + + +@pytest.mark.skipif(os.name != "posix", reason="special names are POSIX-only") +def test_untracked_names_keep_the_bytes_git_reported(tmp_path: Path) -> None: + repo = _repository(tmp_path) + names = [ + "中文文件.txt", + " padded.txt ", + "line\nbreak.txt", + "back\\slash.txt", + "has space.txt", + ] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + files, truncated, _, _ = _changed_files(repo, base_branch="main") + + assert truncated is False + assert sorted(files) == sorted(names) + + +@pytest.mark.skipif(os.name != "posix", reason="special names are POSIX-only") +def test_committed_staged_and_untracked_sources_agree(tmp_path: Path) -> None: + repo = _repository(tmp_path) + committed = "更名内容.txt" + staged = " 缩进 .txt" + untracked = "line\nbreak.txt" + + (repo / committed).write_text("committed\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", committed], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "add committed"], check=True) + (repo / staged).write_text("staged\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", staged], check=True) + (repo / untracked).write_text("untracked\n", encoding="utf-8") + + files, truncated, _, _ = _changed_files(repo, base_branch="main") + + assert truncated is False + assert sorted(files) == sorted([committed, staged, untracked]) + + +def test_a_rename_reports_only_the_new_name(tmp_path: Path) -> None: + repo = _repository(tmp_path) + subprocess.run( + ["git", "-C", str(repo), "mv", "tracked.txt", "renamed.txt"], check=True + ) + + files, truncated, _, _ = _changed_files(repo, base_branch="main") + + assert truncated is False + assert files == ["renamed.txt"], "the rename source leaked into the change list" + + +def test_more_than_twenty_changes_stop_at_the_limit(tmp_path: Path) -> None: + repo = _repository(tmp_path) + names = [f"file-{index:02d}.txt" for index in range(25)] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + files, truncated, _, full_count = _changed_files(repo, base_branch="main") + + assert truncated is True + assert full_count == 25 + assert len(files) == 20 + assert files == sorted(names)[:20] + + +@pytest.mark.skipif(os.name != "posix", reason="special names are POSIX-only") +def test_the_caller_branch_packet_records_the_exact_names(tmp_path: Path) -> None: + repo = _repository(tmp_path) + names = ["中文文件.txt", " padded.txt ", "line\nbreak.txt"] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + packet = build_issue_fix_caller_repo_branch_packet( + repo_path=str(repo), + base_branch="main", + issue_branch="issue", + validation_command=shlex.join([sys.executable, "-c", "print(1)"]), + execute=True, + ) + + branch = packet["caller_repo_branch"] + assert branch["branch_ready"] is True + assert branch["changed_file_count"] == len(names) + assert sorted(branch["changed_files"]) == sorted(names) + assert branch["changed_files_truncated"] is False + + +@pytest.mark.skipif(os.name != "posix", reason="control characters in names are POSIX-only") +def test_a_carriage_return_is_not_folded_into_a_newline(tmp_path: Path) -> None: + """Text-mode capture would turn CR and CRLF into LF and collapse them.""" + + repo = _repository(tmp_path) + names = ["line\nbreak.txt", "line\rreturn.txt", "line\r\nboth.txt"] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + files, truncated, _, _ = _changed_files(repo, base_branch="main") + + assert truncated is False + assert sorted(files) == sorted(names) + + +@pytest.mark.skipif(os.name != "posix", reason="control characters in names are POSIX-only") +def test_committed_and_staged_carriage_return_names_survive(tmp_path: Path) -> None: + repo = _repository(tmp_path) + committed = "committed\r\n.txt" + staged = "staged\r.txt" + + (repo / committed).write_text("committed\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", committed], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "add committed"], check=True) + (repo / staged).write_text("staged\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", staged], check=True) + + files, truncated, _, _ = _changed_files(repo, base_branch="main") + + assert truncated is False + assert sorted(files) == sorted([committed, staged]) + + +@pytest.mark.skipif(os.name != "posix", reason="control characters in names are POSIX-only") +def test_the_packet_counts_lf_cr_and_crlf_names_separately(tmp_path: Path) -> None: + repo = _repository(tmp_path) + names = ["line\nbreak.txt", "line\rreturn.txt", "line\r\nboth.txt"] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + packet = build_issue_fix_caller_repo_branch_packet( + repo_path=str(repo), + base_branch="main", + issue_branch="issue", + validation_command=shlex.join([sys.executable, "-c", "print(1)"]), + execute=True, + ) + + branch = packet["caller_repo_branch"] + assert branch["changed_file_count"] == 3 + assert sorted(branch["changed_files"]) == sorted(names) + + +@pytest.mark.skipif(os.name != "posix", reason="control characters in names are POSIX-only") +def test_packet_keeps_every_line_ending_variant_from_four_sources( + tmp_path: Path, +) -> None: + repo = _repository(tmp_path) + names: list[str] = [] + + committed = [f"committed-{kind}.txt" for kind in ("lf\n", "cr\r", "crlf\r\n")] + for name in committed: + (repo / name).write_text("committed\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", *committed], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "committed"], check=True) + names.extend(committed) + + unstaged = [f"unstaged-{kind}.txt" for kind in ("lf\n", "cr\r", "crlf\r\n")] + for name in unstaged: + (repo / name).write_text("before\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", *unstaged], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "tracked"], check=True) + for name in unstaged: + (repo / name).write_text("after\n", encoding="utf-8") + names.extend(unstaged) + + staged = [f"staged-{kind}.txt" for kind in ("lf\n", "cr\r", "crlf\r\n")] + for name in staged: + (repo / name).write_text("staged\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", *staged], check=True) + names.extend(staged) + + untracked = [f"untracked-{kind}.txt" for kind in ("lf\n", "cr\r", "crlf\r\n")] + for name in untracked: + (repo / name).write_text("untracked\n", encoding="utf-8") + names.extend(untracked) + + packet = build_issue_fix_caller_repo_branch_packet( + repo_path=str(repo), + base_branch="main", + issue_branch="issue", + validation_command=shlex.join([sys.executable, "-c", "print(1)"]), + execute=True, + ) + + branch = packet["caller_repo_branch"] + assert branch["branch_ready"] is True + assert branch["changed_file_count"] == 12 + assert branch["changed_files_truncated"] is False + assert sorted(branch["changed_files"]) == sorted(names) + + +@pytest.mark.skipif(os.name != "posix", reason="non-UTF-8 Git paths are POSIX-only") +def test_packet_keeps_literal_prefix_and_refuses_byte_paths( + tmp_path: Path, +) -> None: + repo = _repository(tmp_path) + literal_prefix = "git-bytes:report.txt" + replacement = "lossy-\ufffd.txt" + for name in (literal_prefix, replacement): + (repo / name).write_text("valid UTF-8\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(repo), "add", literal_prefix, replacement], check=True + ) + + fe_blob = subprocess.run( + ["git", "-C", str(repo), "hash-object", "-w", "--stdin"], + input=b"fe\n", stdout=subprocess.PIPE, check=True, + ).stdout.strip() + ff_blob = subprocess.run( + ["git", "-C", str(repo), "hash-object", "-w", "--stdin"], + input=b"ff\n", stdout=subprocess.PIPE, check=True, + ).stdout.strip() + index_info = ( + b"100644 " + fe_blob + b" 0\tlossy-\xfe.txt\0" + + b"100644 " + ff_blob + b" 0\tlossy-\xff.txt\0" + ) + subprocess.run( + ["git", "-C", str(repo), "update-index", "-z", "--index-info"], + input=index_info, check=True, + ) + + raw = subprocess.run( + ["git", "-C", str(repo), "diff", "--cached", "--name-only", "-z"], + stdout=subprocess.PIPE, check=True, + ).stdout + packet = build_issue_fix_caller_repo_branch_packet( + repo_path=str(repo), + base_branch="main", + issue_branch="issue", + validation_command=shlex.join([sys.executable, "-c", "print(1)"]), + execute=True, + ) + branch = packet["caller_repo_branch"] + review = packet["review_packet"] + expected = [ + literal_prefix, + replacement, + "git-bytes:" + repr(b"lossy-\xfe.txt"), + "git-bytes:" + repr(b"lossy-\xff.txt"), + ] + + assert len([record for record in raw.split(b"\0") if record]) == 4 + assert branch["changed_file_count"] == 4 + assert branch["changed_files_unrepresentable_count"] == 2 + assert len(set(branch["changed_files"])) == 4 + assert set(branch["changed_files"]) == set(expected) + assert literal_prefix in branch["changed_files"] + assert f"git-bytes:utf8:{literal_prefix}" not in branch["changed_files"] + assert set(review["files_changed"]) == set(expected) + assert review["files_changed_unrepresentable_count"] == 2 + assert review["ready"] is False + + +@pytest.mark.skipif(os.name != "posix", reason="non-UTF-8 Git paths are POSIX-only") +def test_untracked_byte_paths_keep_three_distinct_records( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _repository(tmp_path) + output = ( + b"?? lossy-\xef\xbf\xbd.txt\0" + b"?? lossy-\xfe.txt\0" + b"?? lossy-\xff.txt\0" + ) + + def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): + stdout = ( + output + if args == ["status", "--porcelain", "-z", "--untracked-files=all"] + else b"" + ) + return subprocess.CompletedProcess(args, 0, stdout, b"") + + monkeypatch.setattr(acceptance_loop, "_run_git_paths", fake_run_git_paths) + paths = acceptance_loop._git_untracked_paths(repo) + files, truncated, unrepresentable_count, full_count = _changed_files( + repo, base_branch="main" + ) + displayed = [ + acceptance_loop._display_git_path(path)[0] for path in paths + ] + + assert paths == [ + b"lossy-\xef\xbf\xbd.txt", + b"lossy-\xfe.txt", + b"lossy-\xff.txt", + ] + assert truncated is False + assert unrepresentable_count == 2 + assert full_count == 3 + assert files == displayed + assert displayed[0] == "lossy-\ufffd.txt" + assert len(set(displayed)) == 3 + + +@pytest.mark.skipif(os.name != "posix", reason="non-UTF-8 Git paths are POSIX-only") +def test_truncated_packet_checks_every_path_regardless_of_diff_order( + tmp_path: Path, +) -> None: + repo = _repository(tmp_path) + names = [f"a{index:02d}.txt" for index in range(25)] + for name in names: + (repo / name).write_text("valid\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", *names], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "valid files"], check=True) + + blob = subprocess.run( + ["git", "-C", str(repo), "hash-object", "-w", "--stdin"], + input=b"byte path\n", stdout=subprocess.PIPE, check=True, + ).stdout.strip() + path_bytes = b"z-\xff.txt" + subprocess.run( + ["git", "-C", str(repo), "update-index", "-z", "--index-info"], + input=b"100644 " + blob + b" 0\t" + path_bytes + b"\0", check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "byte path"], check=True + ) + marker = "git-bytes:" + repr(path_bytes) + command = [ + "--format", "json", "issue-fix", "caller-repo-branch", + "--repo-path", str(repo), "--base-branch", "main", + "--issue-branch", "issue", "--validation-command", "true", + "--execute", + ] + + def run_packet() -> dict: + completed = run_source_cli(command, cwd=repo, timeout=60) + return json.loads(completed.stdout) + + default = run_packet() + assert default["ok"] is True + assert default["caller_repo_branch"]["changed_file_count"] == 26 + assert default["caller_repo_branch"]["changed_files_truncated"] is True + assert default["caller_repo_branch"]["changed_files_unrepresentable_count"] == 1 + assert len(default["caller_repo_branch"]["changed_files"]) == 20 + assert default["review_packet"]["ready"] is False + assert marker not in default["caller_repo_branch"]["changed_files"] + + order_file = tmp_path / "diff-order" + order_file.write_text("z*\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(repo), "config", "diff.orderFile", str(order_file)], + check=True, + ) + reordered = run_packet() + assert reordered["ok"] is True + assert reordered["caller_repo_branch"]["changed_file_count"] == 26 + assert reordered["caller_repo_branch"]["changed_files_truncated"] is True + assert reordered["caller_repo_branch"]["changed_files_unrepresentable_count"] == 1 + assert reordered["review_packet"]["files_changed_unrepresentable_count"] == 1 + assert reordered["review_packet"]["ready"] is False + assert reordered["caller_repo_branch"]["changed_files"][0] == marker + + subprocess.run( + [ + b"git", b"-C", os.fsencode(str(repo)), b"reset", b"--soft", b"main" + ], + check=True, + ) + subprocess.run( + [ + b"git", b"-C", os.fsencode(str(repo)), b"rm", b"--cached", b"--", + path_bytes, + ], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "valid files"], check=True + ) + + recovered = run_packet() + assert recovered["ok"] is True + assert recovered["caller_repo_branch"]["changed_file_count"] == 25 + assert recovered["caller_repo_branch"]["changed_files_truncated"] is True + assert recovered["caller_repo_branch"]["changed_files_unrepresentable_count"] == 0 + assert recovered["review_packet"]["files_changed_unrepresentable_count"] == 0 + assert len(recovered["caller_repo_branch"]["changed_files"]) == 20 + assert recovered["review_packet"]["ready"] is True + + +@pytest.mark.skipif(os.name != "posix", reason="the fixture uses the POSIX true command") +def test_untracked_directories_are_expanded_before_counting(tmp_path: Path) -> None: + repo = _repository(tmp_path) + nested = repo / "nested" + nested.mkdir() + for index in range(26): + (nested / f"file-{index:02d}.txt").write_text( + "untracked\n", encoding="utf-8" + ) + subprocess.run( + ["git", "-C", str(repo), "config", "status.showUntrackedFiles", "normal"], + check=True, + ) + summary = subprocess.run( + ["git", "-C", str(repo), "status", "--porcelain", "-z"], + stdout=subprocess.PIPE, check=True, + ).stdout + assert summary == b"?? nested/\0" + + command = [ + "--format", "json", "issue-fix", "caller-repo-branch", + "--repo-path", str(repo), "--base-branch", "main", + "--issue-branch", "issue", "--validation-command", "true", + "--execute", + ] + + def run_packet() -> dict: + completed = run_source_cli(command, cwd=repo, timeout=60) + return json.loads(completed.stdout) + + default = run_packet() + assert default["ok"] is True + assert default["caller_repo_branch"]["changed_file_count"] == 26 + assert default["caller_repo_branch"]["changed_files_truncated"] is True + assert len(default["caller_repo_branch"]["changed_files"]) == 20 + assert default["review_packet"]["ready"] is True + + subprocess.run( + ["git", "-C", str(repo), "config", "status.showUntrackedFiles", "no"], + check=True, + ) + hidden = run_packet() + assert hidden["ok"] is True + assert hidden["caller_repo_branch"]["changed_file_count"] == 26 + assert hidden["caller_repo_branch"]["changed_files_truncated"] is True + assert len(hidden["caller_repo_branch"]["changed_files"]) == 20 + assert hidden["review_packet"]["ready"] is True