feat: auto-update pouch and catalog sources during normal use
Add taste-skill to catalog.yaml, and refresh pouch plus cloned catalog or custom Git sources on a 24h interval so users do not need a separate update command.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""在日常命令里顺带更新 pouch 自身和已拉取的外部 source。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pouch import yaml_io
|
||||
from pouch.catalog import catalog_checkout_path, load_catalog
|
||||
from pouch.config import load_auto_update_settings
|
||||
from pouch.paths import POUCH_HOME, STATE_FILE
|
||||
from pouch.sources import load_sources, source_checkout
|
||||
|
||||
GIT_TIMEOUT_SECONDS = 20
|
||||
_SKIP_COMMANDS = {"update", "publish"}
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
_refreshed_paths: set[str] = set()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateResult:
|
||||
name: str
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self.status == "updated"
|
||||
|
||||
|
||||
def _print_err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def _run_git(
|
||||
repo: Path,
|
||||
*args: str,
|
||||
check: bool = False,
|
||||
timeout: int = GIT_TIMEOUT_SECONDS,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _is_git_repo(path: Path) -> bool:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
try:
|
||||
result = _run_git(path, "rev-parse", "--is-inside-work-tree")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||
|
||||
|
||||
def _working_tree_dirty(path: Path) -> bool:
|
||||
result = _run_git(path, "status", "--porcelain")
|
||||
return result.returncode != 0 or bool(result.stdout.strip())
|
||||
|
||||
|
||||
def _head_sha(path: Path) -> str:
|
||||
result = _run_git(path, "rev-parse", "HEAD")
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def refresh_git_checkout(
|
||||
path: Path,
|
||||
ref: str | None = None,
|
||||
*,
|
||||
name: str,
|
||||
strict: bool = False,
|
||||
allow_dirty: bool = False,
|
||||
) -> UpdateResult:
|
||||
key = str(path.resolve()) if path.exists() else str(path)
|
||||
if key in _refreshed_paths:
|
||||
return UpdateResult(name, "current")
|
||||
if not _is_git_repo(path):
|
||||
_refreshed_paths.add(key)
|
||||
result = UpdateResult(name, "skipped", "不是 git 仓库")
|
||||
if strict:
|
||||
raise SystemExit(f"{name} 不是 git 仓库: {path}")
|
||||
return result
|
||||
if not allow_dirty and _working_tree_dirty(path):
|
||||
_refreshed_paths.add(key)
|
||||
result = UpdateResult(name, "skipped", "工作区不干净")
|
||||
if strict:
|
||||
raise SystemExit(f"{name} 工作区不干净,拒绝更新: {path}")
|
||||
return result
|
||||
|
||||
before = _head_sha(path)
|
||||
commands: list[tuple[str, ...]] = [("fetch", "--all", "--tags")]
|
||||
if ref:
|
||||
commands.append(("checkout", ref))
|
||||
commands.append(("pull", "--ff-only"))
|
||||
try:
|
||||
for command in commands:
|
||||
ran = _run_git(path, *command, check=strict)
|
||||
if ran.returncode != 0:
|
||||
detail = (ran.stderr or ran.stdout or "git 失败").strip()
|
||||
return UpdateResult(name, "failed", detail)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = ((exc.stderr or exc.stdout or str(exc))).strip()
|
||||
if strict:
|
||||
raise SystemExit(f"更新 {name} 失败: {detail}") from exc
|
||||
return UpdateResult(name, "failed", detail)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = UpdateResult(name, "failed", "git 超时")
|
||||
if strict:
|
||||
raise SystemExit(f"更新 {name} 超时: {path}") from None
|
||||
return result
|
||||
except OSError as exc:
|
||||
result = UpdateResult(name, "failed", str(exc))
|
||||
if strict:
|
||||
raise SystemExit(f"更新 {name} 失败: {exc}") from exc
|
||||
return result
|
||||
|
||||
after = _head_sha(path)
|
||||
_refreshed_paths.add(key)
|
||||
if before and after and before != after:
|
||||
return UpdateResult(name, "updated", after[:8])
|
||||
return UpdateResult(name, "current")
|
||||
|
||||
|
||||
def update_pouch_home(*, strict: bool = False) -> UpdateResult:
|
||||
return refresh_git_checkout(
|
||||
POUCH_HOME,
|
||||
name="pouch",
|
||||
strict=strict,
|
||||
allow_dirty=strict,
|
||||
)
|
||||
|
||||
|
||||
def update_cloned_catalogs(*, strict: bool = False) -> list[UpdateResult]:
|
||||
results: list[UpdateResult] = []
|
||||
seen: set[Path] = set()
|
||||
for name, entry in load_catalog().items():
|
||||
repo = str(entry.get("repo", ""))
|
||||
if repo.startswith("~"):
|
||||
continue
|
||||
dest = catalog_checkout_path(name, entry)
|
||||
resolved = dest.resolve() if dest.exists() else dest
|
||||
if resolved in seen or not dest.is_dir():
|
||||
continue
|
||||
seen.add(resolved)
|
||||
results.append(
|
||||
refresh_git_checkout(
|
||||
dest,
|
||||
str(entry.get("ref", "main") or "main"),
|
||||
name=f"catalog:{name}",
|
||||
strict=strict,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def update_custom_sources(*, strict: bool = False) -> list[UpdateResult]:
|
||||
results: list[UpdateResult] = []
|
||||
for name, entry in load_sources().items():
|
||||
if entry.get("local_path"):
|
||||
continue
|
||||
checkout = source_checkout(name, entry)
|
||||
if not checkout.is_dir():
|
||||
continue
|
||||
results.append(
|
||||
refresh_git_checkout(
|
||||
checkout,
|
||||
str(entry.get("ref", "main") or "main"),
|
||||
name=f"custom:{name}",
|
||||
strict=strict,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def run_updates(*, strict_pouch: bool = False) -> list[UpdateResult]:
|
||||
results = [update_pouch_home(strict=strict_pouch)]
|
||||
results.extend(update_cloned_catalogs(strict=False))
|
||||
results.extend(update_custom_sources(strict=False))
|
||||
return results
|
||||
|
||||
|
||||
def load_state(path: Path | None = None) -> dict[str, Any]:
|
||||
path = path or STATE_FILE
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def save_state(data: dict[str, Any], path: Path | None = None) -> None:
|
||||
path = path or STATE_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def record_auto_update(path: Path | None = None) -> None:
|
||||
state = load_state(path)
|
||||
state["last_auto_update"] = datetime.now(timezone.utc).isoformat()
|
||||
save_state(state, path)
|
||||
|
||||
|
||||
def _interval_elapsed(state: dict[str, Any], interval_seconds: int) -> bool:
|
||||
if interval_seconds <= 0:
|
||||
return True
|
||||
raw = state.get("last_auto_update")
|
||||
if not raw:
|
||||
return True
|
||||
try:
|
||||
last = datetime.fromisoformat(str(raw))
|
||||
except ValueError:
|
||||
return True
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
elapsed = (datetime.now(timezone.utc) - last).total_seconds()
|
||||
return elapsed >= interval_seconds
|
||||
|
||||
|
||||
def _env_disabled() -> bool:
|
||||
return os.environ.get("POUCH_NO_AUTO_UPDATE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def _acquire_lock(path: Path) -> Any | None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
lock_file = path.open("a+")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
lock_file.close()
|
||||
return None
|
||||
return lock_file
|
||||
|
||||
|
||||
def _release_lock(lock_file: Any | None) -> None:
|
||||
if lock_file is None:
|
||||
return
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
lock_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_refresh_cache() -> None:
|
||||
_refreshed_paths.clear()
|
||||
|
||||
|
||||
def report_results(results: Iterable[UpdateResult], *, prefix: str = "[pouch]") -> None:
|
||||
changed = [item.name for item in results if item.changed]
|
||||
failed = [item for item in results if item.status == "failed"]
|
||||
if changed:
|
||||
_print_err(f"{prefix} 已自动更新: {', '.join(changed)}")
|
||||
for item in failed:
|
||||
detail = f": {item.detail}" if item.detail else ""
|
||||
_print_err(f"{prefix} 更新 {item.name} 失败,已跳过{detail}")
|
||||
|
||||
|
||||
def maybe_auto_update(*, command: str | None = None, disabled: bool = False) -> list[UpdateResult]:
|
||||
if disabled or command in _SKIP_COMMANDS or _env_disabled():
|
||||
return []
|
||||
if not POUCH_HOME.is_dir():
|
||||
return []
|
||||
|
||||
settings = load_auto_update_settings()
|
||||
if not settings.enabled:
|
||||
return []
|
||||
state = load_state()
|
||||
if not _interval_elapsed(state, settings.interval_seconds):
|
||||
return []
|
||||
|
||||
lock_file = _acquire_lock(STATE_FILE.with_name("auto-update.lock"))
|
||||
if lock_file is None:
|
||||
return []
|
||||
try:
|
||||
# 拿到锁后再读一次,避免并发重复拉取。
|
||||
state = load_state()
|
||||
if not _interval_elapsed(state, settings.interval_seconds):
|
||||
return []
|
||||
results = run_updates(strict_pouch=False)
|
||||
record_auto_update()
|
||||
report_results(results)
|
||||
return results
|
||||
except Exception as exc: # noqa: BLE001 — 自动更新不得打断用户命令
|
||||
_print_err(f"[pouch] 自动更新失败,已跳过: {exc}")
|
||||
return []
|
||||
finally:
|
||||
_release_lock(lock_file)
|
||||
Reference in New Issue
Block a user