From 49d6d22f82bf3517b89762ad42d3de240d757c40 Mon Sep 17 00:00:00 2001 From: Gabriel Saraiva Date: Sun, 24 May 2026 18:39:23 -0300 Subject: [PATCH] Add release CI and conflict sync handling --- .github/workflows/ci.yml | 30 +- .github/workflows/release-build.yml | 77 ++++ pyproject.toml | 3 +- scripts/dev.ps1 | 68 ++-- src/willy/daemon.py | 525 ++++++++++++++-------------- src/willy/errors.py | 56 +-- src/willy/git.py | 48 ++- src/willy/operations.py | 242 +++++++------ src/willy/qt_tray.py | 20 ++ src/willy/statusbar.py | 52 ++- tests/test_git.py | 228 +++++++----- tests/test_operations.py | 251 +++++++------ tests/test_statusbar.py | 67 ++++ tests/test_workflows.py | 24 ++ 14 files changed, 1057 insertions(+), 634 deletions(-) create mode 100644 .github/workflows/release-build.yml create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5598c02..89f0a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,19 @@ -name: CI +name: Tests on: push: branches: [main, support/win] pull_request: + release: + types: [published] + +permissions: + contents: read jobs: test: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + if: github.event_name != 'release' || github.event.release.prerelease == true runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -34,3 +40,25 @@ jobs: - name: Test run: pytest + + package-smoke: + name: Package smoke test + if: github.event_name != 'release' || github.event.release.prerelease == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build frontend + run: python -m pip install --upgrade build + + - name: Build source and wheel distributions + run: python -m build + + - name: Check artifacts exist + run: test -n "$(find dist -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.whl' \) -print -quit)" diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..a5915cf --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,77 @@ +name: Release Build + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + python-package: + name: Build Python package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build frontend + run: python -m pip install --upgrade build + + - name: Build source and wheel distributions + run: python -m build + + - uses: actions/upload-artifact@v4 + with: + name: willy-python-package + path: dist/* + + - name: Upload package files to release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG_NAME" dist/* --clobber + + windows-tray: + name: Build Windows tray + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install project + run: python -m pip install -e ".[dev]" + + - name: Build tray executable + shell: pwsh + run: .\scripts\dev.ps1 build-exe + + - name: Archive tray executable + shell: pwsh + run: Compress-Archive -Path .\dist\WillyTray -DestinationPath .\dist\WillyTray-windows-x64.zip -Force + + - uses: actions/upload-artifact@v4 + with: + name: WillyTray-windows-x64 + path: dist/WillyTray-windows-x64.zip + + - name: Upload tray executable to release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: gh release upload $env:TAG_NAME .\dist\WillyTray-windows-x64.zip --clobber diff --git a/pyproject.toml b/pyproject.toml index 9b1c41a..5bd38cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.10" description = "Local-first Git sync for OrcaSlicer profiles." readme = "README.md" requires-python = ">=3.11" -license = { text = "AGPL-3.0-only" } +license = "AGPL-3.0-only" authors = [ { name = "Gabriel Saraiva", email = "extremez3r0@gmail.com" }, ] @@ -18,7 +18,6 @@ classifiers = [ "Environment :: Console", "Environment :: Win32 (MS Windows)", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU Affero General Public License v3", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index 5a88fae..af03f76 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -15,18 +15,32 @@ $WillyExe = Join-Path $ScriptsDir "willy.exe" $PyInstallerExe = Join-Path $ScriptsDir "pyinstaller.exe" $IconPng = Join-Path $RepoRoot "assets\\icon.png" $IconIco = Join-Path $RepoRoot "assets\\icon.ico" + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')" + } +} -function Ensure-Venv { - if (-not (Test-Path $PythonExe)) { - python -m venv $VenvDir - } -} - -function Install-Env { - Ensure-Venv - & $PythonExe -m pip install --upgrade pip - & $PythonExe -m pip install -e '.[dev]' -} +function Ensure-Venv { + if (-not (Test-Path $PythonExe)) { + Invoke-Native python -m venv $VenvDir + } +} + +function Install-Env { + Ensure-Venv + Invoke-Native $PythonExe -m pip install --upgrade pip + Invoke-Native $PythonExe -m pip install -e '.[dev]' +} function Install-Hooks { if (-not (Test-Path (Join-Path $RepoRoot ".git"))) { @@ -75,28 +89,30 @@ try { "install" { Install-Env } - "lint" { - Ensure-Venv - & $RuffExe check --fix src tests - & $RuffExe format src tests - & $RuffExe check src tests - } - "test" { - Ensure-Venv - & $PytestExe - } + "lint" { + Ensure-Venv + Invoke-Native $RuffExe check --fix src tests + Invoke-Native $RuffExe format src tests + Invoke-Native $RuffExe check src tests + } + "test" { + Ensure-Venv + Invoke-Native $PytestExe + } "run" { Ensure-Venv - & $WillyExe start + Invoke-Native $WillyExe start } "tray" { Ensure-Venv - & $WillyExe statusbar + Invoke-Native $WillyExe statusbar } "build-exe" { Ensure-Venv + Remove-Item -LiteralPath (Join-Path $RepoRoot "build\\WillyTray") -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $RepoRoot "dist\\WillyTray") -Recurse -Force -ErrorAction SilentlyContinue if ((Test-Path $IconPng) -and -not (Test-Path $IconIco)) { - & $PythonExe -c "from PIL import Image; Image.open(r'$IconPng').save(r'$IconIco', sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])" + Invoke-Native $PythonExe -c "from PIL import Image; Image.open(r'$IconPng').save(r'$IconIco', sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])" } $args = @( "--noconfirm", @@ -114,11 +130,11 @@ try { $args += @("--icon", $IconIco) } $args += ".\\src\\willy\\tray_app.py" - & $PyInstallerExe @args + Invoke-Native $PyInstallerExe @args } "stop" { Ensure-Venv - & $WillyExe stop + Invoke-Native $WillyExe stop } "hooks" { Install-Hooks diff --git a/src/willy/daemon.py b/src/willy/daemon.py index 31953c7..1d4d191 100644 --- a/src/willy/daemon.py +++ b/src/willy/daemon.py @@ -1,261 +1,264 @@ -from __future__ import annotations - -import os -import signal -import subprocess -import sys -import time -from dataclasses import replace -from datetime import datetime, timedelta -from pathlib import Path - -from watchdog.events import FileSystemEvent, FileSystemEventHandler -from watchdog.observers import Observer - -from willy.config import WillyConfig, WillyState, load_state, save_state -from willy.errors import WillyError -from willy.files import classify_path -from willy.locks import LockError, acquire_lock -from willy.logging import write_event -from willy.operations import save_profile_changes, sync_repo -from willy.paths import WillyPaths - - -class ChangeCollector(FileSystemEventHandler): - def __init__(self, root: Path, *, asset_dirs: tuple[Path, ...] = ()) -> None: - self.root = root - self.asset_dirs = asset_dirs - self.changed = False - - def on_any_event(self, event: FileSystemEvent) -> None: - if event.is_directory: - return - paths = [Path(event.src_path)] - dest_path = getattr(event, "dest_path", "") - if dest_path: - paths.append(Path(dest_path)) - if any(classify_path(self.root, path, asset_dirs=self.asset_dirs).trackable for path in paths): - self.changed = True - - def consume(self) -> bool: - changed = self.changed - self.changed = False - return changed - - -def pid_is_running(pid: int | None) -> bool: - if not pid: - return False - if sys.platform == "win32": - try: - import ctypes - - kernel32 = ctypes.windll.kernel32 - process_query_limited_information = 0x1000 - still_active = 259 - kernel32.OpenProcess.restype = ctypes.c_void_p - handle = kernel32.OpenProcess(process_query_limited_information, False, int(pid)) - if not handle: - return False - exit_code = ctypes.c_ulong() - try: - if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): - return False - return exit_code.value == still_active - finally: - kernel32.CloseHandle(handle) - except Exception: - return False - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - except (OSError, SystemError): - return False - return True - - -def _stop_pid(pid: int, *, force: bool) -> None: - if sys.platform == "win32": - command = ["taskkill", "/PID", str(pid), "/T"] - if force: - command.append("/F") - subprocess.run( - command, - capture_output=True, - text=True, - check=False, - timeout=10, - creationflags=subprocess.CREATE_NO_WINDOW, - ) - return - os.kill(pid, signal.SIGKILL if force else signal.SIGTERM) - - -def start_background(paths: WillyPaths, state: WillyState) -> int: - if pid_is_running(state.daemon_pid): - return state.daemon_pid or 0 - paths.ensure_runtime_dirs() - stdout = paths.logs_dir / "daemon.out.log" - stderr = paths.logs_dir / "daemon.err.log" - creationflags = 0 - executable = sys.executable - args = [executable, "-m", "willy", "daemon"] - if getattr(sys, "frozen", False): - args = [executable, "--daemon"] - if sys.platform == "win32": - python = Path(sys.executable) - pythonw = python.with_name("pythonw.exe") - if not getattr(sys, "frozen", False) and python.name.lower() == "python.exe" and pythonw.exists(): - executable = str(pythonw) - args = [executable, "-m", "willy", "daemon"] - creationflags = subprocess.CREATE_NO_WINDOW - with stdout.open("a", encoding="utf-8") as out, stderr.open("a", encoding="utf-8") as err: - process = subprocess.Popen( - args, - stdout=out, - stderr=err, - start_new_session=True, - creationflags=creationflags, - ) - save_state(paths, replace(state, daemon_pid=process.pid)) - return process.pid - - -def stop_background(paths: WillyPaths, state: WillyState, *, timeout_seconds: float = 5.0) -> bool: - if not pid_is_running(state.daemon_pid): - save_state(paths, replace(state, daemon_pid=None)) - return False - assert state.daemon_pid is not None - _stop_pid(state.daemon_pid, force=False) - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if not pid_is_running(state.daemon_pid): - save_state(paths, replace(state, daemon_pid=None)) - return True - time.sleep(0.1) - _stop_pid(state.daemon_pid, force=True) - save_state(paths, replace(state, daemon_pid=None)) - return True - - -def _flush(paths: WillyPaths, config: WillyConfig, *, description: str) -> None: - state = load_state(paths) - save_state(paths, replace(state, active_operation="saving")) - try: - result = save_profile_changes( - config.repo_path, - description=description, - allow_sensitive=bool(config.repo_private), - asset_dirs=config.asset_dirs, - ) - if result.saved: - write_event(paths, "daemon_commit", count=result.count, subject=result.subject) - finally: - save_state(paths, replace(load_state(paths), active_operation=None)) - - -def _clear_pending(state: WillyState) -> WillyState: - return replace(state, pending_save_since=None, next_save_at=None, pending_save_count=0) - - -def _mark_pending(state: WillyState, *, debounce_seconds: int) -> WillyState: - now = datetime.now().astimezone() - next_save = now + timedelta(seconds=debounce_seconds) - return replace( - state, - pending_save_since=state.pending_save_since or now.isoformat(timespec="seconds"), - next_save_at=next_save.isoformat(timespec="seconds"), - pending_save_count=state.pending_save_count + 1, - ) - - -def run_daemon( - paths: WillyPaths, - config: WillyConfig, - *, - state: WillyState, - is_orca_running_func, - poll_seconds: float = 2.0, - once: bool = False, - stop_event=None, -) -> None: - if not config.repo_path.exists(): - raise WillyError(f"Sync repo path does not exist: {config.repo_path}") - - try: - lock = acquire_lock(paths.locks_dir, "daemon", purpose="watch profiles") - except LockError: - raise - - with lock: - current_state = replace(state, daemon_pid=os.getpid()) - save_state(paths, current_state) - write_event(paths, "daemon_started", repo=str(config.repo_path)) - was_running = False - observer: Observer | None = None - collector: ChangeCollector | None = None - last_change = 0.0 - - try: - while True: - if stop_event is not None and stop_event.is_set(): - return - running = bool(is_orca_running_func()) - now = time.monotonic() - - if running and not was_running: - collector = ChangeCollector(config.repo_path, asset_dirs=config.asset_dirs) - observer = Observer() - observer.schedule(collector, str(config.repo_path), recursive=True) - observer.start() - write_event(paths, "orca_started", repo=str(config.repo_path)) - - if running and collector and collector.consume(): - last_change = now - current_state = _mark_pending(current_state, debounce_seconds=config.debounce_seconds) - save_state(paths, current_state) - write_event(paths, "watcher_batch_seen", repo=str(config.repo_path)) - - if running and last_change and now - last_change >= config.debounce_seconds: - _flush(paths, config, description="Automatic save while OrcaSlicer is running") - current_state = _clear_pending(current_state) - save_state(paths, current_state) - last_change = 0.0 - - if not running and was_running: - if observer: - observer.stop() - observer.join(timeout=5) - observer = None - collector = None - _flush(paths, config, description="Final save after OrcaSlicer closed") - sync_status = sync_repo(config.repo_path) - current_state = _clear_pending( - replace( - current_state, - last_sync_at=datetime.now().astimezone().isoformat(timespec="seconds"), - last_sync_status=sync_status, - daemon_pid=os.getpid(), - ) - ) - save_state(paths, current_state) - write_event(paths, "orca_stopped", sync_status=sync_status) - if once: - return - - was_running = running - if once and not running: - return - if stop_event is not None: - stop_event.wait(poll_seconds) - else: - time.sleep(poll_seconds) - finally: - if observer: - observer.stop() - observer.join(timeout=5) - save_state(paths, _clear_pending(replace(current_state, daemon_pid=None))) - write_event(paths, "daemon_stopped", repo=str(config.repo_path)) +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from dataclasses import replace +from datetime import datetime, timedelta +from pathlib import Path + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from willy.config import WillyConfig, WillyState, load_state, save_state +from willy.errors import GitConflictError, WillyError +from willy.files import classify_path +from willy.locks import LockError, acquire_lock +from willy.logging import write_event +from willy.operations import save_profile_changes, sync_repo +from willy.paths import WillyPaths + + +class ChangeCollector(FileSystemEventHandler): + def __init__(self, root: Path, *, asset_dirs: tuple[Path, ...] = ()) -> None: + self.root = root + self.asset_dirs = asset_dirs + self.changed = False + + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory: + return + paths = [Path(event.src_path)] + dest_path = getattr(event, "dest_path", "") + if dest_path: + paths.append(Path(dest_path)) + if any(classify_path(self.root, path, asset_dirs=self.asset_dirs).trackable for path in paths): + self.changed = True + + def consume(self) -> bool: + changed = self.changed + self.changed = False + return changed + + +def pid_is_running(pid: int | None) -> bool: + if not pid: + return False + if sys.platform == "win32": + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + process_query_limited_information = 0x1000 + still_active = 259 + kernel32.OpenProcess.restype = ctypes.c_void_p + handle = kernel32.OpenProcess(process_query_limited_information, False, int(pid)) + if not handle: + return False + exit_code = ctypes.c_ulong() + try: + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == still_active + finally: + kernel32.CloseHandle(handle) + except Exception: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except (OSError, SystemError): + return False + return True + + +def _stop_pid(pid: int, *, force: bool) -> None: + if sys.platform == "win32": + command = ["taskkill", "/PID", str(pid), "/T"] + if force: + command.append("/F") + subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + return + os.kill(pid, signal.SIGKILL if force else signal.SIGTERM) + + +def start_background(paths: WillyPaths, state: WillyState) -> int: + if pid_is_running(state.daemon_pid): + return state.daemon_pid or 0 + paths.ensure_runtime_dirs() + stdout = paths.logs_dir / "daemon.out.log" + stderr = paths.logs_dir / "daemon.err.log" + creationflags = 0 + executable = sys.executable + args = [executable, "-m", "willy", "daemon"] + if getattr(sys, "frozen", False): + args = [executable, "--daemon"] + if sys.platform == "win32": + python = Path(sys.executable) + pythonw = python.with_name("pythonw.exe") + if not getattr(sys, "frozen", False) and python.name.lower() == "python.exe" and pythonw.exists(): + executable = str(pythonw) + args = [executable, "-m", "willy", "daemon"] + creationflags = subprocess.CREATE_NO_WINDOW + with stdout.open("a", encoding="utf-8") as out, stderr.open("a", encoding="utf-8") as err: + process = subprocess.Popen( + args, + stdout=out, + stderr=err, + start_new_session=True, + creationflags=creationflags, + ) + save_state(paths, replace(state, daemon_pid=process.pid)) + return process.pid + + +def stop_background(paths: WillyPaths, state: WillyState, *, timeout_seconds: float = 5.0) -> bool: + if not pid_is_running(state.daemon_pid): + save_state(paths, replace(state, daemon_pid=None)) + return False + assert state.daemon_pid is not None + _stop_pid(state.daemon_pid, force=False) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not pid_is_running(state.daemon_pid): + save_state(paths, replace(state, daemon_pid=None)) + return True + time.sleep(0.1) + _stop_pid(state.daemon_pid, force=True) + save_state(paths, replace(state, daemon_pid=None)) + return True + + +def _flush(paths: WillyPaths, config: WillyConfig, *, description: str) -> None: + state = load_state(paths) + save_state(paths, replace(state, active_operation="saving")) + try: + result = save_profile_changes( + config.repo_path, + description=description, + allow_sensitive=bool(config.repo_private), + asset_dirs=config.asset_dirs, + ) + if result.saved: + write_event(paths, "daemon_commit", count=result.count, subject=result.subject) + finally: + save_state(paths, replace(load_state(paths), active_operation=None)) + + +def _clear_pending(state: WillyState) -> WillyState: + return replace(state, pending_save_since=None, next_save_at=None, pending_save_count=0) + + +def _mark_pending(state: WillyState, *, debounce_seconds: int) -> WillyState: + now = datetime.now().astimezone() + next_save = now + timedelta(seconds=debounce_seconds) + return replace( + state, + pending_save_since=state.pending_save_since or now.isoformat(timespec="seconds"), + next_save_at=next_save.isoformat(timespec="seconds"), + pending_save_count=state.pending_save_count + 1, + ) + + +def run_daemon( + paths: WillyPaths, + config: WillyConfig, + *, + state: WillyState, + is_orca_running_func, + poll_seconds: float = 2.0, + once: bool = False, + stop_event=None, +) -> None: + if not config.repo_path.exists(): + raise WillyError(f"Sync repo path does not exist: {config.repo_path}") + + try: + lock = acquire_lock(paths.locks_dir, "daemon", purpose="watch profiles") + except LockError: + raise + + with lock: + current_state = replace(state, daemon_pid=os.getpid()) + save_state(paths, current_state) + write_event(paths, "daemon_started", repo=str(config.repo_path)) + was_running = False + observer: Observer | None = None + collector: ChangeCollector | None = None + last_change = 0.0 + + try: + while True: + if stop_event is not None and stop_event.is_set(): + return + running = bool(is_orca_running_func()) + now = time.monotonic() + + if running and not was_running: + collector = ChangeCollector(config.repo_path, asset_dirs=config.asset_dirs) + observer = Observer() + observer.schedule(collector, str(config.repo_path), recursive=True) + observer.start() + write_event(paths, "orca_started", repo=str(config.repo_path)) + + if running and collector and collector.consume(): + last_change = now + current_state = _mark_pending(current_state, debounce_seconds=config.debounce_seconds) + save_state(paths, current_state) + write_event(paths, "watcher_batch_seen", repo=str(config.repo_path)) + + if running and last_change and now - last_change >= config.debounce_seconds: + _flush(paths, config, description="Automatic save while OrcaSlicer is running") + current_state = _clear_pending(current_state) + save_state(paths, current_state) + last_change = 0.0 + + if not running and was_running: + if observer: + observer.stop() + observer.join(timeout=5) + observer = None + collector = None + _flush(paths, config, description="Final save after OrcaSlicer closed") + try: + sync_status = sync_repo(config.repo_path) + except GitConflictError as exc: + sync_status = f"conflict: {exc}" + current_state = _clear_pending( + replace( + current_state, + last_sync_at=datetime.now().astimezone().isoformat(timespec="seconds"), + last_sync_status=sync_status, + daemon_pid=os.getpid(), + ) + ) + save_state(paths, current_state) + write_event(paths, "orca_stopped", sync_status=sync_status) + if once: + return + + was_running = running + if once and not running: + return + if stop_event is not None: + stop_event.wait(poll_seconds) + else: + time.sleep(poll_seconds) + finally: + if observer: + observer.stop() + observer.join(timeout=5) + save_state(paths, _clear_pending(replace(current_state, daemon_pid=None))) + write_event(paths, "daemon_stopped", repo=str(config.repo_path)) diff --git a/src/willy/errors.py b/src/willy/errors.py index 2009dc3..fd29752 100644 --- a/src/willy/errors.py +++ b/src/willy/errors.py @@ -1,26 +1,30 @@ -from __future__ import annotations - - -class WillyError(Exception): - """Base class for expected Willy failures.""" - - -class GitError(WillyError): - def __init__( - self, - message: str, - *, - command: tuple[str, ...] = (), - returncode: int | None = None, - stdout: str = "", - stderr: str = "", - ) -> None: - super().__init__(message) - self.command = command - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - - -class ConfigError(WillyError): - """Raised when configuration cannot be loaded or saved safely.""" +from __future__ import annotations + + +class WillyError(Exception): + """Base class for expected Willy failures.""" + + +class GitError(WillyError): + def __init__( + self, + message: str, + *, + command: tuple[str, ...] = (), + returncode: int | None = None, + stdout: str = "", + stderr: str = "", + ) -> None: + super().__init__(message) + self.command = command + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class ConfigError(WillyError): + """Raised when configuration cannot be loaded or saved safely.""" + + +class GitConflictError(GitError): + """Raised when Git reports files that need a human conflict decision.""" diff --git a/src/willy/git.py b/src/willy/git.py index b754725..d6fb065 100644 --- a/src/willy/git.py +++ b/src/willy/git.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from pathlib import Path -from willy.errors import GitError +from willy.errors import GitConflictError, GitError DEFAULT_TIMEOUT_SECONDS = 60 @@ -171,6 +171,24 @@ def status_porcelain(path: Path) -> list[GitStatusEntry]: return entries +def conflicted_paths(path: Path) -> list[str]: + conflicts: list[str] = [] + for entry in status_porcelain(path): + x, y = entry.code[0], entry.code[1] + if "U" in entry.code or entry.code in {"AA", "DD"} or (x == "A" and y == "A") or (x == "D" and y == "D"): + conflicts.append(entry.path) + return conflicts + + +def rebase_in_progress(path: Path) -> bool: + git_dir = run_git(path, "rev-parse", "--git-dir", check=False) + if git_dir.returncode != 0: + return False + raw = git_dir.stdout.strip() + directory = Path(raw) if Path(raw).is_absolute() else path / raw + return (directory / "rebase-merge").exists() or (directory / "rebase-apply").exists() + + def last_commit(path: Path) -> str | None: result = run_git(path, "log", "-1", "--pretty=%h %s", check=False) if result.returncode != 0: @@ -309,7 +327,33 @@ def ensure_commit_identity(path: Path) -> None: def pull_rebase(path: Path) -> GitResult: - return run_git(path, "pull", "--rebase") + try: + return run_git(path, "pull", "--rebase") + except GitError as exc: + conflicts = conflicted_paths(path) + detail = exc.stderr.strip() or exc.stdout.strip() or str(exc) + conflict_markers = ( + "CONFLICT", + "Resolve all conflicts manually", + "could not apply", + "rebase in progress", + ) + if conflicts or any(marker in detail for marker in conflict_markers): + paths = ", ".join(conflicts[:8]) if conflicts else "unknown files" + raise GitConflictError( + "Willy hit a Git conflict while downloading remote changes.\n\n" + f"Conflicted files: {paths}\n\n" + "Fix the conflict in the repository, then run `willy save` or use Force Sync again.", + command=exc.command, + returncode=exc.returncode, + stdout=exc.stdout, + stderr=exc.stderr, + ) from exc + raise + + +def fetch(path: Path) -> GitResult: + return run_git(path, "fetch", "--prune") def push(path: Path) -> GitResult: diff --git a/src/willy/operations.py b/src/willy/operations.py index 07c192c..26c1304 100644 --- a/src/willy/operations.py +++ b/src/willy/operations.py @@ -1,114 +1,128 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from willy.files import classify_path -from willy.git import ( - add_paths, - commit, - current_branch, - disable_redaction_filter, - ensure_commit_identity, - ensure_redaction_filter, - is_repo, - pull_rebase, - push, - push_set_upstream, - remote_url, - status_porcelain, - upstream_branch, -) -from willy.metadata import change_type_from_status, commit_body, commit_subject, extract_metadata - - -@dataclass(frozen=True) -class SaveResult: - saved: bool - count: int - subject: str | None = None - - -@dataclass(frozen=True) -class UnsavedSummary: - count: int - paths: list[Path] - - -def changed_trackable_paths(repo: Path, *, asset_dirs: tuple[Path, ...] = ()) -> tuple[str, list[Path]]: - changed_paths: list[Path] = [] - first_event = "modified" - for entry in status_porcelain(repo): - event = change_type_from_status(entry.code) - candidates = [part.strip() for part in entry.path.split(" -> ") if part.strip()] - for candidate in candidates: - candidate_path = Path(candidate) - if classify_path(repo, repo / candidate_path, asset_dirs=asset_dirs).trackable: - changed_paths.append(candidate_path) - if len(changed_paths) == 1: - first_event = event - return first_event, sorted(set(changed_paths)) - - -def unsaved_summary(repo: Path, *, limit: int = 12, asset_dirs: tuple[Path, ...] = ()) -> UnsavedSummary: - _event, paths = changed_trackable_paths(repo, asset_dirs=asset_dirs) - return UnsavedSummary(count=len(paths), paths=paths[:limit]) - - -def save_profile_changes( - repo: Path, - *, - description: str, - allow_sensitive: bool = False, - asset_dirs: tuple[Path, ...] = (), -) -> SaveResult: - if not is_repo(repo): - raise ValueError(f"Not a Git repository: {repo}") - - first_event, unique_paths = changed_trackable_paths(repo, asset_dirs=asset_dirs) - if not unique_paths: - return SaveResult(saved=False, count=0) - - ensure_commit_identity(repo) - stage_paths = list(unique_paths) - attributes_path = disable_redaction_filter(repo) if allow_sensitive else ensure_redaction_filter(repo) - if attributes_path: - stage_paths.append(attributes_path) - add_paths(repo, stage_paths) - - if len(unique_paths) == 1: - commit_path = unique_paths[0] - metadata = extract_metadata(repo, commit_path, asset_dirs=asset_dirs) - subject = commit_subject(first_event, metadata, commit_path) - body = commit_body( - metadata=metadata, - event=first_event, - relative_path=commit_path, - description=description, - ) - else: - commit_path = Path(f"{len(unique_paths)} files") - metadata = extract_metadata(repo, unique_paths[0], asset_dirs=asset_dirs) - subject = commit_subject("mixed", metadata, commit_path) - body = commit_body( - metadata=metadata, - event="mixed", - relative_path=commit_path, - description=description, - changed_paths=unique_paths, - ) - - commit(repo, subject, body) - return SaveResult(saved=True, count=len(unique_paths), subject=subject) - - -def sync_repo(repo: Path) -> str: - if not remote_url(repo): - return "no remote configured" - branch = current_branch(repo) - if branch and not upstream_branch(repo): - push_set_upstream(repo, "origin", branch) - return "synced" - pull_rebase(repo) - push(repo) - return "synced" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from willy.files import classify_path +from willy.git import ( + add_paths, + commit, + current_branch, + disable_redaction_filter, + ensure_commit_identity, + ensure_redaction_filter, + fetch, + is_repo, + pull_rebase, + push, + push_set_upstream, + rebase_in_progress, + remote_url, + status_porcelain, + upstream_branch, +) +from willy.metadata import change_type_from_status, commit_body, commit_subject, extract_metadata + + +@dataclass(frozen=True) +class SaveResult: + saved: bool + count: int + subject: str | None = None + + +@dataclass(frozen=True) +class UnsavedSummary: + count: int + paths: list[Path] + + +def changed_trackable_paths(repo: Path, *, asset_dirs: tuple[Path, ...] = ()) -> tuple[str, list[Path]]: + changed_paths: list[Path] = [] + first_event = "modified" + for entry in status_porcelain(repo): + event = change_type_from_status(entry.code) + candidates = [part.strip() for part in entry.path.split(" -> ") if part.strip()] + for candidate in candidates: + candidate_path = Path(candidate) + if classify_path(repo, repo / candidate_path, asset_dirs=asset_dirs).trackable: + changed_paths.append(candidate_path) + if len(changed_paths) == 1: + first_event = event + return first_event, sorted(set(changed_paths)) + + +def unsaved_summary(repo: Path, *, limit: int = 12, asset_dirs: tuple[Path, ...] = ()) -> UnsavedSummary: + _event, paths = changed_trackable_paths(repo, asset_dirs=asset_dirs) + return UnsavedSummary(count=len(paths), paths=paths[:limit]) + + +def save_profile_changes( + repo: Path, + *, + description: str, + allow_sensitive: bool = False, + asset_dirs: tuple[Path, ...] = (), +) -> SaveResult: + if not is_repo(repo): + raise ValueError(f"Not a Git repository: {repo}") + + first_event, unique_paths = changed_trackable_paths(repo, asset_dirs=asset_dirs) + if not unique_paths: + return SaveResult(saved=False, count=0) + + ensure_commit_identity(repo) + stage_paths = list(unique_paths) + attributes_path = disable_redaction_filter(repo) if allow_sensitive else ensure_redaction_filter(repo) + if attributes_path: + stage_paths.append(attributes_path) + add_paths(repo, stage_paths) + + if len(unique_paths) == 1: + commit_path = unique_paths[0] + metadata = extract_metadata(repo, commit_path, asset_dirs=asset_dirs) + subject = commit_subject(first_event, metadata, commit_path) + body = commit_body( + metadata=metadata, + event=first_event, + relative_path=commit_path, + description=description, + ) + else: + commit_path = Path(f"{len(unique_paths)} files") + metadata = extract_metadata(repo, unique_paths[0], asset_dirs=asset_dirs) + subject = commit_subject("mixed", metadata, commit_path) + body = commit_body( + metadata=metadata, + event="mixed", + relative_path=commit_path, + description=description, + changed_paths=unique_paths, + ) + + commit(repo, subject, body) + return SaveResult(saved=True, count=len(unique_paths), subject=subject) + + +def sync_repo(repo: Path) -> str: + if not remote_url(repo): + return "no remote configured" + if rebase_in_progress(repo): + return ( + "conflict: Willy paused because a previous Git rebase needs attention. " + "Fix the conflict in the repository, then run `willy save` or Force Sync again." + ) + branch = current_branch(repo) + if branch and not upstream_branch(repo): + push_set_upstream(repo, "origin", branch) + return "synced" + pull_rebase(repo) + push(repo) + return "synced" + + +def fetch_remote_updates(repo: Path) -> str: + if not remote_url(repo): + return "no remote configured" + fetch(repo) + return "checked remote" diff --git a/src/willy/qt_tray.py b/src/willy/qt_tray.py index 3be7951..94f12bc 100644 --- a/src/willy/qt_tray.py +++ b/src/willy/qt_tray.py @@ -16,6 +16,8 @@ _start_embedded_daemon, _stop_embedded_daemon, _tray_open_config_request_mtime, + check_remote_on_load, + force_sync, snapshot, ) from willy.tray_settings import configure_repository @@ -101,6 +103,20 @@ def show_status() -> None: close.clicked.connect(dialog.close) dialog.exec() + def run_force_sync() -> None: + try: + message = force_sync(paths) + except Exception as exc: + message = f"Force sync failed:\n{exc}" + refresh() + QMessageBox.information(None, "Willy Sync", message) + + def remote_check_on_load() -> None: + message = check_remote_on_load(paths) + refresh() + if "available" in message or "failed" in message or "upstream" in message: + tray.showMessage("Willy", message, QSystemTrayIcon.MessageIcon.Information, 8000) + def line_with_browse(label: str, value: str, parent: QWidget) -> tuple[QLineEdit, QPushButton]: edit = QLineEdit(value) browse = QPushButton("Browse...") @@ -315,17 +331,21 @@ def exit_tray() -> None: app.quit() status_action = QAction("Status", menu) + sync_action = QAction("Force Sync / Download", menu) settings_action = QAction("Settings", menu) exit_action = QAction("Exit", menu) status_action.triggered.connect(show_status) + sync_action.triggered.connect(run_force_sync) settings_action.triggered.connect(open_settings) exit_action.triggered.connect(exit_tray) menu.addAction(status_action) + menu.addAction(sync_action) menu.addAction(settings_action) menu.addSeparator() menu.addAction(exit_action) tray.setContextMenu(menu) tray.show() + QTimer.singleShot(100, remote_check_on_load) refresh_timer = QTimer() refresh_timer.timeout.connect(refresh) diff --git a/src/willy/statusbar.py b/src/willy/statusbar.py index 0dfa33f..730be4b 100644 --- a/src/willy/statusbar.py +++ b/src/willy/statusbar.py @@ -10,6 +10,7 @@ from willy.config import WillyConfig, load_config, load_state, save_config, save_state from willy.daemon import pid_is_running, run_daemon +from willy.errors import GitConflictError from willy.git import ( current_branch, is_repo, @@ -20,7 +21,7 @@ ) from willy.launchd import disable_launchd, enable_launchd, launchd_enabled from willy.logging import setup_logging, write_event -from willy.operations import save_profile_changes, sync_repo, unsaved_summary +from willy.operations import fetch_remote_updates, save_profile_changes, sync_repo, unsaved_summary from willy.orca import is_orca_running from willy.paths import WillyPaths, default_paths from willy.windows_startup import disable_windows_startup, enable_windows_startup, windows_startup_enabled @@ -191,6 +192,8 @@ def _sync_line(state) -> str: def _phase(state, *, unsaved_count: int, sync_pending: bool) -> str: + if state.last_sync_status and state.last_sync_status.startswith("conflict:"): + return "conflict" if state.active_operation == "saving": return "saving" if state.pending_save_count or state.next_save_at or unsaved_count or sync_pending: @@ -214,12 +217,13 @@ def snapshot( sync_pending = bool(delta and delta.pending) daemon_running = _embedded_daemon_running() or pid_is_running(state.daemon_pid) phase = _phase(state, unsaved_count=unsaved_count, sync_pending=sync_pending) - title = {"no pending": "W", "pending": "W*", "saving": "W..."}[phase] + title = {"no pending": "W", "pending": "W*", "saving": "W...", "conflict": "W!"}[phase] pending_count = unsaved_count or state.pending_save_count summary = { "no pending": "Willy: no pending changes", "pending": f"Willy: {pending_count} pending change(s)" if pending_count else "Willy: sync pending", "saving": "Willy: saving", + "conflict": "Willy: conflict needs your decision", }[phase] branch = current_branch(config.repo_path) if repo_ready else None remote = remote_url(config.repo_path) if repo_ready else None @@ -262,6 +266,41 @@ def snapshot( ) +def check_remote_on_load(paths: WillyPaths | None = None) -> str: + paths = paths or default_paths() + setup_logging(paths) + config = load_config(paths) + if not config.repo_path.exists() or not is_repo(config.repo_path): + return "Sync repo is not ready. Run willy setup first." + try: + fetch_status = fetch_remote_updates(config.repo_path) + delta = sync_delta(config.repo_path) + now = datetime.now().astimezone().isoformat(timespec="seconds") + if fetch_status == "no remote configured": + message = fetch_status + elif delta.pending: + if delta.needs_upstream: + message = "Remote check complete. Sync setup still needs an upstream branch." + elif delta.behind: + message = ( + f"Remote updates are available ({delta.behind} commit(s) to download). Use Force Sync / Download." + ) + elif delta.ahead: + message = f"Local saves are waiting to upload ({delta.ahead} commit(s)). Use Force Sync / Download." + else: + message = fetch_status + else: + message = "Remote check complete. Willy is up to date." + save_state(paths, replace(load_state(paths), last_sync_at=now, last_sync_status=message)) + write_event(paths, "statusbar_load_remote_check", status=message) + return message + except Exception as exc: + message = f"Remote check failed: {exc}" + save_state(paths, replace(load_state(paths), last_sync_status=message)) + write_event(paths, "statusbar_load_remote_check_failed", error=str(exc), traceback=traceback.format_exc()) + return message + + def force_sync(paths: WillyPaths | None = None) -> str: paths = paths or default_paths() setup_logging(paths) @@ -277,7 +316,10 @@ def force_sync(paths: WillyPaths | None = None) -> str: allow_sensitive=bool(config.repo_private), asset_dirs=config.asset_dirs, ) - sync_status = sync_repo(config.repo_path) + try: + sync_status = sync_repo(config.repo_path) + except GitConflictError as exc: + sync_status = f"conflict: {exc}" now = datetime.now().astimezone().isoformat(timespec="seconds") state = load_state(paths) save_state(paths, replace(state, active_operation=None, last_sync_at=now, last_sync_status=sync_status)) @@ -473,6 +515,10 @@ def quit_(self, _sender): app.setActivationPolicy_(NSApplicationActivationPolicyAccessory) controller = StatusBarController.alloc().initWithPollSeconds_(poll_seconds) controller.refresh_(None) + load_message = check_remote_on_load(paths) + controller.refresh_(None) + if "available" in load_message or "failed" in load_message or "upstream" in load_message: + controller.showMessage_title_style_(load_message, "Willy", NSInformationalAlertStyle) write_event(paths, "statusbar_started", platform="darwin") try: app.run() diff --git a/tests/test_git.py b/tests/test_git.py index 39f06e4..2f29eb8 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,92 +1,136 @@ -from pathlib import Path - -from willy.git import ( - clone_remote, - current_branch, - is_repo, - last_commit, - remote_refs, - remote_url, - run_git, - status_porcelain, - validate_remote_access, -) - - -def test_git_repo_helpers(tmp_path: Path) -> None: - run_git(tmp_path, "init") - run_git(tmp_path, "config", "user.email", "test@example.com") - run_git(tmp_path, "config", "user.name", "Willy Test") - (tmp_path / "profile.json").write_text('{"name": "Profile"}\n', encoding="utf-8") - run_git(tmp_path, "add", "profile.json") - run_git(tmp_path, "commit", "-m", "initial") - - assert is_repo(tmp_path) - assert current_branch(tmp_path) in {"main", "master"} - assert remote_url(tmp_path) is None - assert last_commit(tmp_path) is not None - - -def test_status_porcelain_reports_changes(tmp_path: Path) -> None: - run_git(tmp_path, "init") - (tmp_path / "profile.json").write_text("{}\n", encoding="utf-8") - - entries = status_porcelain(tmp_path) - - assert len(entries) == 1 - assert entries[0].code == "??" - assert entries[0].path == "profile.json" - - -def test_status_porcelain_preserves_paths_with_spaces(tmp_path: Path) -> None: - run_git(tmp_path, "init") - profile = tmp_path / "default" / "filament" / "PETG Fast.json" - profile.parent.mkdir(parents=True) - profile.write_text("{}\n", encoding="utf-8") - - entries = status_porcelain(tmp_path) - - assert entries[0].path == "default/filament/PETG Fast.json" - - -def test_run_git_can_return_nonzero_without_raising(tmp_path: Path) -> None: - result = run_git(tmp_path, "rev-parse", "--is-inside-work-tree", check=False) - - assert result.returncode != 0 - assert isinstance(result.stderr, str) - - -def test_validate_remote_access_with_local_bare_repo(tmp_path: Path) -> None: - remote = tmp_path / "remote.git" - work = tmp_path / "work" - remote.mkdir() - work.mkdir() - run_git(remote, "init", "--bare") - - result = validate_remote_access(work, str(remote)) - - assert result.returncode == 0 - - -def test_remote_refs_and_clone_remote(tmp_path: Path) -> None: - remote = tmp_path / "remote.git" - source = tmp_path / "source" - clone = tmp_path / "clone" - remote.mkdir() - source.mkdir() - run_git(remote, "init", "--bare") - run_git(source, "init") - run_git(source, "config", "user.email", "test@example.com") - run_git(source, "config", "user.name", "Willy Test") - (source / "profile.json").write_text("{}\n", encoding="utf-8") - run_git(source, "add", "profile.json") - run_git(source, "commit", "-m", "initial") - run_git(source, "branch", "-M", "main") - run_git(source, "remote", "add", "origin", str(remote)) - run_git(source, "push", "-u", "origin", "main") - - refs = remote_refs(source, str(remote)) - clone_remote(tmp_path, str(remote), clone, branch="main") - - assert "refs/heads/main" in refs - assert (clone / "profile.json").exists() +from pathlib import Path + +import pytest + +from willy.errors import GitConflictError +from willy.git import ( + clone_remote, + conflicted_paths, + current_branch, + is_repo, + last_commit, + pull_rebase, + rebase_in_progress, + remote_refs, + remote_url, + run_git, + status_porcelain, + validate_remote_access, +) + + +def test_git_repo_helpers(tmp_path: Path) -> None: + run_git(tmp_path, "init") + run_git(tmp_path, "config", "user.email", "test@example.com") + run_git(tmp_path, "config", "user.name", "Willy Test") + (tmp_path / "profile.json").write_text('{"name": "Profile"}\n', encoding="utf-8") + run_git(tmp_path, "add", "profile.json") + run_git(tmp_path, "commit", "-m", "initial") + + assert is_repo(tmp_path) + assert current_branch(tmp_path) in {"main", "master"} + assert remote_url(tmp_path) is None + assert last_commit(tmp_path) is not None + + +def test_status_porcelain_reports_changes(tmp_path: Path) -> None: + run_git(tmp_path, "init") + (tmp_path / "profile.json").write_text("{}\n", encoding="utf-8") + + entries = status_porcelain(tmp_path) + + assert len(entries) == 1 + assert entries[0].code == "??" + assert entries[0].path == "profile.json" + + +def test_status_porcelain_preserves_paths_with_spaces(tmp_path: Path) -> None: + run_git(tmp_path, "init") + profile = tmp_path / "default" / "filament" / "PETG Fast.json" + profile.parent.mkdir(parents=True) + profile.write_text("{}\n", encoding="utf-8") + + entries = status_porcelain(tmp_path) + + assert entries[0].path == "default/filament/PETG Fast.json" + + +def test_run_git_can_return_nonzero_without_raising(tmp_path: Path) -> None: + result = run_git(tmp_path, "rev-parse", "--is-inside-work-tree", check=False) + + assert result.returncode != 0 + assert isinstance(result.stderr, str) + + +def test_validate_remote_access_with_local_bare_repo(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + work = tmp_path / "work" + remote.mkdir() + work.mkdir() + run_git(remote, "init", "--bare") + + result = validate_remote_access(work, str(remote)) + + assert result.returncode == 0 + + +def test_remote_refs_and_clone_remote(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + source = tmp_path / "source" + clone = tmp_path / "clone" + remote.mkdir() + source.mkdir() + run_git(remote, "init", "--bare") + run_git(source, "init") + run_git(source, "config", "user.email", "test@example.com") + run_git(source, "config", "user.name", "Willy Test") + (source / "profile.json").write_text("{}\n", encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "initial") + run_git(source, "branch", "-M", "main") + run_git(source, "remote", "add", "origin", str(remote)) + run_git(source, "push", "-u", "origin", "main") + + refs = remote_refs(source, str(remote)) + clone_remote(tmp_path, str(remote), clone, branch="main") + + assert "refs/heads/main" in refs + assert (clone / "profile.json").exists() + + +def test_pull_rebase_raises_conflict_error_and_reports_paths(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + source = tmp_path / "source" + clone = tmp_path / "clone" + remote.mkdir() + source.mkdir() + run_git(remote, "init", "--bare") + run_git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + run_git(source, "init") + run_git(source, "config", "user.email", "test@example.com") + run_git(source, "config", "user.name", "Willy Test") + (source / "profile.json").write_text('{"name": "base"}\n', encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "initial") + run_git(source, "branch", "-M", "main") + run_git(source, "remote", "add", "origin", str(remote)) + run_git(source, "push", "-u", "origin", "main") + run_git(tmp_path, "clone", "--branch", "main", str(remote), str(clone)) + run_git(clone, "config", "user.email", "test@example.com") + run_git(clone, "config", "user.name", "Willy Test") + + (source / "profile.json").write_text('{"name": "remote"}\n', encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "remote edit") + run_git(source, "push") + (clone / "profile.json").write_text('{"name": "local"}\n', encoding="utf-8") + run_git(clone, "add", "profile.json") + run_git(clone, "commit", "-m", "local edit") + + with pytest.raises(GitConflictError) as raised: + pull_rebase(clone) + + assert "Willy hit a Git conflict" in str(raised.value) + assert "profile.json" in str(raised.value) + assert conflicted_paths(clone) == ["profile.json"] + assert rebase_in_progress(clone) diff --git a/tests/test_operations.py b/tests/test_operations.py index b6735d0..ebcb231 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -1,107 +1,144 @@ -from pathlib import Path - -from willy.git import config_get_local, run_git -from willy.operations import save_profile_changes, sync_repo, unsaved_summary -from willy.redact import REDACTED - - -def test_save_profile_changes_commits_json_and_sets_identity(tmp_path: Path) -> None: - run_git(tmp_path, "init") - profile = tmp_path / "default" / "filament" / "ABS.json" - sidecar = tmp_path / "default" / "filament" / "ABS.info" - profile.parent.mkdir(parents=True) - profile.write_text('{"name": "ABS"}\n', encoding="utf-8") - sidecar.write_text("setting_id = PFUS\n", encoding="utf-8") - - result = save_profile_changes(tmp_path, description="manual test") - - assert result.saved - assert result.count == 1 - author = run_git(tmp_path, "log", "-1", "--pretty=%an <%ae>").stdout.strip() - assert author == "Willy " - assert config_get_local(tmp_path, "user.name") == "Willy" - assert config_get_local(tmp_path, "user.email") == "willy@local" - status = run_git(tmp_path, "status", "--porcelain").stdout - assert "ABS.json" not in status - assert "ABS.info" in status - - -def test_save_profile_changes_clean_repo_returns_unsaved(tmp_path: Path) -> None: - run_git(tmp_path, "init") - - result = save_profile_changes(tmp_path, description="nothing") - - assert not result.saved - assert result.count == 0 - - -def test_save_profile_changes_redacts_sensitive_json_by_default(tmp_path: Path) -> None: - run_git(tmp_path, "init") - profile = tmp_path / "default" / "machine" / "Printer.json" - profile.parent.mkdir(parents=True) - profile.write_text( - '{"name": "Printer", "print_host": "192.168.1.42", "printhost_apikey": "secret-key"}\n', - encoding="utf-8", - ) - - result = save_profile_changes(tmp_path, description="public save") - - assert result.saved - blob = run_git(tmp_path, "show", "HEAD:default/machine/Printer.json").stdout - assert REDACTED in blob - assert "192.168.1.42" not in blob - assert "secret-key" not in blob - assert "secret-key" in profile.read_text(encoding="utf-8") - assert "*.json filter=willy-redact" in run_git(tmp_path, "show", "HEAD:.gitattributes").stdout - - -def test_save_profile_changes_keeps_sensitive_json_when_allowed(tmp_path: Path) -> None: - run_git(tmp_path, "init") - profile = tmp_path / "default" / "machine" / "Printer.json" - profile.parent.mkdir(parents=True) - profile.write_text( - '{"name": "Printer", "print_host": "192.168.1.42", "printhost_apikey": "secret-key"}\n', - encoding="utf-8", - ) - - result = save_profile_changes(tmp_path, description="private save", allow_sensitive=True) - - assert result.saved - blob = run_git(tmp_path, "show", "HEAD:default/machine/Printer.json").stdout - assert "192.168.1.42" in blob - assert "secret-key" in blob - assert run_git(tmp_path, "show", "HEAD:.gitattributes", check=False).returncode != 0 - - -def test_unsaved_summary_counts_user_id_profiles(tmp_path: Path) -> None: - run_git(tmp_path, "init") - profile = tmp_path / "2765349417" / "process" / "Fast.json" - sidecar = tmp_path / "2765349417" / "process" / "Fast.info" - profile.parent.mkdir(parents=True) - profile.write_text("{}\n", encoding="utf-8") - sidecar.write_text("setting_id = PPUS\n", encoding="utf-8") - - summary = unsaved_summary(tmp_path) - - assert summary.count == 1 - assert summary.paths == [Path("2765349417/process/Fast.json")] - - -def test_sync_repo_without_changes_still_pushes_existing_commit(tmp_path: Path) -> None: - remote = tmp_path / "remote.git" - repo = tmp_path / "repo" - remote.mkdir() - repo.mkdir() - run_git(remote, "init", "--bare") - run_git(repo, "init") - run_git(repo, "branch", "-M", "main") - run_git(repo, "remote", "add", "origin", str(remote)) - run_git(repo, "config", "user.email", "test@example.com") - run_git(repo, "config", "user.name", "Willy Test") - (repo / "default").mkdir() - (repo / "default" / "profile.json").write_text("{}\n", encoding="utf-8") - run_git(repo, "add", "default/profile.json") - run_git(repo, "commit", "-m", "initial") - - assert sync_repo(repo) == "synced" - assert sync_repo(repo) == "synced" +from pathlib import Path + +from willy.git import config_get_local, run_git +from willy.operations import fetch_remote_updates, save_profile_changes, sync_repo, unsaved_summary +from willy.redact import REDACTED + + +def test_save_profile_changes_commits_json_and_sets_identity(tmp_path: Path) -> None: + run_git(tmp_path, "init") + profile = tmp_path / "default" / "filament" / "ABS.json" + sidecar = tmp_path / "default" / "filament" / "ABS.info" + profile.parent.mkdir(parents=True) + profile.write_text('{"name": "ABS"}\n', encoding="utf-8") + sidecar.write_text("setting_id = PFUS\n", encoding="utf-8") + + result = save_profile_changes(tmp_path, description="manual test") + + assert result.saved + assert result.count == 1 + author = run_git(tmp_path, "log", "-1", "--pretty=%an <%ae>").stdout.strip() + assert author == "Willy " + assert config_get_local(tmp_path, "user.name") == "Willy" + assert config_get_local(tmp_path, "user.email") == "willy@local" + status = run_git(tmp_path, "status", "--porcelain").stdout + assert "ABS.json" not in status + assert "ABS.info" in status + + +def test_save_profile_changes_clean_repo_returns_unsaved(tmp_path: Path) -> None: + run_git(tmp_path, "init") + + result = save_profile_changes(tmp_path, description="nothing") + + assert not result.saved + assert result.count == 0 + + +def test_save_profile_changes_redacts_sensitive_json_by_default(tmp_path: Path) -> None: + run_git(tmp_path, "init") + profile = tmp_path / "default" / "machine" / "Printer.json" + profile.parent.mkdir(parents=True) + profile.write_text( + '{"name": "Printer", "print_host": "192.168.1.42", "printhost_apikey": "secret-key"}\n', + encoding="utf-8", + ) + + result = save_profile_changes(tmp_path, description="public save") + + assert result.saved + blob = run_git(tmp_path, "show", "HEAD:default/machine/Printer.json").stdout + assert REDACTED in blob + assert "192.168.1.42" not in blob + assert "secret-key" not in blob + assert "secret-key" in profile.read_text(encoding="utf-8") + assert "*.json filter=willy-redact" in run_git(tmp_path, "show", "HEAD:.gitattributes").stdout + + +def test_save_profile_changes_keeps_sensitive_json_when_allowed(tmp_path: Path) -> None: + run_git(tmp_path, "init") + profile = tmp_path / "default" / "machine" / "Printer.json" + profile.parent.mkdir(parents=True) + profile.write_text( + '{"name": "Printer", "print_host": "192.168.1.42", "printhost_apikey": "secret-key"}\n', + encoding="utf-8", + ) + + result = save_profile_changes(tmp_path, description="private save", allow_sensitive=True) + + assert result.saved + blob = run_git(tmp_path, "show", "HEAD:default/machine/Printer.json").stdout + assert "192.168.1.42" in blob + assert "secret-key" in blob + assert run_git(tmp_path, "show", "HEAD:.gitattributes", check=False).returncode != 0 + + +def test_unsaved_summary_counts_user_id_profiles(tmp_path: Path) -> None: + run_git(tmp_path, "init") + profile = tmp_path / "2765349417" / "process" / "Fast.json" + sidecar = tmp_path / "2765349417" / "process" / "Fast.info" + profile.parent.mkdir(parents=True) + profile.write_text("{}\n", encoding="utf-8") + sidecar.write_text("setting_id = PPUS\n", encoding="utf-8") + + summary = unsaved_summary(tmp_path) + + assert summary.count == 1 + assert summary.paths == [Path("2765349417/process/Fast.json")] + + +def test_sync_repo_without_changes_still_pushes_existing_commit(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + repo = tmp_path / "repo" + remote.mkdir() + repo.mkdir() + run_git(remote, "init", "--bare") + run_git(repo, "init") + run_git(repo, "branch", "-M", "main") + run_git(repo, "remote", "add", "origin", str(remote)) + run_git(repo, "config", "user.email", "test@example.com") + run_git(repo, "config", "user.name", "Willy Test") + (repo / "default").mkdir() + (repo / "default" / "profile.json").write_text("{}\n", encoding="utf-8") + run_git(repo, "add", "default/profile.json") + run_git(repo, "commit", "-m", "initial") + + assert sync_repo(repo) == "synced" + assert sync_repo(repo) == "synced" + + +def test_sync_repo_reports_rebase_conflict_in_progress(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + repo = tmp_path / "repo" + remote.mkdir() + repo.mkdir() + run_git(remote, "init", "--bare") + run_git(repo, "init") + run_git(repo, "remote", "add", "origin", str(remote)) + (repo / ".git" / "rebase-merge").mkdir() + + message = sync_repo(repo) + + assert message.startswith("conflict:") + assert "Fix the conflict" in message + + +def test_fetch_remote_updates_fetches_remote_refs(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + source = tmp_path / "source" + repo = tmp_path / "repo" + remote.mkdir() + source.mkdir() + run_git(remote, "init", "--bare") + run_git(source, "init") + run_git(source, "config", "user.email", "test@example.com") + run_git(source, "config", "user.name", "Willy Test") + (source / "profile.json").write_text("{}\n", encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "initial") + run_git(source, "branch", "-M", "main") + run_git(source, "remote", "add", "origin", str(remote)) + run_git(source, "push", "-u", "origin", "main") + run_git(tmp_path, "clone", "--branch", "main", str(remote), str(repo)) + + assert fetch_remote_updates(repo) == "checked remote" diff --git a/tests/test_statusbar.py b/tests/test_statusbar.py index 47140f3..587fe02 100644 --- a/tests/test_statusbar.py +++ b/tests/test_statusbar.py @@ -13,6 +13,7 @@ _request_existing_tray_open_config, _tray_open_config_request_mtime, _tray_open_config_request_path, + check_remote_on_load, dismiss_tray_welcome, force_sync, snapshot, @@ -97,6 +98,18 @@ def test_snapshot_shows_saving_when_operation_is_active(tmp_path, monkeypatch) - assert "Daemon: running" in current.details +def test_snapshot_shows_conflict_status(tmp_path, monkeypatch) -> None: + paths, _repo = _configured_repo(tmp_path) + save_state(paths, WillyState(last_sync_status="conflict: fix files", daemon_pid=123)) + monkeypatch.setattr("willy.statusbar.pid_is_running", lambda pid: True) + + current = snapshot(paths, is_orca_running_func=lambda: False) + + assert current.phase == "conflict" + assert current.icon_title == "W!" + assert current.summary == "Willy: conflict needs your decision" + + def test_force_sync_saves_and_clears_operation(tmp_path) -> None: paths, repo = _configured_repo(tmp_path) profile = repo / "default" / "filament" / "PETG.json" @@ -111,6 +124,60 @@ def test_force_sync_saves_and_clears_operation(tmp_path) -> None: assert "PETG" in run_git(repo, "show", "HEAD:default/filament/PETG.json").stdout +def test_check_remote_on_load_notifies_when_remote_updates_are_available(tmp_path) -> None: + home = tmp_path / "home" + paths = default_paths(home) + remote = tmp_path / "remote.git" + source = tmp_path / "source" + repo = tmp_path / "repo" + remote.mkdir() + source.mkdir() + run_git(remote, "init", "--bare") + run_git(source, "init") + run_git(source, "config", "user.email", "test@example.com") + run_git(source, "config", "user.name", "Willy Test") + (source / "profile.json").write_text("{}\n", encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "initial") + run_git(source, "branch", "-M", "main") + run_git(source, "remote", "add", "origin", str(remote)) + run_git(source, "push", "-u", "origin", "main") + run_git(tmp_path, "clone", "--branch", "main", str(remote), str(repo)) + (source / "profile.json").write_text('{"name": "new"}\n', encoding="utf-8") + run_git(source, "add", "profile.json") + run_git(source, "commit", "-m", "remote update") + run_git(source, "push") + save_config(paths, WillyConfig(orca_user_dir=repo, repo_path=repo)) + + message = check_remote_on_load(paths) + + assert "Remote updates are available" in message + assert load_state(paths).last_sync_status == message + + +def test_check_remote_on_load_reports_missing_remote(tmp_path) -> None: + paths, _repo = _configured_repo(tmp_path) + + message = check_remote_on_load(paths) + + assert message == "no remote configured" + assert load_state(paths).last_sync_status == "no remote configured" + + +def test_check_remote_on_load_records_fetch_failure(tmp_path, monkeypatch) -> None: + paths, _repo = _configured_repo(tmp_path) + + def fail_fetch(_repo): + raise RuntimeError("nope") + + monkeypatch.setattr("willy.statusbar.fetch_remote_updates", fail_fetch) + + message = check_remote_on_load(paths) + + assert message == "Remote check failed: nope" + assert load_state(paths).last_sync_status == message + + def test_configure_project_folder_saves_folder_inside_repo(tmp_path) -> None: paths, repo = _configured_repo(tmp_path) folder = repo / "projects" diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..c8d372c --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,24 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_ci_workflow_runs_for_pull_requests_and_pre_releases() -> None: + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + + assert "pull_request:" in workflow + assert "release:" in workflow + assert "types: [published]" in workflow + assert "github.event.release.prerelease == true" in workflow + assert "ruff check src tests" in workflow + assert "pytest" in workflow + + +def test_release_workflow_builds_and_uploads_release_assets() -> None: + workflow = (ROOT / ".github" / "workflows" / "release-build.yml").read_text(encoding="utf-8") + + assert "release:" in workflow + assert "types: [published]" in workflow + assert "python -m build" in workflow + assert ".\\scripts\\dev.ps1 build-exe" in workflow + assert "gh release upload" in workflow