diff --git a/src/mygit.py b/src/mygit.py index 3c2a782..584eba5 100644 --- a/src/mygit.py +++ b/src/mygit.py @@ -118,12 +118,12 @@ class _RemoteLsResult: def fetch_states(requests: list[RemoteStateRequest]) -> Iterator[RemoteState]: with TemporaryDirectory() as repo_dir: repo = pygit2.init_repository(repo_dir) - remote_name_to_branches: dict[str, list[str]] = {} + remote_name_to_branches: dict[str, dict[str, None]] = {} for request in requests: name = sha1(request.url.encode("utf-8")).hexdigest() if not _has_remote(repo, name): repo.remotes.create(name, request.url) - remote_name_to_branches.setdefault(name, []).append(request.branch) + remote_name_to_branches.setdefault(name, {})[request.branch] = None def ls(remote: pygit2.Remote) -> _RemoteLsResult: try: diff --git a/src/snapjaw.py b/src/snapjaw.py index 9c59e50..52179e7 100644 --- a/src/snapjaw.py +++ b/src/snapjaw.py @@ -226,6 +226,42 @@ def install_addon( ) config.save() + installed_addon_keys = {addon_key(addon.name) for addon in addons_by_dir.values()} + ghost_candidates = [ + (key, addon) + for key, addon in config.addons_by_key.items() + if (addon.url, addon.branch) == (repo_url, repo.branch) + and addon_key(addon.name) not in installed_addon_keys + ] + if ghost_candidates: + repository_addon_keys = {addon_key(addon.name) for addon in toc.find_addons(repo.workdir, None)} + else: + repository_addon_keys = set() + + removed_ghost = False + for key, configured_addon in ghost_candidates: + if addon_key(configured_addon.name) in repository_addon_keys: + continue + + del config.addons_by_key[key] + removed_ghost = True + configured_addon_dir = os.path.join(addons_dir, configured_addon.name) + if os.path.isdir(configured_addon_dir): + if configured_addon.checksum is not None and signature.validate( + configured_addon_dir, configured_addon.checksum + ): + remove_addon_dir(configured_addon_dir) + logging.info(f'Removed stale addon "{configured_addon.name}"') + else: + logging.warning( + f'Stale addon folder "{configured_addon.name}" was left in place because it was modified' + ) + else: + logging.info(f'Removed stale addon "{configured_addon.name}" from config') + + if removed_ghost: + config.save() + logging.info("Done") @@ -261,18 +297,27 @@ def cmd_update(config: Config, args): addons = [get_addon_from_config(config, name) for name in args.names] else: addons = [] + errors_by_repo: dict[tuple[str, str], tuple[list[str], str]] = {} for state in get_addon_states(config, args.addons_dir): if state.error is not None: - print(f"Error: {state.addon}: {state.error}") + addon = config.addons_by_key.get(addon_key(state.addon)) + if addon is None: + print(f"Error: {state.addon}: {state.error}") + else: + names, _ = errors_by_repo.setdefault((addon.url, addon.branch), ([], state.error)) + names.append(state.addon) elif state.status == AddonStatus.Outdated: addons.append(config.addons_by_key[addon_key(state.addon)]) + for names, error in errors_by_repo.values(): + print(f"Error: {', '.join(names)}: {error}") if not addons: print("No addons to update found") return - for addon in addons: - install_addon(config, addon.url, addon.branch, args.addons_dir, args.expansion) + repos = {(addon.url, addon.branch): None for addon in addons} + for url, branch in repos: + install_addon(config, url, branch, args.addons_dir, args.expansion) def get_addon_from_config(config: Config, addon_name: str) -> ConfigAddon: @@ -362,7 +407,11 @@ def get_addon_states(config: Config, addons_dir: str) -> list[AddonState]: addon_key_to_state = {} total_addons = len(config.addons_by_key) processed = 0 - requests = [mygit.RemoteStateRequest(addon.url, addon.branch) for addon in config.addons_by_key.values()] + requests = [ + mygit.RemoteStateRequest(url, branch) + for url, branch_to_addons in url_to_branch_to_addons.items() + for branch in branch_to_addons + ] for state in mygit.fetch_states(requests): for addon in url_to_branch_to_addons[state.url][state.branch]: processed += 1 diff --git a/src/toc.py b/src/toc.py index f2a7606..af3866a 100644 --- a/src/toc.py +++ b/src/toc.py @@ -18,22 +18,31 @@ class _TocFile: game_version: int -def find_addons(dir_path: str, expansion: Expansion) -> Generator[Addon, None, None]: +def find_addons(dir_path: str, expansion: Expansion | None) -> Generator[Addon, None, None]: def sort_key(toc: _TocFile) -> int: return len(toc.path.parents) addon_paths = [] for toc_file in sorted(_find_toc_files(dir_path, expansion), key=sort_key): if all(parent not in addon_paths for parent in toc_file.path.parents): - yield Addon(name=toc_file.path.stem, path=str(toc_file.path.parent)) + addon_dir = toc_file.path.parent + name = toc_file.path.stem + if addon_dir != Path(dir_path) and any( + path.is_file() + and path.suffix.casefold() == ".toc" + and path.stem.casefold() == addon_dir.name.casefold() + for path in addon_dir.iterdir() + ): + name = addon_dir.name + yield Addon(name=name, path=str(toc_file.path.parent)) addon_paths.append(toc_file.path.parent) -def _find_toc_files(root_dir: str, expansion: Expansion) -> Generator[_TocFile, None, None]: +def _find_toc_files(root_dir: str, expansion: Expansion | None) -> Generator[_TocFile, None, None]: for path in Path(root_dir).rglob("*"): if path.is_file() and path.suffix.lower() == ".toc": game_version = _get_game_version(path) - if game_version is not None and _interface_matches(game_version, expansion): + if game_version is not None and (expansion is None or _interface_matches(game_version, expansion)): yield _TocFile(path, game_version) diff --git a/tests/test_mygit.py b/tests/test_mygit.py index df3a2a2..bcc74d8 100644 --- a/tests/test_mygit.py +++ b/tests/test_mygit.py @@ -147,6 +147,46 @@ def test_branch_not_found_in_refs(self, mock_remote, mock_pygit2_repo, fetch_sta assert len(states) == 0 + def test_duplicate_requests_yield_one_state(self, mock_remote, mock_pygit2_repo, fetch_states_patches): + """Repeated requests for one URL and branch are deduplicated.""" + url = "https://github.com/test/repo.git" + remote = mock_remote( + "abc123", + url, + refs=[SimpleNamespace(name="refs/heads/main", symref_target="", oid="deadbeef")], + ) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + requests = [RemoteStateRequest(url, "main"), RemoteStateRequest(url, "main")] + + with fetch_states_patches(): + states = list(fetch_states(requests)) + + assert [(state.url, state.branch) for state in states] == [(url, "main")] + remote.list_heads.assert_called_once_with() + + def test_two_branches_share_one_list_heads_call(self, mock_remote, mock_pygit2_repo, fetch_states_patches): + """Distinct ordered branches on one URL use one remote lookup.""" + url = "https://github.com/test/repo.git" + remote = mock_remote( + "abc123", + url, + refs=[ + SimpleNamespace(name="refs/heads/main", symref_target="", oid="maincommit"), + SimpleNamespace(name="refs/heads/release", symref_target="", oid="releasecommit"), + ], + ) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + requests = [RemoteStateRequest(url, "release"), RemoteStateRequest(url, "main")] + + with fetch_states_patches(): + states = list(fetch_states(requests)) + + assert [(state.branch, state.head_commit_hex) for state in states] == [ + ("release", "releasecommit"), + ("main", "maincommit"), + ] + remote.list_heads.assert_called_once_with() + class TestCloneInternalProcess: """Tests for _clone() - the subprocess worker function. diff --git a/tests/test_snapjaw_commands.py b/tests/test_snapjaw_commands.py index 89f175e..330a362 100644 --- a/tests/test_snapjaw_commands.py +++ b/tests/test_snapjaw_commands.py @@ -22,6 +22,7 @@ run_command, ) from toc import Addon +from toc import find_addons as real_find_addons class TestRunCommand: @@ -216,6 +217,265 @@ def test_existing_readme_not_overwritten(self, mock_install_env): # Addon's own readme is preserved, not overwritten by root readme assert (env.addons_dir / "MyAddon" / "README.txt").read_text() == "addon readme" + def test_ghost_with_valid_checksum_is_removed(self, mock_install_env, monkeypatch, make_addon, caplog): + """An addon absent from its repository is removed from config and disk when unmodified.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + ghost = make_addon(name="Ghost", url=repo_url, branch="master", checksum="old|2") + env.config.addons_by_key["ghost"] = ghost + ghost_dir = env.addons_dir / ghost.name + ghost_dir.mkdir() + (ghost_dir / "Ghost.toc").write_text("## Interface: 11200\n") + + addon_dir = env.repo_dir / "Current" + addon_dir.mkdir() + (addon_dir / "Current.toc").write_text("## Interface: 11200\n") + monkeypatch.setattr("snapjaw.signature.validate", lambda path, checksum: True) + caplog.set_level("INFO") + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert "ghost" not in env.config.addons_by_key + assert not ghost_dir.exists() + with open(env.config._loaded_from) as config_file: + saved_config = json.load(config_file) + assert "ghost" not in saved_config["addons_by_key"] + assert 'Removed stale addon "Ghost"' in caplog.text + + @pytest.mark.parametrize("checksum", ["old|2", None]) + def test_modified_ghost_folder_is_kept(self, mock_install_env, monkeypatch, make_addon, caplog, checksum): + """A ghost with an invalid or absent checksum leaves its folder on disk with a warning.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + ghost = make_addon(name="Ghost", url=repo_url, branch="master", checksum=checksum) + env.config.addons_by_key["ghost"] = ghost + ghost_dir = env.addons_dir / ghost.name + ghost_dir.mkdir() + (ghost_dir / "user-edit.lua").write_text("-- keep me") + + addon_dir = env.repo_dir / "Current" + addon_dir.mkdir() + (addon_dir / "Current.toc").write_text("## Interface: 11200\n") + validate = MagicMock(return_value=False) + monkeypatch.setattr("snapjaw.signature.validate", validate) + caplog.set_level("WARNING") + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert "ghost" not in env.config.addons_by_key + assert (ghost_dir / "user-edit.lua").exists() + assert 'Stale addon folder "Ghost" was left in place because it was modified' in caplog.text + assert validate.call_count == (1 if checksum is not None else 0) + + def test_missing_ghost_folder_removes_config_without_crashing( + self, mock_install_env, monkeypatch, make_addon, caplog + ): + """A missing ghost folder is dropped from config without checksum validation or an error.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + ghost = make_addon(name="MissingGhost", url=repo_url, branch="master", checksum="old|2") + env.config.addons_by_key["missingghost"] = ghost + + addon_dir = env.repo_dir / "Current" + addon_dir.mkdir() + (addon_dir / "Current.toc").write_text("## Interface: 11200\n") + validate = MagicMock() + monkeypatch.setattr("snapjaw.signature.validate", validate) + caplog.set_level("INFO") + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert "missingghost" not in env.config.addons_by_key + assert not (env.addons_dir / ghost.name).exists() + validate.assert_not_called() + assert 'Removed stale addon "MissingGhost" from config' in caplog.text + assert 'Stale addon folder "MissingGhost" was left in place because it was modified' not in caplog.text + + def test_repository_scan_is_skipped_without_ghost_candidates(self, mock_install_env, monkeypatch, make_addon): + """An install with no ghost candidates scans the repository only once.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + current = make_addon(name="Current", url=repo_url, branch="master") + env.config.addons_by_key["current"] = current + + addon_dir = env.repo_dir / current.name + addon_dir.mkdir() + (addon_dir / "Current.toc").write_text("## Interface: 11200\n") + find_addons = MagicMock(wraps=real_find_addons) + monkeypatch.setattr("snapjaw.toc.find_addons", find_addons) + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + find_addons.assert_called_once_with(env.repo_info.workdir, gameversion.Expansion.Vanilla) + + def test_repository_scan_runs_with_ghost_candidate(self, mock_install_env, monkeypatch, make_addon): + """A ghost candidate triggers the additional unfiltered repository scan.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + ghost = make_addon(name="Ghost", url=repo_url, branch="master") + env.config.addons_by_key["ghost"] = ghost + + addon_dir = env.repo_dir / "Current" + addon_dir.mkdir() + (addon_dir / "Current.toc").write_text("## Interface: 11200\n") + find_addons = MagicMock(wraps=real_find_addons) + monkeypatch.setattr("snapjaw.toc.find_addons", find_addons) + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert find_addons.call_count == 2 + + def test_expansion_filtered_addon_is_not_a_ghost(self, mock_install_env, monkeypatch, make_addon): + """An addon excluded by the active expansion survives when it still exists in the repository.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + wotlk = make_addon(name="WotlkOnly", url=repo_url, branch="master", checksum="old|2") + env.config.addons_by_key["wotlkonly"] = wotlk + installed_wotlk_dir = env.addons_dir / wotlk.name + installed_wotlk_dir.mkdir() + (installed_wotlk_dir / "WotlkOnly.toc").write_text("## Interface: 30300\n") + + vanilla_dir = env.repo_dir / "VanillaOnly" + vanilla_dir.mkdir() + (vanilla_dir / "VanillaOnly.toc").write_text("## Interface: 11200\n") + repo_wotlk_dir = env.repo_dir / "WotlkOnly" + repo_wotlk_dir.mkdir() + (repo_wotlk_dir / "WotlkOnly.toc").write_text("## Interface: 30300\n") + validate = MagicMock() + monkeypatch.setattr("snapjaw.signature.validate", validate) + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert env.config.addons_by_key["wotlkonly"] is wotlk + assert installed_wotlk_dir.exists() + validate.assert_not_called() + + def test_suffixed_only_addon_is_not_a_ghost(self, mock_install_env, make_addon): + """A configured suffixed-only addon survives the unfiltered ghost scan untouched.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + suffixed = make_addon(name="Suffixed_Wrath", url=repo_url, branch="master") + env.config.addons_by_key["suffixed_wrath"] = suffixed + installed_suffixed_dir = env.addons_dir / suffixed.name + installed_suffixed_dir.mkdir() + (installed_suffixed_dir / "keep.txt").write_text("untouched") + + vanilla_dir = env.repo_dir / "VanillaOnly" + vanilla_dir.mkdir() + (vanilla_dir / "VanillaOnly.toc").write_text("## Interface: 11200\n") + suffixed_dir = env.repo_dir / "Suffixed" + suffixed_dir.mkdir() + (suffixed_dir / "Suffixed_Wrath.toc").write_text("## Interface: 30300\n") + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert env.config.addons_by_key["suffixed_wrath"] is suffixed + assert (installed_suffixed_dir / "keep.txt").read_text() == "untouched" + + def test_ghost_cleanup_ignores_other_repo_and_branch(self, mock_install_env, monkeypatch, make_addon): + """Absent entries belonging to a different URL or branch are untouched.""" + with mock_install_env() as env: + repo_url = "https://github.com/test/repo.git" + other_url = make_addon( + name="OtherUrl", + url="https://github.com/test/other.git", + branch="master", + checksum="old|2", + ) + other_branch = make_addon(name="OtherBranch", url=repo_url, branch="release", checksum="old|2") + env.config.addons_by_key.update({"otherurl": other_url, "otherbranch": other_branch}) + for addon in (other_url, other_branch): + addon_dir = env.addons_dir / addon.name + addon_dir.mkdir() + (addon_dir / f"{addon.name}.toc").write_text("## Interface: 11200\n") + + current_dir = env.repo_dir / "Current" + current_dir.mkdir() + (current_dir / "Current.toc").write_text("## Interface: 11200\n") + validate = MagicMock() + monkeypatch.setattr("snapjaw.signature.validate", validate) + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + assert env.config.addons_by_key["otherurl"] is other_url + assert env.config.addons_by_key["otherbranch"] is other_branch + assert (env.addons_dir / other_url.name).exists() + assert (env.addons_dir / other_branch.name).exists() + validate.assert_not_called() + + def test_multi_addon_repo_clones_once_and_installs_every_addon(self, mock_install_env, monkeypatch): + """One clone discovers, installs, and records every addon in a shared repository.""" + with mock_install_env() as env: + for name in ("First", "Second"): + addon_dir = env.repo_dir / name + addon_dir.mkdir() + (addon_dir / f"{name}.toc").write_text("## Interface: 11200\n") + (addon_dir / "init.lua").write_text(f"-- {name}") + clone = MagicMock(return_value=env.repo_info) + monkeypatch.setattr("snapjaw.mygit.clone", clone) + repo_url = "https://github.com/test/repo.git" + + install_addon( + env.config, + repo_url, + "master", + str(env.addons_dir), + gameversion.Expansion.Vanilla, + ) + + clone.assert_called_once() + assert set(env.config.addons_by_key) == {"first", "second"} + assert (env.addons_dir / "First" / "init.lua").read_text() == "-- First" + assert (env.addons_dir / "Second" / "init.lua").read_text() == "-- Second" + class TestCmdRemove: """Tests for cmd_remove() command.""" @@ -363,9 +623,149 @@ def test_update_with_error_prints_error(self, tmp_path, monkeypatch, make_config args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) cmd_update(config, args) out = capsys.readouterr().out - assert "Error: MyAddon: timeout" in out + assert [line for line in out.splitlines() if line.startswith("Error:")] == ["Error: MyAddon: timeout"] assert "No addons to update found" in out + def test_update_collapses_shared_repo_errors(self, tmp_path, monkeypatch, make_config, fixed_now, capsys): + """One broken shared repository prints one line naming every addon.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("SharedOne", AddonStatus.Error, "authentication failed", fixed_now, fixed_now), + AddonState("SharedTwo", AddonStatus.Error, "authentication failed", fixed_now, fixed_now), + AddonState("SharedThree", AddonStatus.Error, "authentication failed", fixed_now, fixed_now), + ], + ) + config = make_config("SharedOne", "SharedTwo", "SharedThree") + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + + cmd_update(config, args) + + error_lines = [line for line in capsys.readouterr().out.splitlines() if line.startswith("Error:")] + assert error_lines == ["Error: SharedOne, SharedTwo, SharedThree: authentication failed"] + + def test_update_keeps_different_repo_errors_separate(self, tmp_path, monkeypatch, make_addon, fixed_now, capsys): + """Broken repositories print separate error lines in encounter order.""" + first = make_addon(name="First", url="https://github.com/test/first.git") + second = make_addon(name="Second", url="https://github.com/test/second.git") + config = Config(addons_by_key={"first": first, "second": second}) + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("First", AddonStatus.Error, "first error", fixed_now, fixed_now), + AddonState("Second", AddonStatus.Error, "second error", fixed_now, fixed_now), + ], + ) + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + + cmd_update(config, args) + + error_lines = [line for line in capsys.readouterr().out.splitlines() if line.startswith("Error:")] + assert error_lines == ["Error: First: first error", "Error: Second: second error"] + + def test_update_error_missing_from_config_does_not_crash(self, tmp_path, monkeypatch, fixed_now, capsys): + """An unexpected error state missing from config keeps its old format.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("Unexpected", AddonStatus.Error, "timeout", fixed_now, fixed_now), + ], + ) + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + + cmd_update(Config(addons_by_key={}), args) + + error_lines = [line for line in capsys.readouterr().out.splitlines() if line.startswith("Error:")] + assert error_lines == ["Error: Unexpected: timeout"] + + def test_update_all_shared_repo_installs_once(self, tmp_path, monkeypatch, make_config, fixed_now): + """Automatic update installs one shared repository only once.""" + calls = [] + monkeypatch.setattr( + "snapjaw.install_addon", + lambda config, url, branch, d, expansion: calls.append((url, branch)), + ) + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("SharedOne", AddonStatus.Outdated, None, fixed_now, fixed_now), + AddonState("SharedTwo", AddonStatus.Outdated, None, fixed_now, fixed_now), + ], + ) + + config = make_config("SharedOne", "SharedTwo") + args = SimpleNamespace( + names=[], + addons_dir=str(tmp_path), + expansion=gameversion.Expansion.Vanilla, + ) + + cmd_update(config, args) + + assert calls == [("https://github.com/test/test.git", "master")] + + def test_update_explicit_shared_names_and_repeat_installs_once(self, tmp_path, monkeypatch, make_config): + """Explicit shared addon names, including repeats, install once.""" + calls = [] + monkeypatch.setattr( + "snapjaw.install_addon", + lambda config, url, branch, d, expansion: calls.append((url, branch)), + ) + + config = make_config("SharedOne", "SharedTwo") + args = SimpleNamespace( + names=["SharedTwo", "SharedOne", "SharedTwo"], + addons_dir=str(tmp_path), + expansion=gameversion.Expansion.Vanilla, + ) + + cmd_update(config, args) + + assert calls == [("https://github.com/test/test.git", "master")] + + def test_update_same_url_different_branches_installs_twice(self, tmp_path, monkeypatch, make_addon): + """The same remote URL on different branches remains distinct.""" + calls = [] + monkeypatch.setattr( + "snapjaw.install_addon", + lambda config, url, branch, d, expansion: calls.append((url, branch)), + ) + url = "https://github.com/test/shared.git" + main = make_addon(name="MainAddon", url=url, branch="main") + release = make_addon(name="ReleaseAddon", url=url, branch="release") + config = Config(addons_by_key={"mainaddon": main, "releaseaddon": release}) + args = SimpleNamespace( + names=["MainAddon", "ReleaseAddon"], + addons_dir=str(tmp_path), + expansion=gameversion.Expansion.Vanilla, + ) + + cmd_update(config, args) + + assert calls == [(url, "main"), (url, "release")] + + def test_update_same_branch_different_urls_installs_twice(self, tmp_path, monkeypatch, make_addon): + """The same branch name on different remote URLs remains distinct.""" + calls = [] + monkeypatch.setattr( + "snapjaw.install_addon", + lambda config, url, branch, d, expansion: calls.append((url, branch)), + ) + first_url = "https://github.com/test/first.git" + second_url = "https://github.com/test/second.git" + first = make_addon(name="FirstAddon", url=first_url, branch="main") + second = make_addon(name="SecondAddon", url=second_url, branch="main") + config = Config(addons_by_key={"firstaddon": first, "secondaddon": second}) + args = SimpleNamespace( + names=["FirstAddon", "SecondAddon"], + addons_dir=str(tmp_path), + expansion=gameversion.Expansion.Vanilla, + ) + + cmd_update(config, args) + + assert calls == [(first_url, "main"), (second_url, "main")] + class TestCmdStatus: """Tests for cmd_status() command.""" diff --git a/tests/test_snapjaw_states.py b/tests/test_snapjaw_states.py index f8366b0..24f20aa 100644 --- a/tests/test_snapjaw_states.py +++ b/tests/test_snapjaw_states.py @@ -1,6 +1,6 @@ """Tests for snapjaw addon state detection (get_addon_states).""" -from mygit import RemoteState +from mygit import RemoteState, RemoteStateRequest from snapjaw import AddonStatus, Config, get_addon_states @@ -219,3 +219,82 @@ def mock_fetch_states(reqs): assert status_by_name["OutdatedAddon"] == AddonStatus.Outdated assert status_by_name["UntrackedAddon"] == AddonStatus.Untracked capsys.readouterr() + + def test_shared_repo_fetches_once_and_progress_matches_addon_count(self, tmp_path, monkeypatch, make_addon, capsys): + """A shared branch is fetched once while every addon gets a state.""" + url = "https://github.com/test/shared.git" + addons = [ + make_addon(name="SharedOne", url=url, branch="main"), + make_addon(name="SharedTwo", url=url, branch="main"), + ] + config = Config(addons_by_key={addon.name.lower(): addon for addon in addons}) + captured_requests = [] + + def mock_fetch_states(requests): + captured_requests.extend(requests) + for request in requests: + yield RemoteState(request.url, request.branch, "newcommit", None) + + monkeypatch.setattr("snapjaw.mygit.fetch_states", mock_fetch_states) + + states = get_addon_states(config, str(tmp_path)) + + assert captured_requests == [RemoteStateRequest(url, "main")] + assert {state.addon for state in states} == {"SharedOne", "SharedTwo"} + progress = capsys.readouterr().out + assert progress.endswith("2/2\r\n") + assert "3/2" not in progress + + def test_same_url_different_branches_fetches_each_pair_once(self, tmp_path, monkeypatch, make_addon, capsys): + """Branches sharing a URL remain distinct requests in config order.""" + url = "https://github.com/test/shared.git" + addons = [ + make_addon(name="MainAddon", url=url, branch="main"), + make_addon(name="ReleaseAddon", url=url, branch="release"), + ] + config = Config(addons_by_key={addon.name.lower(): addon for addon in addons}) + captured_requests = [] + + def mock_fetch_states(requests): + captured_requests.extend(requests) + for request in requests: + yield RemoteState(request.url, request.branch, "newcommit", None) + + monkeypatch.setattr("snapjaw.mygit.fetch_states", mock_fetch_states) + + states = get_addon_states(config, str(tmp_path)) + + assert captured_requests == [ + RemoteStateRequest(url, "main"), + RemoteStateRequest(url, "release"), + ] + assert {state.addon for state in states} == {"MainAddon", "ReleaseAddon"} + assert capsys.readouterr().out.endswith("2/2\r\n") + + def test_shared_repo_error_fans_out_without_inflating_progress(self, tmp_path, monkeypatch, make_addon, capsys): + """One remote error reaches every shared addon exactly once.""" + url = "https://github.com/test/shared.git" + addons = [ + make_addon(name="SharedOne", url=url, branch="main"), + make_addon(name="SharedTwo", url=url, branch="main"), + ] + config = Config(addons_by_key={addon.name.lower(): addon for addon in addons}) + captured_requests = [] + + def mock_fetch_states(requests): + captured_requests.extend(requests) + for request in requests: + yield RemoteState(request.url, request.branch, None, "authentication failed") + + monkeypatch.setattr("snapjaw.mygit.fetch_states", mock_fetch_states) + + states = get_addon_states(config, str(tmp_path)) + + assert captured_requests == [RemoteStateRequest(url, "main")] + assert [(state.addon, state.error) for state in states] == [ + ("SharedOne", "authentication failed"), + ("SharedTwo", "authentication failed"), + ] + progress = capsys.readouterr().out + assert progress.endswith("2/2\r\n") + assert "3/2" not in progress diff --git a/tests/test_toc.py b/tests/test_toc.py index 8bafe6d..9351331 100644 --- a/tests/test_toc.py +++ b/tests/test_toc.py @@ -44,6 +44,92 @@ def test_multiple_addons_different_versions(self, make_toc_addon, tmp_path): assert len(addons) == 1 assert addons[0].name == "VanillaAddon" + @pytest.mark.parametrize( + "folder,toc_versions,expansion,expected_name", + [ + ("DBM-Core", [("DBM-Core", 30300), ("DBM-Core-WOTLKC", 30300)], Expansion.Wotlk, "DBM-Core"), + ("DBM-Core", [("DBM-Core", 11200), ("DBM-Core-WOTLKC", 30300)], Expansion.Wotlk, "DBM-Core"), + ("Foo", [("Foo", 11200), ("Foo_Wrath", 30300)], Expansion.Wotlk, "Foo"), + ], + ) + def test_addon_name_comes_from_folder(self, tmp_path, folder, toc_versions, expansion, expected_name): + """Multi-TOC addons use their folder name, independent of the matching TOC stem.""" + addon_dir = tmp_path / folder + addon_dir.mkdir() + for toc_name, version in toc_versions: + (addon_dir / f"{toc_name}.toc").write_text(f"## Interface: {version}\n") + + addons = list(find_addons(str(tmp_path), expansion)) + + assert len(addons) == 1 + assert addons[0].name == expected_name + + def test_suffixed_only_toc_name_comes_from_stem(self, tmp_path): + """A folder without its base TOC falls back to the compatible TOC stem.""" + addon_dir = tmp_path / "Foo" + addon_dir.mkdir() + (addon_dir / "Foo_Wrath.toc").write_text("## Interface: 30300\n") + + addons = list(find_addons(str(tmp_path), Expansion.Wotlk)) + + assert len(addons) == 1 + assert addons[0].name == "Foo_Wrath" + + def test_folder_toc_name_match_is_case_insensitive(self, tmp_path): + """A differently cased base TOC still preserves the folder name.""" + addon_dir = tmp_path / "Foo" + addon_dir.mkdir() + (addon_dir / "foo.toc").write_text("## Interface: 11200\n") + + addons = list(find_addons(str(tmp_path), Expansion.Vanilla)) + + assert len(addons) == 1 + assert addons[0].name == "Foo" + + def test_root_level_toc_name_comes_from_stem(self, tmp_path): + """A repository-root addon keeps using the TOC stem instead of the clone directory name.""" + (tmp_path / "Root.toc").write_text("## Interface: 11200\n") + + addons = list(find_addons(str(tmp_path), Expansion.Vanilla)) + + assert len(addons) == 1 + assert addons[0].name == "Root" + + def test_unfiltered_scan_finds_every_interface_version_once_per_folder(self, tmp_path): + """An unfiltered scan finds all valid Interface versions and deduplicates each folder.""" + mixed_dir = tmp_path / "Mixed" + mixed_dir.mkdir() + (mixed_dir / "Mixed.toc").write_text("## Interface: 11200\n") + (mixed_dir / "Mixed_Wrath.toc").write_text("## Interface: 30300\n") + + vanilla_dir = tmp_path / "VanillaOnly" + vanilla_dir.mkdir() + (vanilla_dir / "VanillaOnly.toc").write_text("## Interface: 11200\n") + + wrath_dir = tmp_path / "WrathOnly" + wrath_dir.mkdir() + (wrath_dir / "WrathOnly.toc").write_text("## Interface: 30300\n") + + no_header_dir = tmp_path / "NoHeader" + no_header_dir.mkdir() + (no_header_dir / "NoHeader.toc").write_text("## Title: NoHeader\n") + + addons = list(find_addons(str(tmp_path), None)) + + assert {addon.name for addon in addons} == {"Mixed", "VanillaOnly", "WrathOnly"} + assert len(addons) == 3 + + def test_unfiltered_scan_uses_suffixed_only_toc_stem(self, tmp_path): + """An unfiltered scan also falls back when a folder has no base TOC.""" + addon_dir = tmp_path / "Foo" + addon_dir.mkdir() + (addon_dir / "Foo_Wrath.toc").write_text("## Interface: 30300\n") + + addons = list(find_addons(str(tmp_path), None)) + + assert len(addons) == 1 + assert addons[0].name == "Foo_Wrath" + def test_no_interface_header(self, tmp_path): """Addon without Interface header is skipped.""" addon_dir = tmp_path / "NoHeader"