Files
ltdrdata-ComfyUI-Manager/tests/common/test_git_helper.py
T
Dr.Lt.Data c53ee36f30 v4: Find a free backup branch name in the legacy core (#3494)
get_backup_branch_name() lists branches through list_heads() since
49e205ac. The legacy core passes GitPython's git.Repo, which has no
such method; the error was swallowed and the name was returned without
a collision check, so creating the backup branch failed when one of
the same second existed.

- Read the branches from heads when the repository has no list_heads()
- Repair the tests: the fake repository still exposed heads, so the
  collision cases exercised the error fallback and failed
- Run the behaviour tests against both copies of the function, add a
  real-repository test as each core opens it, and freeze the clock
2026-11-12 09:00:19 +09:00

222 lines
8.5 KiB
Python

"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
execution, (3) get_backup_branch_name behavioural correctness, for the copy in
git_helper.py and the copy in timestamp_utils.py that both cores call, (4) the name
each core gets for a real repository.
"""
import ast
import importlib.util
import pathlib
import subprocess
import sys
import textwrap
import pytest
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
def _load_common_module(name):
"""Load a standalone module of comfyui_manager/common without importing the package."""
spec = importlib.util.spec_from_file_location(name, _GIT_HELPER_PATH.with_name(f"{name}.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# ---------------------------------------------------------------------------
# 1. No comfyui_manager imports (AST check — no execution)
# ---------------------------------------------------------------------------
def test_no_comfyui_manager_import():
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
# ---------------------------------------------------------------------------
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
# ---------------------------------------------------------------------------
def test_standalone_subprocess_can_load(tmp_path):
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
(tmp_path / "folder_paths.py").touch()
# Run git_helper.py in a clean subprocess with stripped sys.path.
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
# so we set argv to --check with a dummy path to avoid IndexError, then
# catch the SystemExit to verify the function loaded successfully.
script = textwrap.dedent(f"""\
import sys, os
sys.path = [p for p in sys.path
if "comfyui_manager" not in p and "comfyui-manager" not in p]
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
import importlib.util
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
mod = importlib.util.module_from_spec(spec)
# Intercept sys.exit from module-level argv dispatcher
real_exit = sys.exit
exit_code = [None]
def fake_exit(code=0):
exit_code[0] = code
raise SystemExit(code)
sys.exit = fake_exit
try:
spec.loader.exec_module(mod)
except SystemExit:
pass
finally:
sys.exit = real_exit
# The function must be defined regardless of argv dispatcher outcome
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
name = mod.get_backup_branch_name()
assert name.startswith("backup_"), f"Bad name: {{name}}"
print(f"OK: {{name}}")
""")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "OK: backup_" in result.stdout
# ---------------------------------------------------------------------------
# 3. get_backup_branch_name behavioural tests, for both copies of the function
# ---------------------------------------------------------------------------
@pytest.fixture(params=["git_helper", "timestamp_utils"])
def _get_backup_branch_name(request, monkeypatch):
"""Return get_backup_branch_name from git_helper.py or from timestamp_utils.py."""
import time
# Branch setup and the function call must use the same second.
strftime = time.strftime
fixed_time = time.struct_time((2026, 9, 17, 12, 0, 0, 3, 260, -1))
monkeypatch.setattr(time, "strftime", lambda fmt, *args: strftime(fmt, args[0] if args else fixed_time))
if request.param == "timestamp_utils":
timestamp_utils = _load_common_module("timestamp_utils")
monkeypatch.setattr(timestamp_utils, "get_timestamp_for_filename", lambda: time.strftime("%Y%m%d_%H%M%S"))
return timestamp_utils.get_backup_branch_name
# git_helper.py runs its command line on import, so the function is compiled from its source.
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
func_source = ast.get_source_segment(source, node)
assert func_source is not None
ns = {}
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
return ns["get_backup_branch_name"]
pytest.fail("get_backup_branch_name not found in git_helper.py")
class _FakeHead:
def __init__(self, name):
self.name = name
class _FakeRepo:
def __init__(self, branch_names):
self._heads = [_FakeHead(n) for n in branch_names]
def list_heads(self):
return self._heads
def test_basic_name_format(_get_backup_branch_name):
import time
assert _get_backup_branch_name() == f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
def test_no_collision(_get_backup_branch_name):
import time
repo = _FakeRepo(["main", "dev"])
assert _get_backup_branch_name(repo) == f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
def test_single_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo(["main", f"backup_{ts}"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_1"
def test_multi_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_3"
def test_repo_list_heads_exception_returns_base(_get_backup_branch_name):
"""When listing branches fails, return the timestamp without a suffix."""
import time
class _BrokenRepo:
def list_heads(self):
raise RuntimeError("simulated git error")
name = _get_backup_branch_name(_BrokenRepo())
assert name == f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
"""When all 99 suffixes are taken, fall back to UUID."""
import time
ts = time.strftime("%Y%m%d_%H%M%S")
# All names from backup_TS through backup_TS_99 are taken
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
repo = _FakeRepo(taken)
name = _get_backup_branch_name(repo)
assert name.startswith(f"backup_{ts}_")
# UUID suffix is 6 hex chars
suffix = name[len(f"backup_{ts}_"):]
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"
# ---------------------------------------------------------------------------
# 4. The name each core gets for a real repository
# ---------------------------------------------------------------------------
def _open_as_legacy_core(path):
"""The legacy core opens GitPython's git.Repo, which has `heads` and no `list_heads()`."""
import git
return git.Repo(path)
def _open_as_glob_core(path):
"""The glob core opens the repository through git_compat."""
return _load_common_module("git_compat").open_repo(str(path))
@pytest.mark.parametrize("open_repo", [_open_as_legacy_core, _open_as_glob_core], ids=["legacy core", "glob core"])
def test_real_repository_with_a_backup_from_the_same_second(open_repo, tmp_path, monkeypatch):
"""A backup branch of the same second exists, so the next free name is returned."""
git = pytest.importorskip("git")
timestamp_utils = _load_common_module("timestamp_utils")
monkeypatch.setattr(timestamp_utils, "get_timestamp_for_filename", lambda: "20260917_120000")
with git.Repo.init(tmp_path) as repo:
repo.index.commit("initial")
repo.create_head("backup_20260917_120000")
with open_repo(tmp_path) as repo:
assert timestamp_utils.get_backup_branch_name(repo) == "backup_20260917_120000_1"