diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84af0bf..5a54ebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,85 @@ jobs: - name: Run unit tests run: python -m unittest discover -s tests -v + + install-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + version: '0.10.7' + + - name: Test editable install mode + run: | + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + expected_version="$(cat VERSION)" + + uv tool install --editable . + test "$(syncwheel --version)" = "syncwheel ${expected_version}" + + git remote remove origin || true + state_dir="$(mktemp -d)" + SYNCWHEEL_UPDATE_STATE_PATH="${state_dir}/state.json" \ + SYNCWHEEL_UPDATE_SETTINGS_PATH="${state_dir}/settings.json" \ + syncwheel self status --json > "${state_dir}/editable-status.json" + + python - "${state_dir}/editable-status.json" <<'PY' + import json + import sys + + data = json.load(open(sys.argv[1])) + assert data["status"]["install_kind"] == "git-clone", data + PY + + - name: Test uv-tool install mode + run: | + set -euo pipefail + expected_version="$(cat VERSION)" + tool_root="$(mktemp -d)" + export UV_TOOL_DIR="${tool_root}/tools" + export UV_TOOL_BIN_DIR="${tool_root}/bin" + mkdir -p "$UV_TOOL_BIN_DIR" + + uv tool install "git+file://$PWD" + test "$("$UV_TOOL_BIN_DIR/syncwheel" --version)" = "syncwheel ${expected_version}" + + status_env=( + "SYNCWHEEL_UPDATE_STATE_PATH=${tool_root}/state.json" + "SYNCWHEEL_UPDATE_SETTINGS_PATH=${tool_root}/settings.json" + "SYNCWHEEL_REMOTE_VERSION_URL=file://$PWD/VERSION" + "SYNCWHEEL_UV_TOOL_SOURCE=git+file://$PWD" + ) + + env "${status_env[@]}" "$UV_TOOL_BIN_DIR/syncwheel" self status --json > "${tool_root}/uv-status.json" + python - "${tool_root}/uv-status.json" <<'PY' + import json + import sys + + data = json.load(open(sys.argv[1])) + assert data["status"]["install_kind"] == "uv-tool", data + PY + + env "${status_env[@]}" "$UV_TOOL_BIN_DIR/syncwheel" self check-update --fetch --json > "${tool_root}/check-update.json" + python - "${tool_root}/check-update.json" "$expected_version" <<'PY' + import json + import sys + + data = json.load(open(sys.argv[1])) + expected = sys.argv[2] + assert data["current_version"] == expected, data + assert data["latest_version"] == expected, data + assert data["update_available"] is False, data + assert data["remote_version_url"].startswith("file://"), data + PY diff --git a/.gitignore b/.gitignore index ec1a7b0..37e9314 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__/ *.pyc +*.egg-info/ .syncwheel/ .tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4918624..81804ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.18.0 - 2026-06-10 + +- Add uv packaging with a `syncwheel` console script while preserving direct + `python3 scripts/syncwheel.py ...` execution. +- Add an idempotent `scripts/install.sh` for production uv installs and + editable development installs. +- Extend `self status`, `self check-update`, and `self update` to distinguish + git checkouts, uv tool installs, and plain script execution. +- Teach uv tool installs to check the upstream `VERSION` file directly and + update with uv. +- Add CI coverage for editable and git-sourced uv tool install modes. + ## 0.17.0 - 2026-05-13 - Add a segmented append-only ledger under `.syncwheel/ledger/` with a replayed diff --git a/README.md b/README.md index 755fb3f..61a2f88 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Keep many long-lived pull requests clean, rebuildable, and publishable from one manifest. -Current version: `0.14.0` +Current version: `0.18.0` `syncwheel` is a small CLI and workflow model for maintainers who carry several PR branches against an upstream repository and need those branches to stay @@ -193,11 +193,40 @@ Practical meaning: ## Install -No package install is required. The tool is a single Python script. - Requirements: - Python 3.11+ - Git +- uv 0.10+ for PATH-based installs + +Recommended production install: + +```bash +uv tool install "git+https://github.com/NestDevLab/syncwheel" +``` + +Development editable install from a local checkout: + +```bash +uv tool install --editable . +``` + +Installer script: + +```bash +scripts/install.sh +scripts/install.sh --editable /path/to/syncwheel +``` + +If `uv` is not installed, `scripts/install.sh` exits with instructions by +default. To explicitly let the installer bootstrap uv with the official +astral.sh installer, pass `--with-uv`. + +Legacy checkout execution remains supported for pinned submodules, vendored +checkouts, and existing scripts: + +```bash +python3 scripts/syncwheel.py --help +``` ## Self update, notifications, and AI-safe visibility @@ -205,19 +234,22 @@ Syncwheel now includes a built-in install/update channel so humans and AI agents can notice new releases instead of silently drifting. - default mode: `notify` -- automatic notice is emitted on normal syncwheel usage when the local install is - behind its upstream branch +- automatic notice is emitted on normal syncwheel usage when the local install + is behind the configured update source +- git-checkout installs update with the existing `git fetch` plus fast-forward + merge flow +- uv tool installs update with `uv tool upgrade syncwheel` - manual inspection: ```bash -python3 scripts/syncwheel.py self status -python3 scripts/syncwheel.py self check-update --fetch +syncwheel self status +syncwheel self check-update --fetch ``` - manual update: ```bash -python3 scripts/syncwheel.py self update +syncwheel self update ``` - update policy: @@ -229,23 +261,42 @@ python3 scripts/syncwheel.py self mode off ``` `auto` tries a safe fast-forward self-update when a newer upstream version is -detected. If the syncwheel checkout is dirty or detached, syncwheel falls back -to a visible notice instead of mutating it unsafely. +detected for git-checkout installs and runs the uv tool updater for uv installs. +If a git checkout is dirty or detached, syncwheel falls back to a visible notice +instead of mutating it unsafely. + +For uv installs, `self check-update` reads the upstream `VERSION` file directly +instead of requiring a local git checkout. Advanced wrappers can override the +version source with `SYNCWHEEL_REMOTE_VERSION_URL` and the installer/update +source label with `SYNCWHEEL_UV_TOOL_SOURCE`. ## Installation and adoption modes -1. **Global toolkit (recommended)** +1. **uv production tool (recommended for normal hosts)** + - Run `uv tool install "git+https://github.com/NestDevLab/syncwheel"`. + - The `syncwheel` executable is placed on PATH when uv's tool bin directory + is configured in the shell. + - `syncwheel self update` uses uv to upgrade the installed tool. + +2. **uv editable development tool (recommended for syncwheel development)** - Clone `syncwheel` once in a stable location. - - Run it against target repos via `-r/--repo` using either paths or aliases. - - Best when you want one central install to keep updated. + - Run `uv tool install --editable /path/to/syncwheel`. + - The `syncwheel` executable reflects local source edits immediately. + - `syncwheel self update` treats the checkout as a git install and uses the + existing fast-forward flow against the clone's upstream. -2. **Git submodule** +3. **Git submodule** - Add `syncwheel` as a submodule inside each target repo. - Good when each project must pin an explicit syncwheel version. + - Invoke it with `python3 path/to/syncwheel/scripts/syncwheel.py ...`. + - Self-update status works for detached submodule-style checkouts; updating + remains controlled by the parent repository's submodule policy. -3. **Vendored script** +4. **Vendored checkout or script** - Copy `scripts/syncwheel.py` into a project. - Fastest for experiments, but updates are manual. + - `self status` reports `install_kind: script` when no git checkout or uv + tool environment is detected. ## Repo aliases diff --git a/VERSION b/VERSION index c5523bd..6633391 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.17.0 +0.18.0 diff --git a/docs/agent-procedure.md b/docs/agent-procedure.md index 446d252..2d4ab55 100644 --- a/docs/agent-procedure.md +++ b/docs/agent-procedure.md @@ -57,6 +57,15 @@ Given one of those prompts, the agent should: is used. - If the repo uses GitHub, validate publication state after branch rebuilds. - If the manifest and Git disagree, fix the manifest or name the conflict explicitly. +- **A rebuild reconstructs a branch from the manifest's commit projection, NOT from the + branch's current remote tip.** If the manifest points at a pre-cleanup commit (or a + range that misses a later fix), `stack rebuild` / `int rebuild` will silently **revert + that work** — the rebuilt branch force-pushes back to the older state and the cleanup + disappears. This is a real regression mode, not hypothetical. **Guard against it:** + after every rebuild/sync/publish, diff the rebuilt branch against the expected + post-cleanup state and confirm earlier fixes did not regress; keep the manifest current + with `stack set ` pointing at the post-cleanup commit BEFORE rebuilding, + so the projection includes the latest work. ## Suggested human/AI split diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d790aae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "syncwheel" +dynamic = ["version"] +description = "Deterministic helper for maintaining long-lived PR stacks." +readme = "README.md" +requires-python = ">=3.11" +license = {file = "LICENSE"} +authors = [ + {name = "NestDevLab"}, +] +dependencies = [] + +[project.scripts] +syncwheel = "syncwheel:main" + +[tool.setuptools] +py-modules = ["syncwheel"] +package-dir = {"" = "scripts"} + +[tool.setuptools.dynamic] +version = {file = ["VERSION"]} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..82a907e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Usage: scripts/install.sh [--with-uv] [--editable PATH] + +Installs syncwheel as a uv tool. + +Options: + --with-uv Install uv with the official astral.sh installer if uv is missing. + --editable PATH Install a local checkout in editable mode for development. + -h, --help Show this help. +EOF +} + +with_uv=0 +editable_path= + +while [ "$#" -gt 0 ]; do + case "$1" in + --with-uv) + with_uv=1 + shift + ;; + --editable) + if [ "$#" -lt 2 ]; then + echo "error: --editable requires a path" >&2 + exit 2 + fi + editable_path=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v uv >/dev/null 2>&1; then + if [ "$with_uv" -ne 1 ]; then + echo "error: uv is not installed. Install uv first or rerun with --with-uv." >&2 + exit 1 + fi + if command -v curl >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + elif command -v wget >/dev/null 2>&1; then + wget -qO- https://astral.sh/uv/install.sh | sh + else + echo "error: --with-uv requires curl or wget" >&2 + exit 1 + fi +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv was installed but is not on PATH yet" >&2 + echo "Run: uv tool update-shell" >&2 + exit 1 +fi + +if [ -n "$editable_path" ]; then + uv tool install --force --editable "$editable_path" +else + uv tool install --force "git+https://github.com/NestDevLab/syncwheel" +fi + +tool_bin_dir=${UV_TOOL_BIN_DIR:-"$HOME/.local/bin"} +case ":$PATH:" in + *":$tool_bin_dir:"*) ;; + *) + echo "warning: uv tool bin directory is not on PATH: $tool_bin_dir" >&2 + echo "Run: uv tool update-shell" >&2 + ;; +esac + +if command -v syncwheel >/dev/null 2>&1; then + syncwheel --version +elif [ -x "$tool_bin_dir/syncwheel" ]; then + "$tool_bin_dir/syncwheel" --version +else + echo "warning: syncwheel was installed but the executable was not found on PATH" >&2 + echo "Run: uv tool update-shell" >&2 +fi diff --git a/scripts/syncwheel.py b/scripts/syncwheel.py index 847f050..03eab08 100755 --- a/scripts/syncwheel.py +++ b/scripts/syncwheel.py @@ -2,6 +2,7 @@ import argparse import datetime import hashlib +import importlib.metadata import json import os import shlex @@ -9,6 +10,8 @@ import subprocess import sys import time +import urllib.error +import urllib.request from pathlib import Path @@ -23,6 +26,8 @@ class SyncwheelError(Exception): ENV_UPDATE_INTERVAL_SECONDS = 'SYNCWHEEL_UPDATE_INTERVAL_SECONDS' ENV_UPDATE_STATE_PATH = 'SYNCWHEEL_UPDATE_STATE_PATH' ENV_UPDATE_SETTINGS_PATH = 'SYNCWHEEL_UPDATE_SETTINGS_PATH' +ENV_REMOTE_VERSION_URL = 'SYNCWHEEL_REMOTE_VERSION_URL' +ENV_UV_TOOL_SOURCE = 'SYNCWHEEL_UV_TOOL_SOURCE' PROFILE_FILENAME = 'profile.local.json' INTEGRATION_STRATEGIES = {'cherry-pick', 'merge-stacks'} DEFAULT_INTEGRATION_BRANCH = 'main-integration' @@ -34,6 +39,9 @@ class SyncwheelError(Exception): SYNCWHEEL_LOCAL_EXCLUDE_MARKER = '# syncwheel local metadata' DEFAULT_UPDATE_MODE = 'notify' DEFAULT_UPDATE_INTERVAL_SECONDS = 6 * 60 * 60 +UPSTREAM_REPO_URL = 'https://github.com/NestDevLab/syncwheel' +UPSTREAM_DEFAULT_BRANCH = 'main' +UV_TOOL_NAME = 'syncwheel' SYNCWHEEL_HOOKS_PATH = 'githooks' FALLBACK_GIT_IDENTITY_CONFIG = [ '-c', @@ -54,8 +62,35 @@ def read_version_file(path): return None -INSTALL_ROOT = Path(__file__).resolve().parents[1] -VERSION = read_version_file(INSTALL_ROOT / 'VERSION') or '0.6.0' +def source_checkout_root(source_path=None): + source = Path(source_path or __file__).resolve() + if source.name == 'syncwheel.py' and source.parent.name == 'scripts': + return source.parents[1] + return None + + +SOURCE_ROOT = source_checkout_root() or Path(__file__).resolve().parent + + +def package_metadata_version(): + try: + return importlib.metadata.version(UV_TOOL_NAME) + except importlib.metadata.PackageNotFoundError: + return None + + +def resolve_runtime_version(root=None): + if root: + version = read_version_file(Path(root) / 'VERSION') + if version: + return version + version = read_version_file(SOURCE_ROOT / 'VERSION') + if version: + return version + return package_metadata_version() or '0.6.0' + + +VERSION = resolve_runtime_version() def run(cmd, cwd=None, check=True, input_text=None, env=None): @@ -254,8 +289,114 @@ def save_update_state(data, path=None): return save_json_file(path or get_update_state_path(), data) +def path_is_relative_to(path, root): + try: + Path(path).resolve().relative_to(Path(root).resolve()) + return True + except (OSError, ValueError): + return False + + +def default_remote_version_url(repo_url=None): + repo = (repo_url or UPSTREAM_REPO_URL).rstrip('/') + github_prefix = 'https://github.com/' + if repo.startswith(github_prefix): + return ( + 'https://raw.githubusercontent.com/' + f'{repo[len(github_prefix):]}/{UPSTREAM_DEFAULT_BRANCH}/VERSION' + ) + return f'{repo}/raw/{UPSTREAM_DEFAULT_BRANCH}/VERSION' + + +def remote_version_url(): + return os.environ.get(ENV_REMOTE_VERSION_URL) or default_remote_version_url() + + +def uv_tool_source(): + return os.environ.get(ENV_UV_TOOL_SOURCE) or f'git+{UPSTREAM_REPO_URL}' + + +def parse_remote_version_text(text): + for line in str(text or '').splitlines(): + version = line.strip() + if version: + return version + raise SyncwheelError('remote VERSION file is empty') + + +def fetch_remote_version(url=None, timeout=10): + target = url or remote_version_url() + try: + with urllib.request.urlopen(target, timeout=timeout) as response: + body = response.read(4096).decode('utf-8') + except (OSError, urllib.error.URLError) as exc: + raise SyncwheelError(f'could not fetch remote syncwheel version from {target}: {exc}') from exc + return parse_remote_version_text(body) + + +def detect_uv_tool_prefix(source_path=None, prefix=None, env=None): + source = Path(source_path or __file__).resolve() + active_prefix = Path(prefix or sys.prefix).resolve() + values = env if env is not None else os.environ + if not path_is_relative_to(source, active_prefix): + return None + if not (active_prefix / 'pyvenv.cfg').exists(): + return None + + uv_tool_dir = values.get('UV_TOOL_DIR') + if uv_tool_dir and path_is_relative_to(active_prefix, Path(uv_tool_dir).expanduser()): + return active_prefix + + # uv 0.10.x creates one virtualenv per tool under a tools directory. Editable + # uv installs import from the source checkout, so the source file is not under + # sys.prefix and this heuristic intentionally does not classify them as uv-tool. + if active_prefix.name == UV_TOOL_NAME and active_prefix.parent.name == 'tools': + return active_prefix + + receipt_candidates = ( + active_prefix / 'uv-receipt.toml', + active_prefix / 'uv-receipt.json', + ) + if any(path.exists() for path in receipt_candidates): + return active_prefix + return None + + +def detect_syncwheel_install(root=None, source_path=None, prefix=None, env=None): + source = Path(source_path or __file__).resolve() + explicit_root = Path(root).resolve() if root else None + checkout_root = explicit_root or source_checkout_root(source) + + if checkout_root and install_is_git_checkout(checkout_root): + return { + 'kind': 'git-clone', + 'install_root': checkout_root, + 'source_path': source, + 'git_repo': True, + 'uv_tool_prefix': None, + } + + uv_prefix = detect_uv_tool_prefix(source_path=source, prefix=prefix, env=env) + if uv_prefix: + return { + 'kind': 'uv-tool', + 'install_root': uv_prefix, + 'source_path': source, + 'git_repo': False, + 'uv_tool_prefix': uv_prefix, + } + + return { + 'kind': 'script', + 'install_root': checkout_root or source.parent, + 'source_path': source, + 'git_repo': False, + 'uv_tool_prefix': None, + } + + def install_root(): - return INSTALL_ROOT + return detect_syncwheel_install()['install_root'] def install_is_git_checkout(root): @@ -360,14 +501,61 @@ def install_syncwheel_hooks(root=None, dry_run=False): return install_hooks_status(root) +def current_install_version(install): + root = Path(install['install_root']) + if install['kind'] == 'uv-tool': + return package_metadata_version() or VERSION + return read_version_file(root / 'VERSION') or VERSION + + +def script_self_update_path(install=None): + detected = install or detect_syncwheel_install() + root = Path(detected['install_root']) + legacy_path = root / 'scripts' / 'syncwheel.py' + if legacy_path.exists(): + return legacy_path + return Path(detected['source_path']) + + +def recommended_self_update_command(install=None): + detected = install or detect_syncwheel_install() + if detected['kind'] == 'uv-tool': + return 'syncwheel self update' + return f'python3 {shlex.quote(str(script_self_update_path(detected)))} self update' + + +def uv_self_update_command(): + return ['uv', 'tool', 'upgrade', UV_TOOL_NAME] + + +def build_self_update_commands(status, fetch=True): + if status.get('install_kind') == 'uv-tool': + return [uv_self_update_command()] + upstream = status.get('upstream') + if not upstream: + return [] + remote = upstream.split('/', 1)[0] + commands = [] + if fetch: + commands.append(['git', 'fetch', '--quiet', remote, '--tags']) + commands.append(['git', 'merge', '--ff-only', upstream]) + return commands + + def collect_self_update_status(root=None, fetch=False): - root = Path(root or install_root()).resolve() - current_version = read_version_file(root / 'VERSION') or VERSION + install = detect_syncwheel_install(root) + root = Path(install['install_root']).resolve() + current_version = current_install_version(install) status = { 'install_root': str(root), + 'install_kind': install['kind'], 'current_version': current_version, 'latest_version': current_version, 'git_repo': False, + 'uv_tool': install['kind'] == 'uv-tool', + 'uv_tool_source': None, + 'remote_version_url': None, + 'recommended_command': recommended_self_update_command(install), 'branch': None, 'upstream': None, 'clean': None, @@ -378,7 +566,21 @@ def collect_self_update_status(root=None, fetch=False): 'reason': None, 'checked_at': iso_utc_now(), } - if not install_is_git_checkout(root): + + if install['kind'] == 'uv-tool': + status['can_self_update'] = True + status['uv_tool_source'] = uv_tool_source() + status['remote_version_url'] = remote_version_url() + try: + remote_version = fetch_remote_version(status['remote_version_url']) + except SyncwheelError as exc: + status['reason'] = str(exc) + return status + status['latest_version'] = remote_version + status['update_available'] = compare_versions(remote_version, current_version) > 0 + return status + + if not install['git_repo']: status['reason'] = 'syncwheel install is not a git checkout' return status @@ -416,17 +618,25 @@ def collect_self_update_status(root=None, fetch=False): return status -def recommended_self_update_command(): - return f'python3 {shlex.quote(str(Path(__file__).resolve()))} self update' - - def refresh_cached_self_update_status(force=False): settings = load_update_settings() state, state_path = load_update_state() now = int(time.time()) last_checked_epoch = parse_int(state.get('last_checked_epoch'), 0) cached = state.get('status') if isinstance(state.get('status'), dict) else None - stale = force or not cached or (now - last_checked_epoch) >= settings['check_interval_seconds'] + current_install = detect_syncwheel_install() + current_install_root = str(Path(current_install['install_root']).resolve()) + cache_matches_install = ( + cached + and cached.get('install_root') == current_install_root + and cached.get('install_kind') == current_install['kind'] + ) + stale = ( + force + or not cached + or not cache_matches_install + or (now - last_checked_epoch) >= settings['check_interval_seconds'] + ) if stale: cached = collect_self_update_status(fetch=True) state['status'] = cached @@ -439,27 +649,26 @@ def refresh_cached_self_update_status(force=False): def perform_self_update(root=None, dry_run=False, fetch=True): root = Path(root or install_root()).resolve() before = collect_self_update_status(root, fetch=fetch) - if not before['git_repo']: - raise SyncwheelError(before['reason'] or 'syncwheel install is not a git checkout') - if not before['upstream']: - raise SyncwheelError(before['reason'] or 'syncwheel checkout has no upstream tracking branch') - if before['branch'] == 'DETACHED': - raise SyncwheelError('syncwheel checkout is detached; self-update requires a branch checkout') - if not before['clean']: - raise SyncwheelError('syncwheel checkout is not clean; commit or stash local changes before self-update') - - remote = before['upstream'].split('/', 1)[0] - commands = [] - if fetch: - commands.append(['git', 'fetch', '--quiet', remote, '--tags']) - commands.append(['git', 'merge', '--ff-only', before['upstream']]) + if before['install_kind'] == 'uv-tool': + commands = build_self_update_commands(before, fetch=fetch) + else: + if not before['git_repo']: + raise SyncwheelError(before['reason'] or 'syncwheel install is not a git checkout') + if not before['upstream']: + raise SyncwheelError(before['reason'] or 'syncwheel checkout has no upstream tracking branch') + if before['branch'] == 'DETACHED': + raise SyncwheelError('syncwheel checkout is detached; self-update requires a branch checkout') + if not before['clean']: + raise SyncwheelError('syncwheel checkout is not clean; commit or stash local changes before self-update') + commands = build_self_update_commands(before, fetch=fetch) + if dry_run: for command in commands: print(quoted(command)) return before, before, commands for command in commands: - run(command, cwd=root) + run(command, cwd=root if before['git_repo'] else None) after = collect_self_update_status(root, fetch=False) state, state_path = load_update_state() state['status'] = after @@ -492,13 +701,13 @@ def maybe_handle_startup_update_policy(args): print( 'NOTICE: syncwheel update available ' f'({current_version} -> {latest_version}) but auto-update was blocked: {exc}. ' - f'Run: {recommended_self_update_command()}', + f'Run: {status.get("recommended_command") or recommended_self_update_command()}', file=sys.stderr, ) return print( f'NOTICE: syncwheel update available ({current_version} -> {latest_version}). ' - f'Run: {recommended_self_update_command()}', + f'Run: {status.get("recommended_command") or recommended_self_update_command()}', file=sys.stderr, ) @@ -3533,7 +3742,7 @@ def build_parser(): self_check_p.add_argument('--json', action='store_true') self_check_p.set_defaults(func=command_self_check_update) - self_update_p = self_sub.add_parser('update', help='fast-forward this syncwheel checkout to its upstream branch') + self_update_p = self_sub.add_parser('update', help='update this syncwheel install') self_update_p.add_argument('--dry-run', action='store_true') self_update_p.add_argument('--no-fetch', action='store_true') self_update_p.set_defaults(func=command_self_update) @@ -3848,6 +4057,7 @@ def command_self_status(args): print(json.dumps(output, indent=2)) return 0 print(f"install_root: {status['install_root']}") + print(f"install_kind: {status['install_kind']}") print(f"current_version: {status['current_version']}") print(f"update_mode: {settings['mode']}") print(f"check_interval_seconds: {settings['check_interval_seconds']}") @@ -3859,11 +4069,15 @@ def command_self_status(args): print(f"behind_commits: {status['behind_commits']}") else: print('git_repo: no') + if status.get('uv_tool'): + print('uv_tool: yes') + print(f"uv_tool_source: {status['uv_tool_source']}") + print(f"remote_version_url: {status['remote_version_url']}") if status.get('reason'): print(f"note: {status['reason']}") if status['update_available']: print(f"update: available ({status['current_version']} -> {status['latest_version']})") - print(f"recommended: {recommended_self_update_command()}") + print(f"recommended: {status['recommended_command']}") else: print('update: none') print(f"hooks_active: {'yes' if hooks['active'] else 'no'}") @@ -3880,7 +4094,7 @@ def command_self_check_update(args): return 0 if status['update_available']: print(f"update available: {status['current_version']} -> {status['latest_version']}") - print(recommended_self_update_command()) + print(status['recommended_command']) else: print(f"up to date: {status['current_version']}") if status.get('reason'): diff --git a/skills/syncwheel/SKILL.md b/skills/syncwheel/SKILL.md index c0e28f7..a3a9a0c 100644 --- a/skills/syncwheel/SKILL.md +++ b/skills/syncwheel/SKILL.md @@ -33,14 +33,18 @@ project-specific validation after a rebuild, and safe execution. ## Locate the CLI -In the Syncwheel repo it runs as `python3 scripts/syncwheel.py`. When this skill -is installed into a runtime, resolve the CLI in this order: +Syncwheel is available as the PATH `syncwheel` command. Install it with: + +```bash +uv tool install "git+https://github.com/NestDevLab/syncwheel" # production +uv tool install --editable # development +syncwheel self update # keep current +``` + +If the PATH binary is not available (legacy host or vendored install), fall back to the checkout pointer: ```bash -# 1. Explicit pointer (preferred for installed skills) SW="python3 ${SYNCWHEEL_REPO:?set SYNCWHEEL_REPO to the syncwheel checkout}/scripts/syncwheel.py" -# 2. A repo-vendored wrapper (e.g. scripts/sw -> deps/syncwheel/scripts/syncwheel.py) -# 3. A checkout on disk you can point SYNCWHEEL_REPO at $SW --version ``` @@ -50,13 +54,13 @@ or run from inside the target repo's worktree. ## Safe lifecycle (always dry-run first) ```bash -$SW status --fetch # discover real Git state -$SW validate # manifest vs Git -$SW plan --json # deterministic action plan -$SW reconcile # dry-run reconcile (no writes) -$SW reconcile --apply --worktree-root # apply, only after the plan is understood -$SW reconcile --apply --worktree-root --push # publish shared branches -$SW check # re-verify +syncwheel status --fetch # discover real Git state +syncwheel validate # manifest vs Git +syncwheel plan --json # deterministic action plan +syncwheel reconcile # dry-run reconcile (no writes) +syncwheel reconcile --apply --worktree-root # apply, only after the plan is understood +syncwheel reconcile --apply --worktree-root --push # publish shared branches +syncwheel check # re-verify ``` Never mutate branches from a dirty worktree. Prefer a dedicated worktree for @@ -64,20 +68,31 @@ every rebuild. Use `--dry-run` on rebuild/push commands. If the manifest and Git disagree, fix the manifest or call out the conflict — do not claim a repo is aligned while integration and PR branches still differ. +> ⚠️ **Rebuilds can silently revert already-applied work.** A `stack rebuild` / +> `int rebuild` reconstructs the branch from the **manifest's commit projection, +> not from the branch's current remote tip**. If the manifest points at a +> pre-cleanup commit (or a range that misses a later fix), the rebuild force-pushes +> the branch back to that older state and the cleanup/fix **disappears** — a real +> regression mode (observed in practice: a cleaned-up file came back after a rebuild +> off a stale projection). **Always:** before rebuilding, update the manifest with +> `syncwheel stack set ` so the projection includes the latest commit; +> and after every rebuild/sync/publish, diff the rebuilt branch against the expected +> post-fix state to confirm earlier cleanups did not regress. + ## Authoring a new PR stack ```bash # 1. Ensure a manifest exists (see the tracking decision below) -$SW init # shared manifest (.syncwheel/manifest.json) +syncwheel init # shared manifest (.syncwheel/manifest.json) # 2. Declare the stack -$SW stack create feature-a --branch pr/feature-a --base origin/main --include-in-integration +syncwheel stack create feature-a --branch pr/feature-a --base origin/main --include-in-integration # 3. Author in a dedicated worktree (fresh work uses plain git worktree add) git worktree add -b pr/feature-a ../repo-wt-feature-a origin/main # ... make and commit your changes in that worktree ... # 4. Record the commits into the manifest, then validate and push -$SW stack set feature-a origin/main..HEAD -$SW validate && $SW plan --json -$SW stack push feature-a +syncwheel stack set feature-a origin/main..HEAD +syncwheel validate && syncwheel plan --json +syncwheel stack push feature-a ``` ## Decision: commit the manifest, or keep it untracked? @@ -123,7 +138,7 @@ Benefits: A shared, committed manifest plus the append-only ledger is what lets many agents coordinate deterministically. On a fresh machine or a new agent, recover shared -state with `$SW resume` instead of improvising branch ownership. +state with `syncwheel resume` instead of improvising branch ownership. ## More diff --git a/tests/test_syncwheel.py b/tests/test_syncwheel.py index 3df2c29..6206bc8 100644 --- a/tests/test_syncwheel.py +++ b/tests/test_syncwheel.py @@ -1,9 +1,11 @@ +import importlib.util import json import os import shutil import subprocess import tempfile import unittest +from unittest import mock from pathlib import Path @@ -77,6 +79,12 @@ def run_script(self, script_path, *args, expected=0, cwd=None): ) return result + def load_syncwheel_module(self): + spec = importlib.util.spec_from_file_location('syncwheel_under_test', CLI) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + def git(self, *args): result = subprocess.run( ['git', *args], @@ -1509,6 +1517,120 @@ def test_repo_alias_can_store_default_manifest_path(self): data = json.loads(result.stdout) self.assert_path_equal(data['manifest_path'], custom_manifest) + def test_legacy_script_entrypoint_still_reports_version(self): + result = self.run_custom_cli(CLI, '--version', expected=0, cwd=REPO_ROOT) + + self.assertIn('syncwheel 0.18.0', result.stdout) + + def test_install_kind_detection_identifies_git_checkout(self): + fixture = self.init_syncwheel_install_fixture() + syncwheel = self.load_syncwheel_module() + + detected = syncwheel.detect_syncwheel_install(root=fixture['install'], source_path=fixture['cli']) + + self.assertEqual(detected['kind'], 'git-clone') + self.assertTrue(detected['git_repo']) + self.assertEqual(detected['install_root'], fixture['install']) + + def test_install_kind_detection_identifies_plain_script_without_git(self): + syncwheel = self.load_syncwheel_module() + script_root = self.tmp / 'standalone-syncwheel' + script_path = script_root / 'scripts' / 'syncwheel.py' + script_path.parent.mkdir(parents=True) + script_path.write_text('# placeholder\n') + + detected = syncwheel.detect_syncwheel_install( + source_path=script_path, + prefix=self.tmp / 'not-a-tool-venv', + env={}, + ) + + self.assertEqual(detected['kind'], 'script') + self.assertFalse(detected['git_repo']) + self.assertEqual(detected['install_root'], script_root) + + def test_install_kind_detection_identifies_uv_tool_environment(self): + syncwheel = self.load_syncwheel_module() + tool_dir = self.tmp / 'uv-tools' + prefix = tool_dir / 'syncwheel' + source_path = prefix / 'lib' / 'python3.12' / 'site-packages' / 'syncwheel.py' + source_path.parent.mkdir(parents=True) + source_path.write_text('# placeholder\n') + (prefix / 'pyvenv.cfg').write_text('home = /usr/bin\n') + + detected = syncwheel.detect_syncwheel_install( + source_path=source_path, + prefix=prefix, + env={'UV_TOOL_DIR': str(tool_dir)}, + ) + + self.assertEqual(detected['kind'], 'uv-tool') + self.assertFalse(detected['git_repo']) + self.assertEqual(detected['install_root'], prefix) + + def test_self_update_command_selection_per_install_kind(self): + syncwheel = self.load_syncwheel_module() + + self.assertEqual( + syncwheel.build_self_update_commands({'install_kind': 'uv-tool'}), + [['uv', 'tool', 'upgrade', 'syncwheel']], + ) + self.assertEqual( + syncwheel.build_self_update_commands({'install_kind': 'git-clone', 'upstream': 'origin/main'}), + [['git', 'fetch', '--quiet', 'origin', '--tags'], ['git', 'merge', '--ff-only', 'origin/main']], + ) + self.assertEqual( + syncwheel.build_self_update_commands( + {'install_kind': 'git-clone', 'upstream': 'origin/main'}, + fetch=False, + ), + [['git', 'merge', '--ff-only', 'origin/main']], + ) + + def test_remote_version_fetch_parses_version_without_network(self): + syncwheel = self.load_syncwheel_module() + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, _size): + return b'\n 0.18.0\n' + + with mock.patch.object(syncwheel.urllib.request, 'urlopen', return_value=FakeResponse()) as urlopen: + version = syncwheel.fetch_remote_version('https://example.invalid/VERSION') + + self.assertEqual(version, '0.18.0') + urlopen.assert_called_once() + + def test_self_status_reports_script_install_kind_for_non_git_copy(self): + standalone = self.tmp / 'standalone-syncwheel' + cli = standalone / 'scripts' / 'syncwheel.py' + cli.parent.mkdir(parents=True) + shutil.copy2(CLI, cli) + (standalone / 'VERSION').write_text('0.18.0\n') + + result = self.run_custom_cli( + cli, + 'self', + 'status', + '--json', + expected=0, + extra_env={ + 'SYNCWHEEL_UPDATE_STATE_PATH': str(self.tmp / 'standalone-update-state.json'), + 'SYNCWHEEL_UPDATE_SETTINGS_PATH': str(self.tmp / 'standalone-settings.json'), + }, + cwd=standalone, + ) + data = json.loads(result.stdout) + + self.assertEqual(data['status']['install_kind'], 'script') + self.assertFalse(data['status']['can_self_update']) + self.assertIn('not a git checkout', data['status']['reason']) + def test_self_check_update_reports_newer_version_after_fetch(self): fixture = self.init_syncwheel_install_fixture() result = self.run_custom_cli(