diff --git a/strix/interface/main.py b/strix/interface/main.py index 373b872f..b19ec78e 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -37,6 +37,7 @@ from strix.interface.utils import ( check_docker_connection, clone_repository, collect_local_sources, + dedupe_local_targets, find_oversized_local_targets, generate_run_name, image_exists, @@ -507,6 +508,8 @@ Examples: except ValueError as e: parser.error(str(e)) + args.targets_info = dedupe_local_targets(args.targets_info) + assign_workspace_subdirs(args.targets_info) rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) diff --git a/strix/interface/utils.py b/strix/interface/utils.py index da1c7081..fbde4c2c 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1290,6 +1290,31 @@ def build_mount_targets_info(mount_paths: list[str]) -> list[dict[str, Any]]: return targets_info +def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Collapse local_code targets that resolve to the same path. + + When a directory is supplied both as a copied ``--target`` and via + ``--mount`` (or as duplicate values of either), keep one entry and prefer + the bind-mounted one — so the same tree is never both streamed in and + mounted. Order is preserved; non-local targets pass through untouched. + """ + result: list[dict[str, Any]] = [] + index_by_path: dict[str, int] = {} + for target in targets_info: + details = target.get("details") or {} + path = details.get("target_path") + if target.get("type") != "local_code" or not path: + result.append(target) + continue + existing = index_by_path.get(path) + if existing is None: + index_by_path[path] = len(result) + result.append(target) + elif details.get("mount") and not (result[existing].get("details") or {}).get("mount"): + result[existing] = target # bind mount supersedes the copied entry + return result + + def _is_localhost_host(host: str) -> bool: host_lower = host.lower().strip("[]") diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 4f7f600e..2b0cd2a6 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from strix.interface.utils import ( build_mount_targets_info, collect_local_sources, + dedupe_local_targets, directory_size_bytes, find_oversized_local_targets, ) @@ -122,3 +123,34 @@ def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None: # and silently bind-mount it into the sandbox. with pytest.raises(ValueError, match="must not be empty"): build_mount_targets_info([empty]) + + +def test_dedupe_keeps_distinct_targets_in_order() -> None: + targets = [ + _local_target("/a"), + {"type": "web_application", "details": {"target_url": "https://x"}}, + _local_target("/b", mount=True), + ] + assert dedupe_local_targets(targets) == targets + + +def test_dedupe_mount_supersedes_copied_same_path() -> None: + copied = _local_target("/repo") + mounted = _local_target("/repo", mount=True) + + # Copied first, then mounted: the single surviving entry is the mount. + result = dedupe_local_targets([copied, mounted]) + assert len(result) == 1 + assert result[0]["details"]["mount"] is True + + # Order-independent: mounted first, copied second also yields the mount. + result_rev = dedupe_local_targets([mounted, copied]) + assert len(result_rev) == 1 + assert result_rev[0]["details"]["mount"] is True + + +def test_dedupe_collapses_duplicate_mounts() -> None: + result = dedupe_local_targets( + [_local_target("/repo", mount=True), _local_target("/repo", mount=True)] + ) + assert len(result) == 1