From 28f99c46d307db07d5d1677dacd0fafe1d14fc33 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 29 Jun 2026 21:10:38 +0800 Subject: [PATCH] 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. --- py/routes/update_routes.py | 33 +++++- tests/routes/test_update_routes.py | 177 +++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py index c9e423e9..683108eb 100644 --- a/py/routes/update_routes.py +++ b/py/routes/update_routes.py @@ -16,6 +16,27 @@ logger = logging.getLogger(__name__) 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: """Routes for handling plugin update checks""" @@ -365,6 +386,8 @@ class UpdateRoutes: ) return False, "" + clean_excludes = _clean_excludes() + try: # Open the Git repository repo = git.Repo(plugin_root) @@ -376,8 +399,9 @@ class UpdateRoutes: if nightly: # Reset to discard any local changes repo.git.reset('--hard') - # Clean untracked files - repo.git.clean('-fd') + # Clean untracked files, but preserve user-managed directories + # (wildcards, backups, stats, civitai, caches, settings.json). + repo.git.clean('-fd', *clean_excludes) # Switch to main branch and pull latest main_branch = 'main' @@ -394,8 +418,9 @@ class UpdateRoutes: else: # Reset to discard any local changes repo.git.reset('--hard') - # Clean untracked files - repo.git.clean('-fd') + # Clean untracked files, but preserve user-managed directories + # (wildcards, backups, stats, civitai, caches, settings.json). + repo.git.clean('-fd', *clean_excludes) # Get latest release tag tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True) diff --git a/tests/routes/test_update_routes.py b/tests/routes/test_update_routes.py index 455a6fdb..ef7c2fff 100644 --- a/tests/routes/test_update_routes.py +++ b/tests/routes/test_update_routes.py @@ -59,3 +59,180 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo assert changelog == [] assert "Unable to reach GitHub for nightly version" 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)"