From 5c57940ac11f2d2bf26d5a80ac90f882c48154e4 Mon Sep 17 00:00:00 2001 From: Samir Hanna Verza <123646616+samirhvbr@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:23:11 -0300 Subject: [PATCH] fix(cloud): deduplicate conflicted Git source paths Fixes #1629. Keep the raw enumeration bound while packaging each working-tree path once. --- strix/interface/cloud/source_upload.py | 6 ++ tests/test_cloud_source_upload.py | 87 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/strix/interface/cloud/source_upload.py b/strix/interface/cloud/source_upload.py index 780a84569..9a57a6552 100644 --- a/strix/interface/cloud/source_upload.py +++ b/strix/interface/cloud/source_upload.py @@ -393,6 +393,7 @@ def _git_candidate_paths(git: str, git_root: Path, source: Path) -> Iterator[Pat assert process.stdout is not None buffer = b"" count = 0 + seen: set[Path] = set() try: while chunk := process.stdout.read(64 * 1024): buffer += chunk @@ -404,6 +405,11 @@ def _git_candidate_paths(git: str, git_root: Path, source: Path) -> Iterator[Pat continue count += 1 _check_candidate_limit(count) + # An unmerged index can list a path once per stage. Package the + # working-tree file once, while still bounding every Git record. + if relative in seen: + continue + seen.add(relative) yield relative if buffer: raise http.CloudError("Git returned a malformed source file manifest.") diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py index dcd40baa8..066fe064d 100644 --- a/tests/test_cloud_source_upload.py +++ b/tests/test_cloud_source_upload.py @@ -56,6 +56,93 @@ def _git_source(tmp_path: Path) -> Path: return tmp_path +def _git_source_with_conflict(tmp_path: Path, subdirectory: str = "") -> Path: + git = shutil.which("git") + assert git is not None + subprocess.run([git, "init", "-q", str(tmp_path)], check=True) # noqa: S603 + source = tmp_path / subdirectory + source.mkdir(exist_ok=True) + (source / "tracked.py").write_bytes(b"tracked working-tree content\n") + (source / "untracked.py").write_bytes(b"untracked content\n") + (source / "conflict.py").write_bytes(b"current working-tree conflict content\n") + (source / "ignored.log").write_bytes(b"ignored\n") + (tmp_path / ".gitignore").write_text("ignored.log\n", encoding="utf-8") + subprocess.run( # noqa: S603 + [git, "-C", str(tmp_path), "add", (source / "tracked.py").relative_to(tmp_path).as_posix()], + check=True, + ) + entries: list[str] = [] + for stage, content in enumerate(("base\n", "ours\n", "theirs\n"), start=1): + blob = subprocess.run( # noqa: S603 + [git, "-C", str(tmp_path), "hash-object", "-w", "--stdin"], + input=content, + text=True, + capture_output=True, + check=True, + ).stdout.strip() + relative = (source / "conflict.py").relative_to(tmp_path).as_posix() + entries.append(f"100644 {blob} {stage}\t{relative}\n") + subprocess.run( # noqa: S603 + [git, "-C", str(tmp_path), "update-index", "--index-info"], + input="".join(entries), + text=True, + check=True, + ) + if subdirectory: + (tmp_path / "outside.py").write_bytes(b"outside selected source\n") + return source + + +@pytest.mark.parametrize("subdirectory", ["", "src"]) +def test_source_archive_deduplicates_unmerged_git_paths(tmp_path: Path, subdirectory: str) -> None: + source = _git_source_with_conflict(tmp_path, subdirectory) + expected = { + name: (source / name).read_bytes() for name in ("conflict.py", "tracked.py", "untracked.py") + } + + bundle = source_upload.prepare_source( + str(source), + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + ) + try: + assert [item.archive_name for item in bundle.manifest.files] == sorted(expected) + assert bundle.manifest.total_bytes == sum(map(len, expected.values())) + with zipfile.ZipFile(bundle.archive_path) as archive: + assert archive.namelist() == sorted(expected) + assert {name: archive.read(name) for name in archive.namelist()} == expected + finally: + source_upload.remove_bundle(bundle) + + +@pytest.mark.parametrize("limit", ["files", "bytes"]) +def test_unmerged_git_paths_do_not_inflate_selection_limits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, limit: str +) -> None: + source = _git_source_with_conflict(tmp_path) + if limit == "files": + monkeypatch.setattr(source_upload, "MAX_FILES", 3) + else: + actual_bytes = sum(path.stat().st_size for path in source.glob("*.py")) + monkeypatch.setattr(source_upload, "MAX_TOTAL_BYTES", actual_bytes) + + manifest = source_upload.select_source(source) + + assert len(manifest.files) == 3 + + +def test_unmerged_git_entries_still_count_towards_candidate_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = _git_source_with_conflict(tmp_path) + monkeypatch.setattr(source_upload, "MAX_CANDIDATE_PATHS", 4) + + with pytest.raises(http.CloudError, match="source enumeration exceeded"): + source_upload.select_source(source) + + def test_source_defaults_are_private_and_git_aware(tmp_path: Path) -> None: source = _git_source(tmp_path) (source / ".hidden.py").write_text("hidden\n", encoding="utf-8")