diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 8fe4c3de..a571893c 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -781,7 +781,7 @@ git-pull every service repository configured under `directories.services`. `refr { "refreshed": true, "repos": [ - { "spec": "github.com/example/services", "updated": true, "changes": ["a1b2c3d Update EXAMPLE"] } + { "spec": "github.com/example/services", "updated": true, "changes": ["~EXAMPLE"] } ] } ``` diff --git a/docs/reference/configuration/directories.md b/docs/reference/configuration/directories.md index dcb4fbe9..a008a50b 100644 --- a/docs/reference/configuration/directories.md +++ b/docs/reference/configuration/directories.md @@ -67,6 +67,15 @@ directories: `unshackle util refresh-services` command instead **hard-resets** the clone to upstream, discarding any local edits. + Set `services_repo_force: true` to make the automatic 24 h refresh behave like + `refresh-services`. When a refresh is due, the run fetches and hard-resets every clone, + and discards any local edits in `_repos/`. Runs between refreshes do not touch the + network. Use it on unattended hosts where nobody edits the clones by hand. + + ```yaml + services_repo_force: true + ``` + !!! warning "Read-only installs and reinstalls" If the services directory lives inside the installed package, a reinstall can delete the `_repos` clones. They are re-cloned on next use. On **read-only installs** you must diff --git a/docs/reference/configuration/index.md b/docs/reference/configuration/index.md index 4b3dff1f..bf9f7a14 100644 --- a/docs/reference/configuration/index.md +++ b/docs/reference/configuration/index.md @@ -29,7 +29,7 @@ Where the file lives, how unshackle discovers it, and how to edit it: | Area | Keys | |------|------| -| [Directories](directories.md) | `directories`, `filenames` | +| [Directories](directories.md) | `directories`, `filenames`, `services_repo_force` | | [Services & authentication](services.md) | `services`, `credentials`, `firefox_cookies`, `remote_services`, `serve` | | [Download & processing](download.md) | `dl`, `continue_downloads`, `subtitle`, `audio`, `muxing`, `language_tags`, `dual_multi_mode` | | [Output & naming](output.md) | `output_template`, `tag`, `tag_rules`, `tag_group_name`, `tag_imdb_tmdb`, `chapter_fallback_name`, `unicode_filenames` | diff --git a/tests/core/test_service_repo.py b/tests/core/test_service_repo.py index 6a692e8c..be2013d9 100644 --- a/tests/core/test_service_repo.py +++ b/tests/core/test_service_repo.py @@ -172,7 +172,7 @@ def test_auto_refresh_refuses_dirty_clone(monkeypatch, tmp_path): def test_manual_refresh_force_overwrites(monkeypatch, tmp_path): - """Manual path (force=True): hard-reset to upstream, never raises on a dirty clone.""" + """Force path (services_repo_force): hard-resets a stale clone, never raises; leaves a fresh clone alone.""" dest = tmp_path / "github.com__a__b" dest.mkdir(parents=True) monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) @@ -186,6 +186,10 @@ def test_manual_refresh_force_overwrites(monkeypatch, tmp_path): assert service_repo.resolve_service_repo("a/b", force=True) == dest assert "fetch" in calls and "reset" in calls # force-sync ran assert "status" not in calls # no dirty gate on the force path + calls.clear() + service_repo.write_stamp(service_repo.stamp_for(dest)) # fresh stamp, inside the TTL + assert service_repo.resolve_service_repo("a/b", force=True) == dest + assert calls == [] # no network before the TTL expires def test_changed_services_groups_by_top_dir(monkeypatch): @@ -194,18 +198,13 @@ def test_changed_services_groups_by_top_dir(monkeypatch): def run(args, **kw): if "--name-status" in args: out = b"A\tNEW/__init__.py\nM\tOLD/__init__.py\nM\tOLD/config.yaml\nD\tGONE/__init__.py\n" - elif "--shortstat" in args: - out = b" 4 files changed, 10 insertions(+), 2 deletions(-)\n" else: out = b"" return subprocess.CompletedProcess(args, 0, out, b"") monkeypatch.setattr(service_repo.subprocess, "run", run) lines = service_repo.changed_services(Path("/x"), "aaa", "bbb") - assert "+ NEW (added)" in lines - assert "~ OLD (modified)" in lines - assert "- GONE (removed)" in lines - assert any("4 files changed" in line for line in lines) + assert lines == ["-GONE", "+NEW", "~OLD"] def test_local_dirty_services_lists_edited_dirs(monkeypatch): @@ -234,7 +233,7 @@ def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path): monkeypatch.setattr(service_repo.subprocess, "run", run) _, changes = service_repo.refresh_repo("a/b") - assert changes == ["! CR (local changes discarded)"] + assert changes == ["!CR"] def test_changed_services_up_to_date(monkeypatch): diff --git a/unshackle/commands/util.py b/unshackle/commands/util.py index c5e7f9ec..1359f39d 100644 --- a/unshackle/commands/util.py +++ b/unshackle/commands/util.py @@ -25,7 +25,6 @@ def refresh_services() -> None: """Force a refresh (git pull) of all service repos configured in directories.services.""" from unshackle.core.config import config from unshackle.core.service_repo import is_repo_spec, refresh_repo - from unshackle.core.utils.redact import redact_path log = logging.getLogger("util") @@ -43,11 +42,9 @@ def refresh_services() -> None: log.error(f"Failed to update {spec} (see log).") continue if changes: - log.info(f"Updated {spec} → {redact_path(str(dest))}") - for line in changes: - log.info(f" {line}") + log.info(f"Updated {spec}: {' '.join(changes)}") else: - log.info(f"No changes {spec}") + log.info(f"Up to date: {spec}") @util.command() diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 2238bfd9..5c1b2143 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -128,6 +128,7 @@ class Config: self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or "" self.ipinfo_api_key: str = kwargs.get("ipinfo_api_key") or "" self.update_checks: bool = kwargs.get("update_checks", True) + self.services_repo_force: bool = bool(kwargs.get("services_repo_force", False)) self.update_check_interval: int = kwargs.get("update_check_interval", 24) # mask local base dirs (install root/venv/home) in logged paths; False shows full paths self.redact_paths: bool = kwargs.get("redact_paths", True) diff --git a/unshackle/core/service_repo.py b/unshackle/core/service_repo.py index 7f71136f..3b963247 100644 --- a/unshackle/core/service_repo.py +++ b/unshackle/core/service_repo.py @@ -80,10 +80,11 @@ def repos_base() -> Path: def resolve_service_repo(spec: str, *, ttl: int = DEFAULT_TTL, force: bool = False) -> Optional[Path]: """Find the local clone dir of a repo spec. - First use clones it. ``force=True`` (manual ``refresh-services``) hard-resets the clone to - upstream, discarding local changes. Otherwise a TTL-stale clone is fast-forwarded, but a dirty - clone raises ``DirtyServiceRepo`` so the automated dl/search path can warn and exit instead of - clobbering in-progress edits. + First use clones it. Only a TTL-stale clone touches the network. With ``force=True`` + (``services_repo_force``) the refresh hard-resets the clone to upstream and discards local + changes. Without it the refresh fast-forwards, and a dirty clone raises ``DirtyServiceRepo`` + so the automated dl/search path can warn and exit instead of clobbering in-progress edits. + Manual ``refresh-services`` uses ``refresh_repo`` and ignores the TTL. """ url, branch, dest_name = parse_spec(spec) dest = repos_base() / dest_name @@ -91,10 +92,8 @@ def resolve_service_repo(spec: str, *, ttl: int = DEFAULT_TTL, force: bool = Fal if not dest.exists(): return clone(url, branch, dest, stamp) - if force: - force_sync(dest, stamp) - elif is_stale(stamp, ttl): - pull(dest, stamp) + if is_stale(stamp, ttl): + force_sync(dest, stamp) if force else pull(dest, stamp) return dest @@ -135,9 +134,9 @@ def pull(dest: Path, stamp: Path) -> None: def refresh_repo(spec: str) -> tuple[Optional[Path], list[str]]: """Force-sync a repo to upstream and return (clone dir, git-style per-service change lines). - Used by the manual ``refresh-services`` command. Change lines look like ``+ TAG (added)`` / - ``~ TAG (modified)`` / ``- TAG (removed)`` plus a shortstat. An empty list means the clone is - already up to date. + Used by the manual ``refresh-services`` command. Each line is one service tag with a status + prefix: ``+TAG`` added, ``~TAG`` modified, ``-TAG`` removed, ``!TAG`` local changes discarded. + An empty list means the clone is already up to date. """ url, branch, dest_name = parse_spec(spec) dest = repos_base() / dest_name @@ -149,7 +148,7 @@ def refresh_repo(spec: str) -> tuple[Optional[Path], list[str]]: old = head(dest) discarded = local_dirty_services(dest) force_sync(dest, stamp) - lines = [f"! {tag} (local changes discarded)" for tag in discarded] + lines = [f"!{tag}" for tag in discarded] # ! = local changes discarded lines += changed_services(dest, old, head(dest)) return dest, lines @@ -168,7 +167,7 @@ def local_dirty_services(dest: Path) -> list[str]: def changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> list[str]: - """Summarise a HEAD old->new diff grouped by top-level service dir, plus a line shortstat.""" + """Summarise a HEAD old->new diff as one ``+TAG``/``~TAG``/``-TAG`` token per top-level service directory.""" if not old or not new or old == new: return [] git = str(binaries.Git) @@ -185,15 +184,12 @@ def changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> list for top in sorted(by_dir): codes = by_dir[top] kind = "added" if codes == {"A"} else "removed" if codes == {"D"} else "modified" - lines.append(f"{sym[kind]} {top} ({kind})") - short = subprocess.run([git, "-C", str(dest), "diff", "--shortstat", f"{old}..{new}"], capture_output=True) # nosec B603 - if short.returncode == 0 and short.stdout.strip(): - lines.append(short.stdout.decode(errors="ignore").strip()) + lines.append(f"{sym[kind]}{top}") return lines def force_sync(dest: Path, stamp: Path) -> None: - """Hard-reset the clone to its upstream, discarding local changes (manual refresh only).""" + """Hard-reset the clone to its upstream, discarding local changes.""" if not binaries.Git: write_stamp(stamp) return diff --git a/unshackle/core/services.py b/unshackle/core/services.py index f2c9d1a7..ef49e787 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -25,7 +25,7 @@ DIRTY_REPOS: list[str] = [] for _entry in raw: if isinstance(_entry, str) and is_repo_spec(_entry): try: - _resolved = resolve_service_repo(_entry) + _resolved = resolve_service_repo(_entry, force=config.services_repo_force) except DirtyServiceRepo as e: # local edits in the clone - record it and let check_load_errors() exit cleanly DIRTY_REPOS.append(redact_path(str(e.path)))