mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
feat(services): add services_repo_force and compact refresh output
Force-reset repo clones on the automatic TTL refresh when enabled. Report refresh changes as one +/~/-/!TAG line per repo.
This commit is contained in:
@@ -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"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user