From 8a48049c687822f9b1a7b62dc4ed2cdd022a7017 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:17:03 +0000 Subject: [PATCH 1/7] fix(issue-fix): record changed paths without Git quoting Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 56 +++++++-- ...test_issue_fix_acceptance_changed_files.py | 118 ++++++++++++++++++ 2 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 tests/capabilities/test_issue_fix_acceptance_changed_files.py diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index 3160877284..f68a60598b 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -153,10 +153,46 @@ 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: str) -> list[str]: + """Return one path per NUL-framed record, byte for byte. + + Every caller passes ``-z``: the default framing quotes a non-ASCII name + (``"\\344\\270\\255..."``) and ends records on LF, so one legal file name + carrying a newline or leading/trailing spaces would arrive as a fragment + that no later step could match back to the file it names. + """ + + return [item for item in output.split("\0") if item] + + +def _git_untracked_paths(workspace: Path) -> list[str]: + """Return the untracked paths exactly as Git recorded them.""" + + result = _run_git_capture(workspace, ["status", "--porcelain", "-z"]) + entries = result.stdout.split("\0") + paths: list[str] = [] + 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] in {"R", "C"} or status[1] in {"R", "C"}: + # The next record names the rename or copy source. + index += 1 + continue + if status == "??": + 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}}"] @@ -219,10 +255,9 @@ def _changed_files(workspace: Path, *, base_branch: str) -> tuple[list[str], boo files: list[str] = [] truncated = False - def add_lines(lines: list[str]) -> None: + def add_paths(paths: list[str]) -> None: nonlocal truncated - for raw in lines: - path = raw.strip() + for path in paths: if not path or path.startswith("/"): continue if path not in files: @@ -233,7 +268,7 @@ def add_lines(lines: list[str]) -> None: if _git_branch_exists(workspace, base_branch): result = subprocess.run( - ["git", "diff", "--name-only", f"{base_branch}...HEAD"], + ["git", "diff", "--name-only", "-z", f"{base_branch}...HEAD"], cwd=workspace, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, @@ -241,13 +276,14 @@ def add_lines(lines: list[str]) -> None: timeout=10, ) if result.returncode == 0: - add_lines(result.stdout.splitlines()) - for args in (["diff", "--name-only"], ["diff", "--cached", "--name-only"]): + add_paths(_split_git_paths(result.stdout)) + for args in ( + ["diff", "--name-only", "-z"], + ["diff", "--cached", "--name-only", "-z"], + ): 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:]]) + add_paths(_split_git_paths(result.stdout)) + add_paths(_git_untracked_paths(workspace)) return files[:20], truncated 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..c65ecece51 --- /dev/null +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.capabilities.issue_fix.acceptance_loop import ( + _changed_files, + build_issue_fix_caller_repo_branch_packet, +) + + +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 = _changed_files(repo, base_branch="main") + + assert truncated is True + 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 From 755b6dbbb6e82768734ff179c9d7e3cf601a25d7 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:14:00 +0000 Subject: [PATCH 2/7] fix(issue-fix): capture changed paths as bytes before framing Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 64 +++++++++++++------ ...test_issue_fix_acceptance_changed_files.py | 53 +++++++++++++++ 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index f68a60598b..9931ed7f42 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 + ``_split_git_paths`` decodes each record on its own. + """ + + 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: @@ -159,23 +183,26 @@ def _git_status_lines(workspace: Path) -> list[str]: return [line for line in result.stdout.splitlines() if line.strip()] -def _split_git_paths(output: str) -> list[str]: - """Return one path per NUL-framed record, byte for byte. +def _split_git_paths(output: bytes) -> list[str]: + """Return one path per NUL-framed record, decoded after the split. - Every caller passes ``-z``: the default framing quotes a non-ASCII name - (``"\\344\\270\\255..."``) and ends records on LF, so one legal file name - carrying a newline or leading/trailing spaces would arrive as a fragment - that no later step could match back to the file it names. + Every caller passes ``-z`` and captures bytes: the default framing quotes a + non-ASCII name (``"\\344\\270\\255..."``) and ends records on LF, and text + mode turns a CR or CRLF inside a legal name into another LF before this + function ever sees it. Decoding each record separately keeps CR, LF and + CRLF names distinct, with leading and trailing spaces intact. """ - return [item for item in output.split("\0") if item] + return [item.decode("utf-8", "replace") for item in output.split(b"\0") if item] def _git_untracked_paths(workspace: Path) -> list[str]: """Return the untracked paths exactly as Git recorded them.""" - result = _run_git_capture(workspace, ["status", "--porcelain", "-z"]) - entries = result.stdout.split("\0") + result = _run_git_paths(workspace, ["status", "--porcelain", "-z"]) + if result.returncode != 0: + raise RuntimeError("git status --porcelain -z failed") + entries = result.stdout.split(b"\0") paths: list[str] = [] index = 0 while index < len(entries): @@ -184,12 +211,12 @@ def _git_untracked_paths(workspace: Path) -> list[str]: if not entry or len(entry) < 4: continue status = entry[:2] - if status[0] in {"R", "C"} or status[1] in {"R", "C"}: + 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 == "??": - paths.append(entry[3:]) + if status == b"??": + paths.append(entry[3:].decode("utf-8", "replace")) return paths @@ -267,13 +294,8 @@ def add_paths(paths: list[str]) -> None: return if _git_branch_exists(workspace, base_branch): - result = subprocess.run( - ["git", "diff", "--name-only", "-z", 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_paths(_split_git_paths(result.stdout)) @@ -281,7 +303,9 @@ def add_paths(paths: list[str]) -> None: ["diff", "--name-only", "-z"], ["diff", "--cached", "--name-only", "-z"], ): - result = _run_git_capture(workspace, args) + 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)) return files[:20], truncated diff --git a/tests/capabilities/test_issue_fix_acceptance_changed_files.py b/tests/capabilities/test_issue_fix_acceptance_changed_files.py index c65ecece51..55dd3599f1 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -116,3 +116,56 @@ def test_the_caller_branch_packet_records_the_exact_names(tmp_path: Path) -> Non 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) From 074d3ecc16f583c0a50718b2b546e789824a62a5 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:12:01 +0000 Subject: [PATCH 3/7] test(issue-fix): cover CR and CRLF paths from every source Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- ...test_issue_fix_acceptance_changed_files.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/capabilities/test_issue_fix_acceptance_changed_files.py b/tests/capabilities/test_issue_fix_acceptance_changed_files.py index 55dd3599f1..629231e739 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -169,3 +169,52 @@ def test_the_packet_counts_lf_cr_and_crlf_names_separately(tmp_path: Path) -> No 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) From 662b17387263e7ebb6bf7950896788364c36dbd3 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:17:53 +0000 Subject: [PATCH 4/7] fix(issue-fix): preserve lossless git path evidence Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 54 ++++++++---- ...test_issue_fix_acceptance_changed_files.py | 83 +++++++++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index 9931ed7f42..b12b0b9a9c 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -115,7 +115,7 @@ def _run_git_paths( ``\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 - ``_split_git_paths`` decodes each record on its own. + ``_display_git_path`` prepares the public artifact. """ return subprocess.run( @@ -183,27 +183,47 @@ def _git_status_lines(workspace: Path) -> list[str]: return [line for line in result.stdout.splitlines() if line.strip()] -def _split_git_paths(output: bytes) -> list[str]: - """Return one path per NUL-framed record, decoded after the split. +def _split_git_paths(output: bytes) -> list[bytes]: + """Return one raw pathname per NUL-framed Git record. - Every caller passes ``-z`` and captures bytes: the default framing quotes a - non-ASCII name (``"\\344\\270\\255..."``) and ends records on LF, and text - mode turns a CR or CRLF inside a legal name into another LF before this - function ever sees it. Decoding each record separately keeps CR, LF and - CRLF names distinct, with leading and trailing spaces intact. + 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.decode("utf-8", "replace") for item in output.split(b"\0") if item] + return [item for item in output.split(b"\0") if item] -def _git_untracked_paths(workspace: Path) -> list[str]: - """Return the untracked paths exactly as Git recorded them.""" +_GIT_BYTE_PATH_PREFIX = "git-bytes:" + + +def _display_git_path(path: bytes) -> str: + """Render a Git pathname without losing its byte identity. + + Valid UTF-8 keeps its normal spelling. A pathname that begins with the + reserved prefix is escaped into a disjoint namespace so it cannot be + confused with an encoded byte pathname. Invalid UTF-8 is represented by + ``repr(bytes)``, which preserves every byte and distinguishes names such as + ``lossy-\\xfe.txt`` from the valid UTF-8 name ``lossy-\ufffd.txt``. + """ + + try: + text = path.decode("utf-8") + except UnicodeDecodeError: + return f"{_GIT_BYTE_PATH_PREFIX}{path!r}" + if text.startswith(_GIT_BYTE_PATH_PREFIX): + return f"{_GIT_BYTE_PATH_PREFIX}utf8:{text}" + return text + + +def _git_untracked_paths(workspace: Path) -> list[bytes]: + """Return the untracked byte pathnames exactly as Git recorded them.""" result = _run_git_paths(workspace, ["status", "--porcelain", "-z"]) if result.returncode != 0: raise RuntimeError("git status --porcelain -z failed") entries = result.stdout.split(b"\0") - paths: list[str] = [] + paths: list[bytes] = [] index = 0 while index < len(entries): entry = entries[index] @@ -216,7 +236,7 @@ def _git_untracked_paths(workspace: Path) -> list[str]: index += 1 continue if status == b"??": - paths.append(entry[3:].decode("utf-8", "replace")) + paths.append(entry[3:]) return paths @@ -279,13 +299,13 @@ 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] = [] + files: list[bytes] = [] truncated = False - def add_paths(paths: list[str]) -> None: + def add_paths(paths: list[bytes]) -> None: nonlocal truncated for path in paths: - if not path or path.startswith("/"): + if not path or path.startswith(b"/"): continue if path not in files: files.append(path) @@ -308,7 +328,7 @@ def add_paths(paths: list[str]) -> None: raise RuntimeError(f"git {' '.join(args)} failed") add_paths(_split_git_paths(result.stdout)) add_paths(_git_untracked_paths(workspace)) - return files[:20], truncated + return [_display_git_path(path) for path in files[:20]], truncated # Caller-validation execution (_run_caller_validation / _require_passed) is diff --git a/tests/capabilities/test_issue_fix_acceptance_changed_files.py b/tests/capabilities/test_issue_fix_acceptance_changed_files.py index 629231e739..fadd027d0d 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -8,6 +8,7 @@ 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, @@ -218,3 +219,85 @@ def test_packet_keeps_every_line_ending_variant_from_four_sources( 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_distinguishes_byte_paths_from_a_real_replacement_character( + tmp_path: Path, +) -> None: + repo = _repository(tmp_path) + replacement = "lossy-\ufffd.txt" + (repo / replacement).write_text("valid replacement\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", 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, + ) + expected = [ + 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]) == 3 + assert packet["caller_repo_branch"]["changed_file_count"] == 3 + assert len(set(packet["caller_repo_branch"]["changed_files"])) == 3 + assert set(packet["caller_repo_branch"]["changed_files"]) == set(expected) + assert set(packet["review_packet"]["files_changed"]) == set(expected) + assert packet["review_packet"]["ready"] is True + + +@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"] 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 = _changed_files(repo, base_branch="main") + displayed = [acceptance_loop._display_git_path(path) 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 files == displayed + assert displayed[0] == "lossy-\ufffd.txt" + assert len(set(displayed)) == 3 From 6a04d50ee204633424b115464fc15b4ac9a9b2df Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:20:14 +0000 Subject: [PATCH 5/7] fix(issue-fix): keep literal UTF-8 paths and fail byte paths closed Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 64 ++++++++++++++----- .../protocols/issue-fix-acceptance-loop-v0.md | 6 ++ ...test_issue_fix_acceptance_changed_files.py | 50 ++++++++++----- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index b12b0b9a9c..c4ec07e86a 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -197,23 +197,20 @@ def _split_git_paths(output: bytes) -> list[bytes]: _GIT_BYTE_PATH_PREFIX = "git-bytes:" -def _display_git_path(path: bytes) -> str: - """Render a Git pathname without losing its byte identity. - - Valid UTF-8 keeps its normal spelling. A pathname that begins with the - reserved prefix is escaped into a disjoint namespace so it cannot be - confused with an encoded byte pathname. Invalid UTF-8 is represented by - ``repr(bytes)``, which preserves every byte and distinguishes names such as - ``lossy-\\xfe.txt`` from the valid UTF-8 name ``lossy-\ufffd.txt``. +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: - text = path.decode("utf-8") + return path.decode("utf-8"), False except UnicodeDecodeError: - return f"{_GIT_BYTE_PATH_PREFIX}{path!r}" - if text.startswith(_GIT_BYTE_PATH_PREFIX): - return f"{_GIT_BYTE_PATH_PREFIX}utf8:{text}" - return text + return f"{_GIT_BYTE_PATH_PREFIX}{path!r}", True def _git_untracked_paths(workspace: Path) -> list[bytes]: @@ -298,7 +295,9 @@ def _base_snapshot(workspace: Path, base_branch: str) -> dict[str, Any]: } -def _changed_files(workspace: Path, *, base_branch: str) -> tuple[list[str], bool]: +def _changed_files( + workspace: Path, *, base_branch: str +) -> tuple[list[str], bool, int]: files: list[bytes] = [] truncated = False @@ -328,7 +327,13 @@ def add_paths(paths: list[bytes]) -> None: raise RuntimeError(f"git {' '.join(args)} failed") add_paths(_split_git_paths(result.stdout)) add_paths(_git_untracked_paths(workspace)) - return [_display_git_path(path) for path in files[:20]], truncated + displayed: list[str] = [] + unrepresentable_count = 0 + for path in files[:20]: + display, unrepresentable = _display_git_path(path) + displayed.append(display) + unrepresentable_count += int(unrepresentable) + return displayed, truncated, unrepresentable_count # Caller-validation execution (_run_caller_validation / _require_passed) is @@ -735,6 +740,7 @@ def build_issue_fix_caller_repo_branch_packet( } changed_files: list[str] = [] changed_files_truncated = False + changed_files_unrepresentable_count = 0 if execute: if not workspace.exists() or not workspace.is_dir(): @@ -790,12 +796,17 @@ 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_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, @@ -812,6 +823,7 @@ def build_issue_fix_caller_repo_branch_packet( "validation": validation_command_result, "changed_files": changed_files, "changed_files_truncated": changed_files_truncated, + "changed_files_unrepresentable_count": changed_files_unrepresentable_count, "changed_file_count": len(changed_files), } packet: dict[str, Any] = { @@ -842,6 +854,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, @@ -1023,6 +1036,14 @@ 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") + 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(files, list) and unrepresentable_count > len(files)) + ): + errors.append("changed_files_unrepresentable_count must be a bounded count") review = ( packet.get("review_packet") if isinstance(packet.get("review_packet"), Mapping) @@ -1031,6 +1052,17 @@ 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 + ): + errors.append("files_changed_unrepresentable_count must be a non-negative count") + 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..f4eea70a57 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,12 @@ 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. Reviewers must inspect the local +checkout before proceeding. 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 index fadd027d0d..6300f8dfca 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -46,7 +46,7 @@ def test_untracked_names_keep_the_bytes_git_reported(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated = _changed_files(repo, base_branch="main") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted(names) @@ -66,7 +66,7 @@ def test_committed_staged_and_untracked_sources_agree(tmp_path: Path) -> None: 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") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted([committed, staged, untracked]) @@ -78,7 +78,7 @@ def test_a_rename_reports_only_the_new_name(tmp_path: Path) -> None: ["git", "-C", str(repo), "mv", "tracked.txt", "renamed.txt"], check=True ) - files, truncated = _changed_files(repo, base_branch="main") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is False assert files == ["renamed.txt"], "the rename source leaked into the change list" @@ -90,7 +90,7 @@ def test_more_than_twenty_changes_stop_at_the_limit(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated = _changed_files(repo, base_branch="main") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is True assert len(files) == 20 @@ -128,7 +128,7 @@ def test_a_carriage_return_is_not_folded_into_a_newline(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated = _changed_files(repo, base_branch="main") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted(names) @@ -146,7 +146,7 @@ def test_committed_and_staged_carriage_return_names_survive(tmp_path: Path) -> N (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") + files, truncated, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted([committed, staged]) @@ -222,13 +222,17 @@ def test_packet_keeps_every_line_ending_variant_from_four_sources( @pytest.mark.skipif(os.name != "posix", reason="non-UTF-8 Git paths are POSIX-only") -def test_packet_distinguishes_byte_paths_from_a_real_replacement_character( +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" - (repo / replacement).write_text("valid replacement\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", replacement], check=True) + 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"], @@ -258,18 +262,25 @@ def test_packet_distinguishes_byte_paths_from_a_real_replacement_character( 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]) == 3 - assert packet["caller_repo_branch"]["changed_file_count"] == 3 - assert len(set(packet["caller_repo_branch"]["changed_files"])) == 3 - assert set(packet["caller_repo_branch"]["changed_files"]) == set(expected) - assert set(packet["review_packet"]["files_changed"]) == set(expected) - assert packet["review_packet"]["ready"] is True + 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") @@ -289,8 +300,12 @@ def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): monkeypatch.setattr(acceptance_loop, "_run_git_paths", fake_run_git_paths) paths = acceptance_loop._git_untracked_paths(repo) - files, truncated = _changed_files(repo, base_branch="main") - displayed = [acceptance_loop._display_git_path(path) for path in paths] + files, truncated, unrepresentable_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", @@ -298,6 +313,7 @@ def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): b"lossy-\xff.txt", ] assert truncated is False + assert unrepresentable_count == 2 assert files == displayed assert displayed[0] == "lossy-\ufffd.txt" assert len(set(displayed)) == 3 From e3076c9a7456c2703b9722ec7083ce81cfb9a2ae Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:48:02 +0000 Subject: [PATCH 6/7] fix(issue-fix): derive byte-path readiness before truncation Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 55 ++++++--- .../protocols/issue-fix-acceptance-loop-v0.md | 7 +- ...test_issue_fix_acceptance_changed_files.py | 104 ++++++++++++++++-- 3 files changed, 142 insertions(+), 24 deletions(-) diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index c4ec07e86a..90d881261b 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -297,20 +297,16 @@ def _base_snapshot(workspace: Path, base_branch: str) -> dict[str, Any]: def _changed_files( workspace: Path, *, base_branch: str -) -> tuple[list[str], bool, int]: +) -> tuple[list[str], bool, int, int]: files: list[bytes] = [] - truncated = False + seen: set[bytes] = set() def add_paths(paths: list[bytes]) -> None: - nonlocal truncated for path in paths: - if not path or path.startswith(b"/"): + 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 = _run_git_paths( @@ -329,11 +325,12 @@ def add_paths(paths: list[bytes]) -> None: add_paths(_git_untracked_paths(workspace)) displayed: list[str] = [] unrepresentable_count = 0 - for path in files[:20]: + for path in files: display, unrepresentable = _display_git_path(path) - displayed.append(display) + if len(displayed) < 20: + displayed.append(display) unrepresentable_count += int(unrepresentable) - return displayed, truncated, unrepresentable_count + return displayed, len(files) > 20, unrepresentable_count, len(files) # Caller-validation execution (_run_caller_validation / _require_passed) is @@ -741,6 +738,7 @@ 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(): @@ -800,6 +798,7 @@ def build_issue_fix_caller_repo_branch_packet( changed_files, changed_files_truncated, changed_files_unrepresentable_count, + changed_file_count, ) = _changed_files(workspace, base_branch=base) review_ready = bool( @@ -824,7 +823,7 @@ def build_issue_fix_caller_repo_branch_packet( "changed_files": changed_files, "changed_files_truncated": changed_files_truncated, "changed_files_unrepresentable_count": changed_files_unrepresentable_count, - "changed_file_count": len(changed_files), + "changed_file_count": changed_file_count, } packet: dict[str, Any] = { "ok": True, @@ -1036,12 +1035,37 @@ 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(files, list) and unrepresentable_count > len(files)) + 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 = ( @@ -1059,8 +1083,9 @@ def validate_issue_fix_caller_repo_branch_packet(packet: Mapping[str, Any]) -> d 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 be a non-negative 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 { 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 f4eea70a57..ae33ef4120 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 @@ -60,8 +60,11 @@ 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. Reviewers must inspect the local -checkout before proceeding. +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; readiness therefore does not depend on diff order. 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 index 6300f8dfca..0e7397c6f8 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import shlex import subprocess @@ -13,6 +14,7 @@ _changed_files, build_issue_fix_caller_repo_branch_packet, ) +from tests.source_cli import run_source_cli def _repository(tmp_path: Path) -> Path: @@ -46,7 +48,7 @@ def test_untracked_names_keep_the_bytes_git_reported(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated, _ = _changed_files(repo, base_branch="main") + files, truncated, _, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted(names) @@ -66,7 +68,7 @@ def test_committed_staged_and_untracked_sources_agree(tmp_path: Path) -> None: 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") + files, truncated, _, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted([committed, staged, untracked]) @@ -78,7 +80,7 @@ def test_a_rename_reports_only_the_new_name(tmp_path: Path) -> None: ["git", "-C", str(repo), "mv", "tracked.txt", "renamed.txt"], check=True ) - files, truncated, _ = _changed_files(repo, base_branch="main") + files, truncated, _, _ = _changed_files(repo, base_branch="main") assert truncated is False assert files == ["renamed.txt"], "the rename source leaked into the change list" @@ -90,9 +92,10 @@ def test_more_than_twenty_changes_stop_at_the_limit(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated, _ = _changed_files(repo, base_branch="main") + 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] @@ -128,7 +131,7 @@ def test_a_carriage_return_is_not_folded_into_a_newline(tmp_path: Path) -> None: for name in names: (repo / name).write_text("content\n", encoding="utf-8") - files, truncated, _ = _changed_files(repo, base_branch="main") + files, truncated, _, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted(names) @@ -146,7 +149,7 @@ def test_committed_and_staged_carriage_return_names_survive(tmp_path: Path) -> N (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") + files, truncated, _, _ = _changed_files(repo, base_branch="main") assert truncated is False assert sorted(files) == sorted([committed, staged]) @@ -300,7 +303,7 @@ def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): monkeypatch.setattr(acceptance_loop, "_run_git_paths", fake_run_git_paths) paths = acceptance_loop._git_untracked_paths(repo) - files, truncated, unrepresentable_count = _changed_files( + files, truncated, unrepresentable_count, full_count = _changed_files( repo, base_branch="main" ) displayed = [ @@ -314,6 +317,93 @@ def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): ] 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 From 83bee892dacb48c4448dfb17e534f25992b3a2f8 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:07:58 +0000 Subject: [PATCH 7/7] fix(issue-fix): expand untracked directories before path scans Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- .../capabilities/issue_fix/acceptance_loop.py | 7 ++- .../protocols/issue-fix-acceptance-loop-v0.md | 4 +- ...test_issue_fix_acceptance_changed_files.py | 55 ++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/loopx/capabilities/issue_fix/acceptance_loop.py b/loopx/capabilities/issue_fix/acceptance_loop.py index 90d881261b..f29ee48926 100644 --- a/loopx/capabilities/issue_fix/acceptance_loop.py +++ b/loopx/capabilities/issue_fix/acceptance_loop.py @@ -214,9 +214,12 @@ def _display_git_path(path: bytes) -> tuple[str, bool]: def _git_untracked_paths(workspace: Path) -> list[bytes]: - """Return the untracked byte pathnames exactly as Git recorded them.""" + """Return every untracked file, not Git's directory summary.""" - result = _run_git_paths(workspace, ["status", "--porcelain", "-z"]) + 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") 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 ae33ef4120..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 @@ -63,7 +63,9 @@ the artifact records `changed_files_unrepresentable_count`, and 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; readiness therefore does not depend on diff order. Reviewers must +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 index 0e7397c6f8..730beb9597 100644 --- a/tests/capabilities/test_issue_fix_acceptance_changed_files.py +++ b/tests/capabilities/test_issue_fix_acceptance_changed_files.py @@ -298,7 +298,11 @@ def test_untracked_byte_paths_keep_three_distinct_records( ) def fake_run_git_paths(workspace: Path, args: list[str], *, timeout: int = 10): - stdout = output if args == ["status", "--porcelain", "-z"] else b"" + 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) @@ -407,3 +411,52 @@ def run_packet() -> dict: 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