mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-03 07:51:16 -03:00
fix(update): preserve user data dirs during Git-based update via git clean -e excludes
git clean -fd in _perform_git_update deleted untracked, non-ignored directories (wildcards, stats, backups, civitai, caches, logs) during portable-mode updates, since released tags do not list them in .gitignore. Add -e excludes for all user-managed paths to both nightly and stable update branches. Add regression tests for both paths.
This commit is contained in:
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||||
|
|
||||||
|
# User-managed directories that live inside the plugin folder (portable
|
||||||
|
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||||
|
# otherwise delete them because they are untracked and, in released tags,
|
||||||
|
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||||
|
# regardless of whether it is ignored.
|
||||||
|
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_excludes() -> List[str]:
|
||||||
|
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||||
|
excludes: List[str] = []
|
||||||
|
for name in _PRESERVE_DIRS:
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(name)
|
||||||
|
# For directories, also exclude nested matches explicitly
|
||||||
|
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||||
|
# contents under all git versions as defense-in-depth).
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(f'{name}/**')
|
||||||
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
|||||||
)
|
)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open the Git repository
|
# Open the Git repository
|
||||||
repo = git.Repo(plugin_root)
|
repo = git.Repo(plugin_root)
|
||||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
|||||||
if nightly:
|
if nightly:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Switch to main branch and pull latest
|
# Switch to main branch and pull latest
|
||||||
main_branch = 'main'
|
main_branch = 'main'
|
||||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
|||||||
else:
|
else:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Get latest release tag
|
# Get latest release tag
|
||||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||||
|
|||||||
@@ -59,3 +59,180 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo
|
|||||||
assert changelog == []
|
assert changelog == []
|
||||||
assert "Unable to reach GitHub for nightly version" in caplog.text
|
assert "Unable to reach GitHub for nightly version" in caplog.text
|
||||||
assert "Traceback" not in caplog.text
|
assert "Traceback" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_excludes_covers_user_data_dirs():
|
||||||
|
"""git clean must receive -e excludes for every user-managed dir."""
|
||||||
|
excludes = update_routes._clean_excludes()
|
||||||
|
assert "-e" in excludes # at least one exclude flag present
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in excludes
|
||||||
|
assert f"{name}/**" in excludes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_git_update_preserves_user_dirs(monkeypatch, tmp_path):
|
||||||
|
"""``git clean`` must be called with -e excludes for user data dirs.
|
||||||
|
|
||||||
|
Regression test for portable-mode updates wiping wildcards/, stats/,
|
||||||
|
backups/, etc. because ``git clean -fd`` removed untracked, non-ignored
|
||||||
|
directories.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeGit:
|
||||||
|
def reset(self, *args, **kwargs):
|
||||||
|
calls.append(("reset", args))
|
||||||
|
|
||||||
|
def clean(self, *args, **kwargs):
|
||||||
|
calls.append(("clean", args))
|
||||||
|
|
||||||
|
def checkout(self, *args, **kwargs):
|
||||||
|
calls.append(("checkout", args))
|
||||||
|
|
||||||
|
class FakeRemote:
|
||||||
|
def fetch(self):
|
||||||
|
calls.append(("fetch", ()))
|
||||||
|
|
||||||
|
def pull(self, *args, **kwargs):
|
||||||
|
calls.append(("pull", args))
|
||||||
|
|
||||||
|
class FakeRemotes:
|
||||||
|
origin = FakeRemote()
|
||||||
|
|
||||||
|
class FakeCommit:
|
||||||
|
hexsha = "abcdef123456"
|
||||||
|
|
||||||
|
class FakeHeads:
|
||||||
|
def __getitem__(self, name):
|
||||||
|
class Head:
|
||||||
|
def checkout(self_inner):
|
||||||
|
calls.append(("head-checkout", (name,)))
|
||||||
|
return Head()
|
||||||
|
|
||||||
|
class FakeBranches:
|
||||||
|
names = ["main"]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
class B:
|
||||||
|
name = "main"
|
||||||
|
return iter([B()])
|
||||||
|
|
||||||
|
class FakeRepo:
|
||||||
|
def __init__(self, path):
|
||||||
|
calls.append(("repo", (path,)))
|
||||||
|
|
||||||
|
git = FakeGit()
|
||||||
|
remotes = FakeRemotes()
|
||||||
|
head = type("H", (), {"commit": FakeCommit()})()
|
||||||
|
branches = FakeBranches()
|
||||||
|
heads = FakeHeads()
|
||||||
|
|
||||||
|
def create_head(self, name, ref):
|
||||||
|
calls.append(("create_head", (name, ref)))
|
||||||
|
|
||||||
|
class FakeGitModule:
|
||||||
|
class Repo:
|
||||||
|
def __new__(cls, path):
|
||||||
|
return FakeRepo(path)
|
||||||
|
|
||||||
|
class exc:
|
||||||
|
class GitError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == "git":
|
||||||
|
return FakeGitModule
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
|
success, version = await update_routes.UpdateRoutes._perform_git_update(
|
||||||
|
str(tmp_path), nightly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
clean_calls = [c for c in calls if c[0] == "clean"]
|
||||||
|
assert len(clean_calls) == 1
|
||||||
|
clean_args = clean_calls[0][1]
|
||||||
|
# Every preserved dir must be excluded via -e
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in clean_args, f"{name} missing from git clean excludes"
|
||||||
|
assert f"{name}/**" in clean_args, f"{name}/** missing from git clean excludes"
|
||||||
|
# Ensure there's an -e before each name occurrence
|
||||||
|
idx = clean_args.index(name)
|
||||||
|
assert clean_args[idx - 1] == "-e"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_path):
|
||||||
|
"""Stable (tag) update path must also pass -e excludes to git clean."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeGit:
|
||||||
|
def reset(self, *args, **kwargs):
|
||||||
|
calls.append(("reset", args))
|
||||||
|
|
||||||
|
def clean(self, *args, **kwargs):
|
||||||
|
calls.append(("clean", args))
|
||||||
|
|
||||||
|
def checkout(self, *args, **kwargs):
|
||||||
|
calls.append(("checkout", args))
|
||||||
|
|
||||||
|
class FakeRemote:
|
||||||
|
def fetch(self):
|
||||||
|
calls.append(("fetch", ()))
|
||||||
|
|
||||||
|
class FakeRemotes:
|
||||||
|
origin = FakeRemote()
|
||||||
|
|
||||||
|
class FakeCommit:
|
||||||
|
committed_datetime = "2026-01-01"
|
||||||
|
|
||||||
|
class FakeTag:
|
||||||
|
name = "v9.9.9"
|
||||||
|
commit = FakeCommit()
|
||||||
|
|
||||||
|
class FakeRepo:
|
||||||
|
def __init__(self, path):
|
||||||
|
calls.append(("repo", (path,)))
|
||||||
|
|
||||||
|
git = FakeGit()
|
||||||
|
remotes = FakeRemotes()
|
||||||
|
tags = [FakeTag()]
|
||||||
|
|
||||||
|
class FakeGitModule:
|
||||||
|
class Repo:
|
||||||
|
def __new__(cls, path):
|
||||||
|
return FakeRepo(path)
|
||||||
|
|
||||||
|
class exc:
|
||||||
|
class GitError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == "git":
|
||||||
|
return FakeGitModule
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
|
success, version = await update_routes.UpdateRoutes._perform_git_update(
|
||||||
|
str(tmp_path), nightly=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert version == "v9.9.9"
|
||||||
|
clean_calls = [c for c in calls if c[0] == "clean"]
|
||||||
|
assert len(clean_calls) == 1
|
||||||
|
clean_args = clean_calls[0][1]
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in clean_args, f"{name} missing from git clean excludes (stable)"
|
||||||
|
|||||||
Reference in New Issue
Block a user