From cd9785d406f9a1331d83ecc220d72d7a15a390fc Mon Sep 17 00:00:00 2001 From: Jon Gordon <1441736+jongordon@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:57:04 +0100 Subject: [PATCH] fix(services): ignore tracked bytecode in the service-repo dirty check Importing services rewrites upstream-committed __pycache__/*.pyc and was treated as local source edits, blocking refresh. Ignore bytecode and .DS_Store in the dirty check, restore those paths before pull, and disable bytecode writes while loading services. Port of vinefeeder/TwinVine#18 (merged). --- unshackle/core/service_repo.py | 78 +++++++++++++++++++++++++++++++--- unshackle/core/services.py | 4 ++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/unshackle/core/service_repo.py b/unshackle/core/service_repo.py index 3b963247..4e445f46 100644 --- a/unshackle/core/service_repo.py +++ b/unshackle/core/service_repo.py @@ -32,6 +32,58 @@ def stamp_for(dest: Path) -> Path: SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$") +# Importing services rewrites tracked bytecode when upstream mistakenly commits +# ``__pycache__`` (e.g. playtv). That must not block refresh / look like source edits. +_JUNK_NAME_SUFFIXES = (".pyc", ".pyo") +_JUNK_BASENAMES = frozenset({".DS_Store"}) + + +def is_junk_path(path: str) -> bool: + """True for import/OS noise that is never a meaningful service-source edit.""" + normalized = path.replace("\\", "/").strip() + if not normalized: + return False + parts = normalized.split("/") + if "__pycache__" in parts: + return True + name = parts[-1] + if name in _JUNK_BASENAMES: + return True + return name.endswith(_JUNK_NAME_SUFFIXES) + + +def porcelain_paths(porcelain: str) -> list[str]: + """Paths from ``git status --porcelain`` lines (handles rename ``old -> new``).""" + paths: list[str] = [] + for raw in porcelain.splitlines(): + if len(raw) < 4: + continue + # XYpath or XYold -> new + entry = raw[3:] + if " -> " in entry: + entry = entry.split(" -> ", 1)[1] + # quoted paths are rare here; strip optional quotes + if len(entry) >= 2 and entry[0] == entry[-1] == '"': + entry = entry[1:-1] + paths.append(entry) + return paths + + +def discard_junk_workdir(dest: Path) -> None: + """Restore tracked junk paths so ``git pull`` is not blocked by rewritten ``.pyc`` files.""" + if not binaries.Git: + return + git = str(binaries.Git) + status = subprocess.run( + [git, "-C", str(dest), "status", "--porcelain", "--untracked-files=no"], capture_output=True + ) # nosec B603 + if status.returncode != 0 or not status.stdout.strip(): + return + junk = [p for p in porcelain_paths(status.stdout.decode(errors="ignore")) if is_junk_path(p)] + if not junk: + return + subprocess.run([git, "-C", str(dest), "checkout", "--", *junk], capture_output=True) # nosec B603 + def is_repo_spec(value: object) -> bool: """True if value is a git repo spec (URL or owner/repo shorthand), not a local path.""" @@ -121,6 +173,9 @@ def pull(dest: Path, stamp: Path) -> None: if not binaries.Git: write_stamp(stamp) return + # Drop rewritten bytecode before the dirty check / pull so a tracked ``.pyc`` cannot + # false-trigger DirtyServiceRepo or block ``git pull --ff-only``. + discard_junk_workdir(dest) # never clobber local edits: refuse to refresh a dirty clone and let the caller exit cleanly if is_dirty(dest): raise DirtyServiceRepo(dest) @@ -163,7 +218,13 @@ def local_dirty_services(dest: Path) -> list[str]: r = subprocess.run([str(binaries.Git), "-C", str(dest), "diff", "--name-only", "HEAD"], capture_output=True) # nosec B603 if r.returncode != 0: return [] - return sorted({line.split("/")[0] for line in r.stdout.decode(errors="ignore").splitlines() if line}) + return sorted( + { + line.split("/")[0] + for line in r.stdout.decode(errors="ignore").splitlines() + if line and not is_junk_path(line) + } + ) def changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> list[str]: @@ -205,17 +266,20 @@ def force_sync(dest: Path, stamp: Path) -> None: def is_dirty(dest: Path) -> bool: """True if the clone has uncommitted edits to tracked files or local commits not on its upstream. - This function intentionally ignores untracked files: a `git pull --ff-only` only touches tracked - files, so untracked files (including `__pycache__` that importing the services creates) are never - at risk and must not falsely flag the clone as dirty. + Ignores untracked files (``git pull`` does not remove them) and tracked import/OS noise + (``__pycache__/``, ``*.pyc`` / ``*.pyo``, ``.DS_Store``). Upstream sometimes commits a + ``.pyc``; importing under another interpreter rewrites it and must not look like a + service-source edit. """ git = str(binaries.Git) - # modifications to tracked files only (--untracked-files=no skips __pycache__ etc.) + # modifications to tracked files only (--untracked-files=no skips untracked __pycache__ etc.) status = subprocess.run( [git, "-C", str(dest), "status", "--porcelain", "--untracked-files=no"], capture_output=True ) # nosec B603 - if status.returncode == 0 and status.stdout.strip(): - return True + if status.returncode == 0: + meaningful = [p for p in porcelain_paths(status.stdout.decode(errors="ignore")) if not is_junk_path(p)] + if meaningful: + return True # local commits ahead of upstream (committed but not pushed); no upstream → treat as clean ahead = subprocess.run([git, "-C", str(dest), "rev-list", "--count", "@{u}..HEAD"], capture_output=True) # nosec B603 if ahead.returncode == 0: diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 6e7f2397..68fcaa84 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -28,6 +28,10 @@ log = logging.getLogger("services") DIRTY_REPOS: list[str] = [] SHADOWED: list[str] = [] +# Service clones live inside the tree; writing bytecode there rewrites tracked ``.pyc`` +# files (when upstream committed them) and floods untracked ``__pycache__`` dirs. +sys.dont_write_bytecode = True + def discover_services() -> tuple[list[Path], list[str]]: """Resolve the configured service dirs and glob every ``TAG/__init__.py``.