From c180665739c9da6800e04cecae84920ccb86f015 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 05:24:41 +0000 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20PLAN04-repos-core=20Draft=20PR=20?= =?UTF-8?q?=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From cd8c1c5df26d951f254dae34dd3c4908afa09b9d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 05:32:26 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(plugin):=20repos/=20=E6=B0=B8=E7=B6=9A?= =?UTF-8?q?=E3=82=AF=E3=83=AD=E3=83=BC=E3=83=B3=20+=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=AF=20install=20(PLAN04-repos-core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins/ 中間層を廃止し、repos/ に git clone を永続保持して projects/ からシンボリックリンクで直接参照する構造に変更。 - models.py: RegisteredRepository に local_path フィールド追加 - registry.py: get_repos_dir() 追加 - repo_manager.py: repos/ 永続クローン、git pull refresh、dirty check 付き remove - installer.py: repos/ ベースのシンボリックリンク install、repos/ 保護 uninstall、 copy_plugin / _sync_dir 等のコピー系ロジック削除 - syncer.py: InstalledPlugin.path ベース走査、同名衝突時の . suffix リンク - updater.py: git pull ベース update - cli.py: repo remove に --force オプション追加 - .gitignore: repos/ 追加 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + lib/devbase/cli.py | 2 + lib/devbase/commands/plugin.py | 3 +- lib/devbase/plugin/installer.py | 344 ++++---------- lib/devbase/plugin/models.py | 15 +- lib/devbase/plugin/registry.py | 6 +- lib/devbase/plugin/repo_manager.py | 348 +++++++++----- lib/devbase/plugin/syncer.py | 93 ++-- lib/devbase/plugin/updater.py | 112 +++-- tests/plugin/__init__.py | 0 tests/plugin/test_repos_core.py | 701 +++++++++++++++++++++++++++++ 11 files changed, 1173 insertions(+), 452 deletions(-) create mode 100644 tests/plugin/__init__.py create mode 100644 tests/plugin/test_repos_core.py diff --git a/.gitignore b/.gitignore index b11348e..f96d56b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .gemini/ .docker-compose.scale.yml plugins.yml +repos/ plugins/*/ !plugins/.gitkeep projects/* diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e2fd4be..9cd1ce1 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -244,6 +244,8 @@ def _add_plugin_parser(subparsers): r_remove = pl_repo_sub.add_parser('remove', help='Unregister a repository') r_remove.add_argument('name', help='Repository name') + r_remove.add_argument('--force', action='store_true', + help='Force removal even if repo has uncommitted/unpushed changes') pl_repo_sub.add_parser('list', help='List repositories') diff --git a/lib/devbase/commands/plugin.py b/lib/devbase/commands/plugin.py index 0f1be34..b2a5953 100644 --- a/lib/devbase/commands/plugin.py +++ b/lib/devbase/commands/plugin.py @@ -149,7 +149,8 @@ def cmd_repo(devbase_root: Path, args) -> int: handlers = { 'add': lambda: add_repository(registry, args.url, name=args.name), - 'remove': lambda: remove_repository(registry, args.name), + 'remove': lambda: remove_repository(registry, args.name, + force=getattr(args, 'force', False)), 'list': lambda: show_repositories(registry), 'refresh': lambda: _repo_refresh(registry, args), } diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index 79ca000..45d868a 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -1,12 +1,9 @@ """Plugin installer - handles install/uninstall operations""" -import hashlib import os import shutil import subprocess -import tempfile import yaml -from dataclasses import dataclass, field from pathlib import Path from typing import Optional @@ -49,12 +46,19 @@ def parse_registry_yml(path: Path) -> Optional[RegistryInfo]: ) -def git_clone(url: str, dest: Path, ref: Optional[str] = None) -> None: +def git_clone( + url: str, + dest: Path, + ref: Optional[str] = None, + shallow: bool = True, +) -> None: """Clone a git repository. Raises PluginError on failure. """ - cmd = ['git', 'clone', '--depth', '1'] + cmd = ['git', 'clone'] + if shallow: + cmd.extend(['--depth', '1']) if ref: cmd.extend(['--branch', ref]) cmd.extend([url, str(dest)]) @@ -89,9 +93,7 @@ def install_plugin( """ source = PluginSource.parse(source_str, link=link) plugins_dir = registry.get_plugins_dir() - plugins_dir.mkdir(exist_ok=True) - # Name-only: look up in registered repositories if not source.repo and source.plugin_name: result = registry.find_plugin_in_repos(source.plugin_name) if result: @@ -101,7 +103,7 @@ def install_plugin( ref=source.ref, linked=False, ) _install_from_repo( - registry, repo_source, plugins_dir, install_all=False, + registry, repo_source, install_all=False, ) return raise PluginError( @@ -110,20 +112,17 @@ def install_plugin( "Use 'devbase plugin repo list' to see registered repositories and available plugins." ) - # Resolve repo URL repo_url = resolve_repo_url(source.repo) - # Local path with --link if link and (Path(source.repo).is_dir()): + plugins_dir.mkdir(exist_ok=True) _install_from_local(registry, source, plugins_dir) return - # Git repository (user/repo:plugin-name or URL:plugin-name) _install_from_repo( registry, PluginSource( repo=repo_url, plugin_name=source.plugin_name, ref=source.ref, linked=False, ), - plugins_dir, install_all=install_all, ) @@ -140,10 +139,8 @@ def _install_from_local( local_path = Path(source.repo) if source.plugin_name: - # Specific plugin within the repo plugin_path = local_path / source.plugin_name if not plugin_path.is_dir(): - # Try looking at registry.yml for path mapping reg_info = parse_registry_yml(local_path) if reg_info: for entry in reg_info.plugins: @@ -165,7 +162,7 @@ def _link_plugin( source_display: str, plugins_dir: Path, ) -> None: - """Create a symlink for a local plugin""" + """Create a symlink for a local plugin (--link install only)""" dest = plugins_dir / name if dest.exists() or dest.is_symlink(): logger.warning("Removing existing plugin '%s'", name) @@ -195,281 +192,126 @@ def _link_plugin( def _install_from_repo( registry: PluginRegistry, source: PluginSource, - plugins_dir: Path, install_all: bool = False, ) -> None: - """Install plugin(s) from a git repository. + """Install plugin(s) from a registered repository via symlink to repos/. Raises PluginError on failure. """ - with tempfile.TemporaryDirectory() as tmpdir: - clone_dir = Path(tmpdir) / 'repo' - git_clone(source.repo, clone_dir, source.ref) - - reg_info = parse_registry_yml(clone_dir) - if not reg_info: - raise PluginError("No registry.yml found in repository") - - if install_all: - # Install all plugins from the repo - errors = [] - for entry in reg_info.plugins: - try: - copy_plugin( - registry, entry.name, - clone_dir / entry.path.rstrip('/'), - source.repo, plugins_dir - ) - except PluginError as e: - errors.append(str(e)) - sync_projects(registry) - if errors: - raise PluginError( - "Some plugins failed to install:\n" + "\n".join(errors) - ) - return + repo_reg = registry.get_repository_by_url(source.repo) + if not repo_reg or not repo_reg.local_path: + raise PluginError( + f"Repository '{source.repo}' is not registered or has no local clone.\n" + "Use 'devbase plugin repo add ' first." + ) - if source.plugin_name: - # Find specific plugin - target_entry = None - for entry in reg_info.plugins: - if entry.name == source.plugin_name: - target_entry = entry - break - - if not target_entry: - available = "\n".join( - f" - {e.name}: {e.description}" for e in reg_info.plugins - ) - raise PluginError( - f"Plugin '{source.plugin_name}' not found in repository\n" - f"Available plugins:\n{available}" - ) + clone_dir = registry.devbase_root / repo_reg.local_path + if not clone_dir.is_dir(): + raise PluginError( + f"Clone directory not found: {clone_dir}\n" + "Use 'devbase plugin repo remove' and 'repo add' to re-clone." + ) - plugin_path = clone_dir / target_entry.path.rstrip('/') - copy_plugin( - registry, target_entry.name, plugin_path, source.repo, plugins_dir + reg_info = parse_registry_yml(clone_dir) + if not reg_info: + raise PluginError("No registry.yml found in repository") + + if install_all: + errors = [] + for entry in reg_info.plugins: + try: + _register_repo_plugin( + registry, entry.name, + clone_dir / entry.path.rstrip('/'), + source.repo, repo_reg.local_path, + ) + except PluginError as e: + errors.append(str(e)) + sync_projects(registry) + if errors: + raise PluginError( + "Some plugins failed to install:\n" + "\n".join(errors) ) - sync_projects(registry) - else: - # No plugin specified - show available - print(f"Available plugins in {source.repo}:") - for entry in reg_info.plugins: - installed = registry.get(entry.name) - status = " (installed)" if installed else "" - print(f" {entry.name}: {entry.description}{status}") - print(f"\nUse 'devbase plugin install {source.repo}:PLUGIN_NAME' to install") - raise PluginError("No plugin name specified") - - -def _replace_entry(path: Path) -> None: - """Remove ``path`` (file, symlink, or directory) so it can be replaced.""" - if path.is_symlink() or not path.is_dir(): - path.unlink() - else: - shutil.rmtree(path) - - -def _hash_file(path: Path) -> str: - """Return the SHA-256 hex digest of a regular file's contents.""" - h = hashlib.sha256() - with path.open('rb') as f: - for chunk in iter(lambda: f.read(65536), b''): - h.update(chunk) - return h.hexdigest() - - -@dataclass -class _SyncReport: - """Summary of an in-place plugin sync, surfaced to users after update.""" - added: list[Path] = field(default_factory=list) - updated: list[Path] = field(default_factory=list) - kept_local: list[Path] = field(default_factory=list) - preserved_orphans: list[Path] = field(default_factory=list) - - -# Files at the plugin root that are upstream-owned metadata: always overwritten -# so registry version/description never desync from upstream even if a user -# happened to edit them locally. -_ALWAYS_OVERWRITE_AT_ROOT = frozenset({'plugin.yml'}) - - -def _sync_dir(src: Path, dst: Path, report: _SyncReport, rel: Path = Path('.')) -> None: - """Conservatively sync ``src`` → ``dst``, preserving user edits. - - Semantics (per file in src/dst): + return - | src | dst | content | action | - |---|---|---|---| - | exists | missing | - | copy from src (record as ``added``) | - | exists | exists | same | no-op | - | exists | exists | differ | keep dst, write src as ``.new`` (``kept_local``) | - | missing | exists | - | leave dst alone (``preserved_orphans``) | + if source.plugin_name: + target_entry = None + for entry in reg_info.plugins: + if entry.name == source.plugin_name: + target_entry = entry + break + + if not target_entry: + available = "\n".join( + f" - {e.name}: {e.description}" for e in reg_info.plugins + ) + raise PluginError( + f"Plugin '{source.plugin_name}' not found in repository\n" + f"Available plugins:\n{available}" + ) - Exception: files named in ``_ALWAYS_OVERWRITE_AT_ROOT`` at the plugin root - are always overwritten with upstream content (treated as plugin metadata, - not user-editable). + _register_repo_plugin( + registry, target_entry.name, + clone_dir / target_entry.path.rstrip('/'), + source.repo, repo_reg.local_path, + ) + sync_projects(registry) + else: + print(f"Available plugins in {source.repo}:") + for entry in reg_info.plugins: + installed = registry.get(entry.name) + status = " (installed)" if installed else "" + print(f" {entry.name}: {entry.description}{status}") + print(f"\nUse 'devbase plugin install {source.repo}:PLUGIN_NAME' to install") + raise PluginError("No plugin name specified") - Preserves the inode of ``dst`` and of subdirectories present in both — a - user whose CWD lives inside ``dst`` (typically via a ``projects/`` - symlink resolving into the plugin tree) keeps a valid CWD across updates. - User-only files (orphans) and user-edited files are never destroyed. - """ - dst.mkdir(parents=True, exist_ok=True) - - src_entries = {e.name: e for e in src.iterdir()} - dst_entries = {e.name: e for e in dst.iterdir()} - - for name, dst_entry in dst_entries.items(): - if name in src_entries: - continue - if name.endswith('.new'): - # `.new` is our own conflict marker — refresh, don't preserve. - _replace_entry(dst_entry) - continue - report.preserved_orphans.append(rel / name) - - for name, src_entry in src_entries.items(): - dst_entry = dst / name - sub_rel = rel / name - if ( - rel == Path('.') - and name in _ALWAYS_OVERWRITE_AT_ROOT - and not src_entry.is_symlink() - and not src_entry.is_dir() - ): - if dst_entry.is_symlink() or dst_entry.exists(): - _replace_entry(dst_entry) - shutil.copy2(src_entry, dst_entry) - report.updated.append(sub_rel) - continue - if src_entry.is_symlink(): - link_target = os.readlink(src_entry) - if dst_entry.is_symlink() and os.readlink(dst_entry) == link_target: - continue - if dst_entry.is_symlink() or dst_entry.exists(): - # Conflict: leave user's, drop upstream alongside as `.new` symlink. - new_dst = dst_entry.with_name(dst_entry.name + '.new') - if new_dst.is_symlink() or new_dst.exists(): - _replace_entry(new_dst) - os.symlink(link_target, new_dst) - report.kept_local.append(sub_rel) - else: - os.symlink(link_target, dst_entry) - report.added.append(sub_rel) - elif src_entry.is_dir(): - if dst_entry.is_symlink() or (dst_entry.exists() and not dst_entry.is_dir()): - # Type mismatch: user has a file/symlink where upstream has a dir. - # Drop upstream alongside as `.new/`. - new_dst = dst_entry.with_name(dst_entry.name + '.new') - if new_dst.is_symlink() or (new_dst.exists() and not new_dst.is_dir()): - _replace_entry(new_dst) - _sync_dir(src_entry, new_dst, report, sub_rel) - report.kept_local.append(sub_rel) - else: - already_existed = dst_entry.is_dir() - _sync_dir(src_entry, dst_entry, report, sub_rel) - if not already_existed: - report.added.append(sub_rel) - else: - if not dst_entry.exists() and not dst_entry.is_symlink(): - shutil.copy2(src_entry, dst_entry) - report.added.append(sub_rel) - continue - if dst_entry.is_symlink() or dst_entry.is_dir(): - # Type mismatch: user has a symlink/dir where upstream has a file. - new_dst = dst_entry.with_name(dst_entry.name + '.new') - if new_dst.is_symlink() or new_dst.exists(): - _replace_entry(new_dst) - shutil.copy2(src_entry, new_dst) - report.kept_local.append(sub_rel) - continue - # Both are regular files — compare content. - if _hash_file(src_entry) == _hash_file(dst_entry): - continue - new_dst = dst_entry.with_name(dst_entry.name + '.new') - if new_dst.is_symlink() or new_dst.exists(): - _replace_entry(new_dst) - shutil.copy2(src_entry, new_dst) - report.kept_local.append(sub_rel) - - -def copy_plugin( +def _register_repo_plugin( registry: PluginRegistry, name: str, plugin_path: Path, - source_display: str, - plugins_dir: Path, + source_url: str, + repo_local_path: str, ) -> None: - """Install or update a plugin from a cloned repo into ``plugins/``. - - For updates, contents are synced in place (preserving directory inodes - and user-edited files) instead of rmtree+copytree. User-edited files - are kept as-is; the upstream version of a conflicting file is dropped - alongside with a ``.new`` suffix for the user to diff/merge manually. - Files present only in the user's working tree (orphans) are preserved. - - Raises PluginError on failure. - """ + """Register a plugin from repos/ (no file copy, just metadata).""" if not plugin_path.is_dir(): raise PluginError(f"Plugin directory not found: {plugin_path}") - dest = plugins_dir / name - if dest.is_symlink(): - logger.warning("Removing existing plugin '%s' (symlink)", name) - dest.unlink() - shutil.copytree(plugin_path, dest) - elif dest.exists(): - logger.info("Updating existing plugin '%s'", name) - report = _SyncReport() - _sync_dir(plugin_path, dest, report) - if report.kept_local: - logger.warning( - " %d local edit(s) kept; upstream saved as .new alongside:", - len(report.kept_local), - ) - for p in report.kept_local[:10]: - logger.warning(" - %s (upstream: %s.new)", p, p.name) - if len(report.kept_local) > 10: - logger.warning(" ... and %d more", len(report.kept_local) - 10) - if report.preserved_orphans: - logger.info( - " %d local-only file(s) preserved (not in upstream)", - len(report.preserved_orphans), - ) - else: - shutil.copytree(plugin_path, dest) - - info = load_plugin_info(dest) + info = load_plugin_info(plugin_path) version = info.version if info else '0.1.0' + rel_path = f"{repo_local_path}/{name}" + registry.add(InstalledPlugin( name=name, version=version, - source=source_display, + source=source_url, installed_at=registry.now_iso(), - path=f"plugins/{name}", + path=rel_path, linked=False, )) - logger.info("Installed plugin '%s' (v%s)", name, version) + logger.info("Installed plugin '%s' (v%s) from repos/", name, version) def uninstall_plugin(registry: PluginRegistry, name: str) -> None: """Uninstall a plugin. + For repos/-based plugins: removes registry entry and syncs symlinks only. + For --link plugins: removes the symlink in plugins/. + Raises PluginError if not installed. """ plugin = registry.get(name) if not plugin: raise PluginError(f"Plugin '{name}' is not installed") - plugin_dir = registry.devbase_root / plugin.path - if plugin_dir.is_symlink(): - plugin_dir.unlink() - elif plugin_dir.is_dir(): - shutil.rmtree(plugin_dir) + if plugin.linked: + plugin_dir = registry.devbase_root / plugin.path + if plugin_dir.is_symlink(): + plugin_dir.unlink() + elif plugin_dir.is_dir(): + shutil.rmtree(plugin_dir) registry.remove(name) logger.info("Uninstalled plugin '%s'", name) diff --git a/lib/devbase/plugin/models.py b/lib/devbase/plugin/models.py index 597a5e4..24b87e9 100644 --- a/lib/devbase/plugin/models.py +++ b/lib/devbase/plugin/models.py @@ -151,18 +151,22 @@ class RegisteredRepository: name: str url: str added_at: str = "" + local_path: str = "" plugins: list[AvailablePlugin] = field(default_factory=list) def to_dict(self) -> dict: - return { + d: dict = { 'name': self.name, 'url': self.url, 'added_at': self.added_at, - 'plugins': [ - {'name': p.name, 'description': p.description, 'path': p.path} - for p in self.plugins - ], } + if self.local_path: + d['local_path'] = self.local_path + d['plugins'] = [ + {'name': p.name, 'description': p.description, 'path': p.path} + for p in self.plugins + ] + return d @classmethod def from_dict(cls, data: dict) -> 'RegisteredRepository': @@ -178,6 +182,7 @@ def from_dict(cls, data: dict) -> 'RegisteredRepository': name=data.get('name', ''), url=data.get('url', ''), added_at=data.get('added_at', ''), + local_path=data.get('local_path', ''), plugins=plugins, ) diff --git a/lib/devbase/plugin/registry.py b/lib/devbase/plugin/registry.py index b0940ec..f852fcb 100644 --- a/lib/devbase/plugin/registry.py +++ b/lib/devbase/plugin/registry.py @@ -85,9 +85,13 @@ def remove(self, name: str) -> bool: return False def get_plugins_dir(self) -> Path: - """Get the plugins directory path""" + """Get the plugins directory path (used for --link installs only)""" return self.devbase_root / 'plugins' + def get_repos_dir(self) -> Path: + """Get the repos directory path (persistent git clones)""" + return self.devbase_root / 'repos' + def get_projects_dir(self) -> Path: """Get the projects directory path""" return self.devbase_root / 'projects' diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index 211784a..29eb7bc 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -1,6 +1,6 @@ """Repository management - handles repo add/remove/list/refresh operations""" -import tempfile +import subprocess import yaml from pathlib import Path from typing import Optional @@ -33,18 +33,96 @@ def _get_official_registry_url() -> str: return DEFAULT_OFFICIAL_REGISTRY +def _derive_repo_name(url: str) -> str: + """Derive a repository name from a URL using owner/repo format. + + Examples: + https://github.com/devbasex/devbase-samples.git -> devbasex/devbase-samples + git@github.com:user/my-repo.git -> user/my-repo + """ + name = url.rstrip('/') + if name.endswith('.git'): + name = name[:-4] + if ':' in name and '@' in name: + return name.rsplit(':', 1)[-1] + from urllib.parse import urlparse + path = urlparse(name).path.strip('/') + segments = path.split('/') + if len(segments) >= 2: + return f"{segments[-2]}/{segments[-1]}" + return segments[-1] if segments else name + + +def _url_to_repos_dirname(url: str) -> str: + """Convert a repo URL to a repos/ directory name using owner--repo format. + + Examples: + https://github.com/devbasex/devbase-samples.git -> devbasex--devbase-samples + git@github.com:user/my-repo.git -> user--my-repo + """ + owner_repo = _derive_repo_name(url) + return owner_repo.replace('/', '--') + + +def _is_repo_dirty(repo_dir: Path) -> tuple[bool, str]: + """Check if a git repository has uncommitted or unpushed changes. + + Returns (is_dirty, description). + """ + issues = [] + + try: + result = subprocess.run( + ['git', 'status', '--porcelain'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + if result.returncode == 0 and result.stdout.strip(): + issues.append("uncommitted changes") + except subprocess.CalledProcessError: + pass + + try: + result = subprocess.run( + ['git', 'log', '--oneline', '@{u}..HEAD'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + if result.returncode == 0 and result.stdout.strip(): + issues.append("unpushed commits") + except subprocess.CalledProcessError: + pass + + if issues: + return True, ", ".join(issues) + return False, "" + + +def _git_pull(repo_dir: Path) -> None: + """Run git pull in a repository directory. + + Raises PluginError on failure. + """ + try: + subprocess.run( + ['git', 'pull'], + check=True, capture_output=True, text=True, cwd=str(repo_dir), + ) + except subprocess.CalledProcessError as e: + raise PluginError( + f"git pull failed in {repo_dir}: {e.stderr.strip()}" + ) + + def add_repository( registry: PluginRegistry, url: str, name: Optional[str] = None, ) -> None: - """Register a repository: clone -> read registry.yml -> save to plugins.yml. + """Register a repository: clone to repos/ -> read registry.yml -> save to plugins.yml. Raises RepositoryError on failure. """ repo_url = resolve_repo_url(url) - # Check if already registered by URL existing = registry.get_repository_by_url(repo_url) if existing: raise RepositoryError( @@ -52,75 +130,101 @@ def add_repository( "Use 'devbase plugin repo refresh' to update the plugin list." ) - with tempfile.TemporaryDirectory() as tmpdir: - clone_dir = Path(tmpdir) / 'repo' - try: - git_clone(repo_url, clone_dir) - except PluginError as e: - raise RepositoryError(str(e)) + repos_dir = registry.get_repos_dir() + repos_dir.mkdir(exist_ok=True) - try: - reg_info = parse_registry_yml(clone_dir) - except PluginError as e: - raise RepositoryError(str(e)) - if not reg_info: - raise RepositoryError(f"No registry.yml found in {repo_url}") + dir_name = _url_to_repos_dirname(repo_url) + clone_dir = repos_dir / dir_name - # Determine repo name: explicit --name > registry.yml name > owner/repo from URL - derived_name = _derive_repo_name(repo_url) - candidate_name = name or reg_info.name or derived_name + if clone_dir.exists(): + raise RepositoryError( + f"Directory already exists: {clone_dir}\n" + "The repository may have been previously added. " + "Remove the directory manually or use a different --name." + ) - # On name collision, fall back to owner/repo format if not already used - if registry.get_repository(candidate_name) and candidate_name != derived_name: - candidate_name = derived_name + try: + git_clone(repo_url, clone_dir, shallow=False) + except PluginError as e: + raise RepositoryError(str(e)) - repo_name = candidate_name + try: + reg_info = parse_registry_yml(clone_dir) + except PluginError as e: + raise RepositoryError(str(e)) + if not reg_info: + raise RepositoryError(f"No registry.yml found in {repo_url}") - # Check name collision - if registry.get_repository(repo_name): - raise RepositoryError( - f"Repository name '{repo_name}' already exists.\n" - "Use --name to specify a different name." - ) + derived_name = _derive_repo_name(repo_url) + candidate_name = name or reg_info.name or derived_name - plugins = [ - AvailablePlugin( - name=e.name, - description=e.description, - path=e.path, - ) - for e in reg_info.plugins - ] - - repo = RegisteredRepository( - name=repo_name, - url=repo_url, - added_at=registry.now_iso(), - plugins=plugins, - ) - registry.add_repository(repo) - - logger.info("Repository registered: %s (%s)", repo_name, repo_url) - if plugins: - print("Available plugins:") - for p in plugins: - installed = registry.get(p.name) - status = " (installed)" if installed else "" - print(f" - {p.name}: {p.description}{status}") + if registry.get_repository(candidate_name) and candidate_name != derived_name: + candidate_name = derived_name + repo_name = candidate_name -def remove_repository(registry: PluginRegistry, name: str) -> None: - """Remove a repository registration and uninstall all plugins from it. + if registry.get_repository(repo_name): + raise RepositoryError( + f"Repository name '{repo_name}' already exists.\n" + "Use --name to specify a different name." + ) - Raises RepositoryError if not found. + plugins = [ + AvailablePlugin( + name=e.name, + description=e.description, + path=e.path, + ) + for e in reg_info.plugins + ] + + local_path = f"repos/{dir_name}" + + repo = RegisteredRepository( + name=repo_name, + url=repo_url, + added_at=registry.now_iso(), + local_path=local_path, + plugins=plugins, + ) + registry.add_repository(repo) + + logger.info("Repository registered: %s (%s)", repo_name, repo_url) + if plugins: + print("Available plugins:") + for p in plugins: + installed = registry.get(p.name) + status = " (installed)" if installed else "" + print(f" - {p.name}: {p.description}{status}") + + +def remove_repository( + registry: PluginRegistry, + name: str, + force: bool = False, +) -> None: + """Remove a repository registration, uninstall plugins, and delete repos/ clone. + + Raises RepositoryError if not found or if repos/ is dirty (without --force). """ + import shutil from .installer import uninstall_plugin repo = registry.get_repository(name) if not repo: raise RepositoryError(f"Repository '{name}' not found.") - # Uninstall all plugins installed from this repository + repos_dir = registry.get_repos_dir() + repo_clone_dir = registry.devbase_root / repo.local_path if repo.local_path else None + + if repo_clone_dir and repo_clone_dir.is_dir() and not force: + is_dirty, description = _is_repo_dirty(repo_clone_dir) + if is_dirty: + raise RepositoryError( + f"Repository '{name}' has {description} in {repo_clone_dir}.\n" + "Commit/push your changes first, or use --force to delete anyway." + ) + installed = registry.list_installed() plugins_to_remove = [p for p in installed if p.source == repo.url] for plugin in plugins_to_remove: @@ -128,6 +232,11 @@ def remove_repository(registry: PluginRegistry, name: str) -> None: uninstall_plugin(registry, plugin.name) registry.remove_repository(name) + + if repo_clone_dir and repo_clone_dir.is_dir(): + shutil.rmtree(repo_clone_dir) + logger.info("Removed clone directory: %s", repo_clone_dir) + logger.info("Repository removed: %s", name) @@ -142,7 +251,8 @@ def show_repositories(registry: PluginRegistry) -> None: installed_names = {p.name for p in registry.list_installed()} for repo in repos: - print(f"{repo.name} ({repo.url})") + local_info = f" [{repo.local_path}]" if repo.local_path else "" + print(f"{repo.name} ({repo.url}){local_info}") if repo.plugins: for p in repo.plugins: status = " [installed]" if p.name in installed_names else "" @@ -158,7 +268,7 @@ def refresh_repository( registry: PluginRegistry, name: str, ) -> None: - """Refresh plugin list for a registered repository (re-clone -> update cache). + """Refresh plugin list for a registered repository (git pull + re-read registry.yml). Raises RepositoryError if not found. """ @@ -166,44 +276,69 @@ def refresh_repository( if not repo: raise RepositoryError(f"Repository '{name}' not found.") - with tempfile.TemporaryDirectory() as tmpdir: - clone_dir = Path(tmpdir) / 'repo' - try: - git_clone(repo.url, clone_dir) - except PluginError as e: - raise RepositoryError(str(e)) - - try: - reg_info = parse_registry_yml(clone_dir) - except PluginError as e: - raise RepositoryError(str(e)) - if not reg_info: - raise RepositoryError(f"No registry.yml found in {repo.url}") - - plugins = [ - AvailablePlugin( - name=e.name, - description=e.description, - path=e.path, - ) - for e in reg_info.plugins - ] - - updated_repo = RegisteredRepository( - name=repo.name, - url=repo.url, - added_at=repo.added_at, - plugins=plugins, + if not repo.local_path: + raise RepositoryError( + f"Repository '{name}' has no local clone path. " + "Remove and re-add the repository to create a persistent clone." ) - registry.add_repository(updated_repo) - - logger.info("Repository refreshed: %s", repo.name) - if plugins: - print("Available plugins:") - for p in plugins: - installed = registry.get(p.name) - status = " (installed)" if installed else "" - print(f" - {p.name}: {p.description}{status}") + + clone_dir = registry.devbase_root / repo.local_path + if not clone_dir.is_dir(): + raise RepositoryError( + f"Clone directory not found: {clone_dir}\n" + "Remove and re-add the repository to re-clone." + ) + + try: + _git_pull(clone_dir) + except PluginError as e: + raise RepositoryError(str(e)) + + try: + reg_info = parse_registry_yml(clone_dir) + except PluginError as e: + raise RepositoryError(str(e)) + if not reg_info: + raise RepositoryError(f"No registry.yml found in {repo.url}") + + old_plugin_names = {p.name for p in repo.plugins} + + plugins = [ + AvailablePlugin( + name=e.name, + description=e.description, + path=e.path, + ) + for e in reg_info.plugins + ] + new_plugin_names = {p.name for p in plugins} + + installed = registry.list_installed() + installed_names = {p.name for p in installed} + removed_installed = (old_plugin_names - new_plugin_names) & installed_names + if removed_installed: + for pname in sorted(removed_installed): + logger.warning( + "Installed plugin '%s' no longer exists in registry.yml of '%s'", + pname, name, + ) + + updated_repo = RegisteredRepository( + name=repo.name, + url=repo.url, + added_at=repo.added_at, + local_path=repo.local_path, + plugins=plugins, + ) + registry.add_repository(updated_repo) + + logger.info("Repository refreshed: %s", repo.name) + if plugins: + print("Available plugins:") + for p in plugins: + installed_p = registry.get(p.name) + status = " (installed)" if installed_p else "" + print(f" - {p.name}: {p.description}{status}") def add_official_repository(registry: PluginRegistry) -> bool: @@ -214,7 +349,6 @@ def add_official_repository(registry: PluginRegistry) -> bool: """ official_url = _get_official_registry_url() - # Already registered? if registry.get_repository_by_url(official_url): return True @@ -224,25 +358,3 @@ def add_official_repository(registry: PluginRegistry) -> bool: except Exception as e: logger.warning("Could not register official repository: %s", e) return False - - -def _derive_repo_name(url: str) -> str: - """Derive a repository name from a URL using owner/repo format. - - Examples: - https://github.com/devbasex/devbase-samples.git -> devbasex/devbase-samples - git@github.com:user/my-repo.git -> user/my-repo - """ - name = url.rstrip('/') - if name.endswith('.git'): - name = name[:-4] - # Handle git@ SSH URLs (git@github.com:owner/repo) - if ':' in name and '@' in name: - return name.rsplit(':', 1)[-1] - # HTTPS URLs: extract owner/repo from URL path - from urllib.parse import urlparse - path = urlparse(name).path.strip('/') - segments = path.split('/') - if len(segments) >= 2: - return f"{segments[-2]}/{segments[-1]}" - return segments[-1] if segments else name diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 9ac3762..46e822e 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -40,84 +40,121 @@ def discover_projects(plugin_dir: Path) -> list[str]: ] +def _extract_owner(plugin: InstalledPlugin) -> str: + """Extract owner identifier from a plugin for suffix generation. + + For repos/-based plugins: owner part from repos/--/ + For --link plugins: basename of the source path + """ + if plugin.linked: + return Path(plugin.source).name if plugin.source else plugin.name + + parts = plugin.path.split('/') + if len(parts) >= 2 and parts[0] == 'repos': + dir_name = parts[1] + if '--' in dir_name: + return dir_name.split('--', 1)[0] + return plugin.name + + def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: """Synchronize project symlinks from all installed plugins. - Creates symlinks in projects/ pointing to plugins/*/projects/* + Creates symlinks in projects/ pointing to plugin directories: + - repos/-based plugins: projects/ -> ../repos/--//projects/ + - --link plugins: projects/ -> ../plugins//projects/ + + On name collision, the winner (highest priority) gets the bare name, + losers get . suffix symlinks. Returns: Number of symlinks created """ - plugins_dir = registry.get_plugins_dir() projects_dir = registry.get_projects_dir() - - # Ensure projects/ exists projects_dir.mkdir(exist_ok=True) - # Collect all existing non-symlink entries in projects/ (real directories) real_projects = set() for entry in projects_dir.iterdir(): if not entry.is_symlink() and entry.is_dir(): real_projects.add(entry.name) - # Remove existing symlinks (clean slate) for entry in projects_dir.iterdir(): if entry.is_symlink(): entry.unlink() - if not plugins_dir.is_dir(): + installed = registry.list_installed() + if not installed: if verbose: - logger.info("plugins/ directory does not exist yet") + logger.info("No plugins installed") return 0 - # Build priority-sorted list of (project_name, plugin_name, plugin_priority, plugin_dir) - project_candidates: dict[str, list[tuple[str, int, Path]]] = {} - - installed = registry.list_installed() - installed_names = {p.name for p in installed} + project_candidates: dict[str, list[tuple[InstalledPlugin, int, Path]]] = {} - for plugin_entry in sorted(plugins_dir.iterdir()): - if not plugin_entry.is_dir() or plugin_entry.name.startswith('.'): - continue - if plugin_entry.name not in installed_names: + for plugin in installed: + plugin_dir = registry.devbase_root / plugin.path + if not plugin_dir.is_dir(): + if verbose: + logger.warning("Plugin directory missing: %s", plugin.path) continue - info = load_plugin_info(plugin_entry) + info = load_plugin_info(plugin_dir) priority = info.priority if info else 0 - for proj_name in discover_projects(plugin_entry): + for proj_name in discover_projects(plugin_dir): if proj_name not in project_candidates: project_candidates[proj_name] = [] project_candidates[proj_name].append( - (plugin_entry.name, priority, plugin_entry) + (plugin, priority, plugin_dir) ) - # Create symlinks with priority resolution created = 0 for proj_name, candidates in sorted(project_candidates.items()): - # Skip if real directory exists if proj_name in real_projects: if verbose: logger.info(" Skip: %s (real directory exists)", proj_name) continue - # Sort by priority (highest first), then by name (alphabetical) - candidates.sort(key=lambda c: (-c[1], c[0])) + candidates.sort(key=lambda c: (-c[1], c[0].name)) winner_plugin, winner_priority, winner_dir = candidates[0] if len(candidates) > 1 and verbose: logger.warning( "Project '%s' exists in multiple plugins — using '%s' (priority: %d)", - proj_name, winner_plugin, winner_priority, + proj_name, winner_plugin.name, winner_priority, ) - - # Create relative symlink - target = Path('..') / 'plugins' / winner_plugin / 'projects' / proj_name + for loser_plugin, _, _ in candidates[1:]: + owner = _extract_owner(loser_plugin) + logger.info( + " Also available as: projects/%s.%s", + proj_name, owner, + ) + + target = _make_relative_target(winner_plugin, proj_name) link_path = projects_dir / proj_name link_path.symlink_to(target) created += 1 + if len(candidates) > 1: + for loser_plugin, _, loser_dir in candidates[1:]: + owner = _extract_owner(loser_plugin) + suffix_name = f"{proj_name}.{owner}" + + if suffix_name in real_projects: + if verbose: + logger.info(" Skip: %s (real directory exists)", suffix_name) + continue + + suffix_target = _make_relative_target(loser_plugin, proj_name) + suffix_link = projects_dir / suffix_name + suffix_link.symlink_to(suffix_target) + created += 1 + if verbose: logger.info("Synced %d project(s) from %d plugin(s)", created, len(installed)) return created + + +def _make_relative_target(plugin: InstalledPlugin, proj_name: str) -> Path: + """Build the relative symlink target from projects/ to a plugin's project.""" + return Path('..') / plugin.path / 'projects' / proj_name diff --git a/lib/devbase/plugin/updater.py b/lib/devbase/plugin/updater.py index 4f1045b..3e43a24 100644 --- a/lib/devbase/plugin/updater.py +++ b/lib/devbase/plugin/updater.py @@ -1,16 +1,15 @@ """Plugin updater - handles update and migration operations""" -import shutil -import tempfile from pathlib import Path from typing import Optional from devbase.errors import PluginError from devbase.log import get_logger -from .installer import git_clone, resolve_repo_url, parse_registry_yml, copy_plugin +from .installer import parse_registry_yml from .models import InstalledPlugin, RegistryInfo from .registry import PluginRegistry +from .repo_manager import _git_pull from .syncer import sync_projects, discover_projects logger = get_logger("devbase.plugin.updater") @@ -33,7 +32,6 @@ def _migrate_removed_plugin( plugin: InstalledPlugin, clone_dir: Path, reg_info: RegistryInfo, - plugins_dir: Path, ) -> bool: """Migrate a plugin that no longer exists in the source. @@ -47,16 +45,11 @@ def _migrate_removed_plugin( if not old_projects: logger.info(" Plugin '%s' has no projects — removing", plugin.name) - plugin_dir = plugins_dir / plugin.name - if plugin_dir.is_dir(): - shutil.rmtree(plugin_dir) registry.remove(plugin.name) return True - # Build project → new_plugin mapping from source project_to_plugin = _discover_source_projects(clone_dir, reg_info) - # Find which new plugins contain the old projects replacement_plugins: dict[str, list[str]] = {} unmapped_projects: list[str] = [] for proj in sorted(old_projects): @@ -78,29 +71,29 @@ def _migrate_removed_plugin( for p in unmapped_projects: logger.warning(" - %s", p) - # Remove old plugin - old_dir = plugins_dir / plugin.name - if old_dir.is_dir(): - shutil.rmtree(old_dir) registry.remove(plugin.name) - # Install replacement plugins (skip already installed ones) + repo_reg = registry.get_repository_by_url(plugin.source) + repo_local_path = repo_reg.local_path if repo_reg else "" + for new_name in sorted(replacement_plugins): if registry.get(new_name): logger.info(" Skip: '%s' already installed", new_name) continue entry = next((e for e in reg_info.plugins if e.name == new_name), None) - if entry: + if entry and repo_local_path: + from .installer import _register_repo_plugin plugin_path = clone_dir / entry.path.rstrip('/') - copy_plugin( - registry, entry.name, plugin_path, plugin.source, plugins_dir, + _register_repo_plugin( + registry, entry.name, plugin_path, + plugin.source, repo_local_path, ) return True def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: - """Update a plugin (or all if name is None). + """Update a plugin (or all if name is None) via git pull. Raises PluginError on failure. """ @@ -116,7 +109,9 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: if name and not targets: raise PluginError(f"Plugin '{name}' is not installed") + updated_repos: set[str] = set() errors = [] + for plugin in targets: if plugin.linked: logger.info("Skip: '%s' is locally linked (update manually)", plugin.name) @@ -129,45 +124,66 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: ) continue - logger.info("Updating '%s' from %s...", plugin.name, plugin.source) + repo_reg = registry.get_repository_by_url(plugin.source) + if not repo_reg or not repo_reg.local_path: + errors.append( + f"Plugin '{plugin.name}': repository not found or has no local clone. " + "Use 'devbase plugin repo add' to re-register." + ) + continue - repo_url = resolve_repo_url(plugin.source) - plugins_dir = registry.get_plugins_dir() + clone_dir = registry.devbase_root / repo_reg.local_path + if not clone_dir.is_dir(): + errors.append( + f"Plugin '{plugin.name}': clone directory not found: {clone_dir}" + ) + continue - with tempfile.TemporaryDirectory() as tmpdir: - clone_dir = Path(tmpdir) / 'repo' + if repo_reg.url not in updated_repos: + logger.info("Updating '%s' via git pull in %s...", plugin.name, clone_dir) try: - git_clone(repo_url, clone_dir) + _git_pull(clone_dir) except PluginError as e: errors.append(str(e)) continue + updated_repos.add(repo_reg.url) + else: + logger.info("Updating '%s' (repo already pulled)...", plugin.name) - reg_info = parse_registry_yml(clone_dir) - if not reg_info: - errors.append(f"No registry.yml in source for '{plugin.name}'") - continue + reg_info = parse_registry_yml(clone_dir) + if not reg_info: + errors.append(f"No registry.yml in source for '{plugin.name}'") + continue - target_entry = None - for entry in reg_info.plugins: - if entry.name == plugin.name: - target_entry = entry - break - - if not target_entry: - logger.info(" Plugin '%s' no longer exists in source", plugin.name) - if not _migrate_removed_plugin( - registry, plugin, clone_dir, reg_info, plugins_dir, - ): - errors.append(f"Migration failed for '{plugin.name}'") - continue + target_entry = None + for entry in reg_info.plugins: + if entry.name == plugin.name: + target_entry = entry + break + + if not target_entry: + logger.info(" Plugin '%s' no longer exists in source", plugin.name) + if not _migrate_removed_plugin( + registry, plugin, clone_dir, reg_info, + ): + errors.append(f"Migration failed for '{plugin.name}'") + continue - plugin_path = clone_dir / target_entry.path.rstrip('/') - try: - copy_plugin( - registry, plugin.name, plugin_path, plugin.source, plugins_dir - ) - except PluginError as e: - errors.append(str(e)) + plugin_path = clone_dir / target_entry.path.rstrip('/') + from .syncer import load_plugin_info + info = load_plugin_info(plugin_path) + version = info.version if info else '0.1.0' + + rel_path = f"{repo_reg.local_path}/{plugin.name}" + registry.add(InstalledPlugin( + name=plugin.name, + version=version, + source=plugin.source, + installed_at=plugin.installed_at, + path=rel_path, + linked=False, + )) + logger.info("Updated plugin '%s' (v%s)", plugin.name, version) sync_projects(registry) diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py new file mode 100644 index 0000000..b752a96 --- /dev/null +++ b/tests/plugin/test_repos_core.py @@ -0,0 +1,701 @@ +"""Tests for PLAN04 repos/ persistent clone + direct link install""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from devbase.errors import PluginError, RepositoryError +from devbase.plugin.models import ( + AvailablePlugin, + InstalledPlugin, + RegisteredRepository, +) +from devbase.plugin.registry import PluginRegistry +from devbase.plugin.repo_manager import ( + _derive_repo_name, + _is_repo_dirty, + _url_to_repos_dirname, + add_repository, + refresh_repository, + remove_repository, +) +from devbase.plugin.installer import ( + git_clone, + install_plugin, + uninstall_plugin, +) +from devbase.plugin.syncer import ( + _extract_owner, + _make_relative_target, + sync_projects, +) + + +# ── Fixtures ──────────────────────────────────────────────────── + + +@pytest.fixture +def devbase_root(tmp_path): + """Create a minimal devbase directory structure.""" + (tmp_path / "projects").mkdir() + return tmp_path + + +@pytest.fixture +def registry(devbase_root): + return PluginRegistry(devbase_root) + + +def _make_repo_dir(devbase_root: Path, owner_repo: str, plugins: list[dict]) -> Path: + """Create a fake repos/--/ with registry.yml and plugin dirs.""" + dir_name = owner_repo.replace("/", "--") + repo_dir = devbase_root / "repos" / dir_name + repo_dir.mkdir(parents=True, exist_ok=True) + + # Create a fake .git directory + (repo_dir / ".git").mkdir(exist_ok=True) + + plugin_entries = [] + for p in plugins: + pdir = repo_dir / p["path"] + pdir.mkdir(parents=True, exist_ok=True) + # plugin.yml + import yaml + with open(pdir / "plugin.yml", "w") as f: + yaml.dump({ + "name": p["name"], + "version": p.get("version", "1.0.0"), + "priority": p.get("priority", 0), + }, f) + # projects/ + for proj in p.get("projects", []): + (pdir / "projects" / proj).mkdir(parents=True, exist_ok=True) + plugin_entries.append({ + "name": p["name"], + "path": p["path"], + "description": p.get("description", ""), + }) + + import yaml + with open(repo_dir / "registry.yml", "w") as f: + yaml.dump({ + "name": owner_repo.split("/")[-1], + "plugins": plugin_entries, + }, f) + + return repo_dir + + +def _register_repo(registry: PluginRegistry, owner_repo: str, url: str, plugins: list[dict]): + """Register a repository in plugins.yml with local_path.""" + dir_name = owner_repo.replace("/", "--") + repo = RegisteredRepository( + name=owner_repo.split("/")[-1], + url=url, + added_at=registry.now_iso(), + local_path=f"repos/{dir_name}", + plugins=[ + AvailablePlugin(name=p["name"], description=p.get("description", ""), path=p["path"]) + for p in plugins + ], + ) + registry.add_repository(repo) + + +# ── models.py ─────────────────────────────────────────────────── + + +class TestRegisteredRepositoryLocalPath: + def test_to_dict_includes_local_path(self): + repo = RegisteredRepository( + name="test", + url="https://github.com/test/repo.git", + local_path="repos/test--repo", + ) + d = repo.to_dict() + assert d["local_path"] == "repos/test--repo" + + def test_to_dict_omits_empty_local_path(self): + repo = RegisteredRepository( + name="test", + url="https://github.com/test/repo.git", + ) + d = repo.to_dict() + assert "local_path" not in d + + def test_from_dict_reads_local_path(self): + repo = RegisteredRepository.from_dict({ + "name": "test", + "url": "https://github.com/test/repo.git", + "local_path": "repos/test--repo", + }) + assert repo.local_path == "repos/test--repo" + + def test_from_dict_defaults_empty_local_path(self): + repo = RegisteredRepository.from_dict({ + "name": "test", + "url": "https://github.com/test/repo.git", + }) + assert repo.local_path == "" + + +# ── registry.py ───────────────────────────────────────────────── + + +class TestGetReposDir: + def test_returns_repos_path(self, registry, devbase_root): + assert registry.get_repos_dir() == devbase_root / "repos" + + +# ── repo_manager.py ───────────────────────────────────────────── + + +class TestUrlToReposDirname: + def test_github_https(self): + assert _url_to_repos_dirname("https://github.com/devbasex/devbase-samples.git") == "devbasex--devbase-samples" + + def test_github_ssh(self): + assert _url_to_repos_dirname("git@github.com:user/my-repo.git") == "user--my-repo" + + def test_owner_with_hyphens(self): + assert _url_to_repos_dirname("https://github.com/takemi-ohama/devbase-ext.git") == "takemi-ohama--devbase-ext" + + +class TestDeriveRepoName: + def test_github_https(self): + assert _derive_repo_name("https://github.com/devbasex/devbase-samples.git") == "devbasex/devbase-samples" + + def test_github_ssh(self): + assert _derive_repo_name("git@github.com:user/my-repo.git") == "user/my-repo" + + +class TestAddRepository: + def test_add_creates_persistent_clone(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + repos_dir = devbase_root / "repos" + + with patch("devbase.plugin.repo_manager.git_clone") as mock_clone, \ + patch("devbase.plugin.repo_manager.parse_registry_yml") as mock_parse: + + def fake_clone(u, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + (dest / ".git").mkdir() + + mock_clone.side_effect = fake_clone + + from devbase.plugin.models import RegistryInfo, RegistryEntry + mock_parse.return_value = RegistryInfo( + name="testrepo", + plugins=[RegistryEntry(name="myplugin", path="myplugin", description="test")], + ) + + add_repository(registry, url) + + mock_clone.assert_called_once() + call_kwargs = mock_clone.call_args + assert call_kwargs.kwargs.get("shallow") is False or ( + len(call_kwargs.args) >= 3 or "shallow" in str(call_kwargs) + ) + + repo = registry.get_repository("testrepo") + assert repo is not None + assert repo.local_path == "repos/testorg--testrepo" + assert repo.url == url + + def test_add_duplicate_url_raises(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _register_repo(registry, "testorg/testrepo", url, []) + + with pytest.raises(RepositoryError, match="already registered"): + add_repository(registry, url) + + +class TestRemoveRepository: + def test_remove_deletes_clone_dir(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + repo_dir = _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + ]) + + remove_repository(registry, "testrepo", force=True) + + assert not repo_dir.exists() + assert registry.get_repository("testrepo") is None + + def test_remove_dirty_repo_raises_without_force(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", []) + _register_repo(registry, "testorg/testrepo", url, []) + + with patch("devbase.plugin.repo_manager._is_repo_dirty", return_value=(True, "uncommitted changes")): + with pytest.raises(RepositoryError, match="uncommitted changes"): + remove_repository(registry, "testrepo") + + def test_remove_dirty_repo_succeeds_with_force(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + repo_dir = _make_repo_dir(devbase_root, "testorg/testrepo", []) + _register_repo(registry, "testorg/testrepo", url, []) + + with patch("devbase.plugin.repo_manager._is_repo_dirty", return_value=(True, "uncommitted changes")): + remove_repository(registry, "testrepo", force=True) + + assert not repo_dir.exists() + + def test_remove_uninstalls_plugins_and_syncs(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + ]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + projects_dir = devbase_root / "projects" + link = projects_dir / "proj1" + link.symlink_to(Path("..") / "repos" / "testorg--testrepo" / "p1" / "projects" / "proj1") + + remove_repository(registry, "testrepo", force=True) + + assert registry.get("p1") is None + assert not link.exists() + + +class TestRefreshRepository: + def test_refresh_pulls_and_updates_metadata(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1"}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + ]) + + with patch("devbase.plugin.repo_manager._git_pull"): + refresh_repository(registry, "testrepo") + + repo = registry.get_repository("testrepo") + assert repo is not None + assert any(p.name == "p1" for p in repo.plugins) + + def test_refresh_warns_removed_installed_plugin(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + repo_dir = _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p2", "path": "p2"}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + {"name": "p2", "path": "p2"}, + ]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + with patch("devbase.plugin.repo_manager._git_pull"), \ + patch("devbase.plugin.repo_manager.logger") as mock_logger: + refresh_repository(registry, "testrepo") + mock_logger.warning.assert_any_call( + "Installed plugin '%s' no longer exists in registry.yml of '%s'", + "p1", "testrepo", + ) + + +# ── installer.py ──────────────────────────────────────────────── + + +class TestGitClone: + def test_shallow_true_adds_depth(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + dest = Path("/tmp/test-clone") + dest.parent.mkdir(parents=True, exist_ok=True) + git_clone("https://example.com/repo.git", dest, shallow=True) + cmd = mock_run.call_args[0][0] + assert "--depth" in cmd + + def test_shallow_false_no_depth(self): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + dest = Path("/tmp/test-clone2") + dest.parent.mkdir(parents=True, exist_ok=True) + git_clone("https://example.com/repo.git", dest, shallow=False) + cmd = mock_run.call_args[0][0] + assert "--depth" not in cmd + + +class TestInstallPlugin: + def test_install_creates_symlinks_via_repos(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "myplugin", "path": "myplugin", "projects": ["myproj"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "myplugin", "path": "myplugin"}, + ]) + + install_plugin(registry, "myplugin") + + plugin = registry.get("myplugin") + assert plugin is not None + assert plugin.path == "repos/testorg--testrepo/myplugin" + assert not plugin.linked + + proj_link = devbase_root / "projects" / "myproj" + assert proj_link.is_symlink() + target = os.readlink(str(proj_link)) + assert "repos/testorg--testrepo/myplugin/projects/myproj" in target + + def test_install_all_plugins(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + {"name": "p2", "path": "p2", "projects": ["proj2"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + {"name": "p2", "path": "p2"}, + ]) + + install_plugin(registry, url, install_all=True) + + assert registry.get("p1") is not None + assert registry.get("p2") is not None + + def test_install_not_registered_raises(self, registry): + with pytest.raises(PluginError, match="not found in registered"): + install_plugin(registry, "nonexistent") + + +class TestUninstallPlugin: + def test_uninstall_repos_plugin_preserves_files(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + repo_dir = _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "myplugin", "path": "myplugin", "projects": ["myproj"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "myplugin", "path": "myplugin"}, + ]) + registry.add(InstalledPlugin( + name="myplugin", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/myplugin", + )) + + proj_link = devbase_root / "projects" / "myproj" + proj_link.symlink_to( + Path("..") / "repos" / "testorg--testrepo" / "myplugin" / "projects" / "myproj" + ) + + uninstall_plugin(registry, "myplugin") + + assert registry.get("myplugin") is None + assert not proj_link.exists() + assert (repo_dir / "myplugin").is_dir() + + def test_uninstall_linked_plugin_removes_symlink(self, registry, devbase_root): + plugins_dir = devbase_root / "plugins" + plugins_dir.mkdir() + + local_src = devbase_root / "local-repo" / "myplugin" + local_src.mkdir(parents=True) + (local_src / "plugin.yml").write_text("name: myplugin\nversion: 1.0.0\n") + + link_dest = plugins_dir / "myplugin" + link_dest.symlink_to(local_src) + + registry.add(InstalledPlugin( + name="myplugin", version="1.0.0", source=str(local_src.parent), + installed_at=registry.now_iso(), + path="plugins/myplugin", + linked=True, + )) + + uninstall_plugin(registry, "myplugin") + + assert not link_dest.exists() + assert local_src.is_dir() + + def test_uninstall_nonexistent_raises(self, registry): + with pytest.raises(PluginError, match="not installed"): + uninstall_plugin(registry, "nonexistent") + + +# ── syncer.py ─────────────────────────────────────────────────── + + +class TestSyncProjects: + def test_basic_sync_creates_symlinks(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1", "proj2"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + ]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "proj1").is_symlink() + assert (devbase_root / "projects" / "proj2").is_symlink() + + def test_collision_creates_suffix_links(self, registry, devbase_root): + url1 = "https://github.com/orgA/repo1.git" + url2 = "https://github.com/orgB/repo2.git" + _make_repo_dir(devbase_root, "orgA/repo1", [ + {"name": "p1", "path": "p1", "projects": ["shared"], "priority": 10}, + ]) + _make_repo_dir(devbase_root, "orgB/repo2", [ + {"name": "p2", "path": "p2", "projects": ["shared"], "priority": 0}, + ]) + _register_repo(registry, "orgA/repo1", url1, [{"name": "p1", "path": "p1"}]) + _register_repo(registry, "orgB/repo2", url2, [{"name": "p2", "path": "p2"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url1, + installed_at=registry.now_iso(), + path="repos/orgA--repo1/p1", + )) + registry.add(InstalledPlugin( + name="p2", version="1.0.0", source=url2, + installed_at=registry.now_iso(), + path="repos/orgB--repo2/p2", + )) + + count = sync_projects(registry, verbose=False) + + bare_link = devbase_root / "projects" / "shared" + assert bare_link.is_symlink() + target = os.readlink(str(bare_link)) + assert "orgA--repo1" in target + + suffix_link = devbase_root / "projects" / "shared.orgB" + assert suffix_link.is_symlink() + suffix_target = os.readlink(str(suffix_link)) + assert "orgB--repo2" in suffix_target + + def test_no_collision_no_suffix(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [{"name": "p1", "path": "p1"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + sync_projects(registry, verbose=False) + + assert not (devbase_root / "projects" / "proj1.testorg").exists() + + def test_winner_has_no_suffix(self, registry, devbase_root): + url1 = "https://github.com/orgA/repo1.git" + url2 = "https://github.com/orgB/repo2.git" + _make_repo_dir(devbase_root, "orgA/repo1", [ + {"name": "p1", "path": "p1", "projects": ["shared"], "priority": 10}, + ]) + _make_repo_dir(devbase_root, "orgB/repo2", [ + {"name": "p2", "path": "p2", "projects": ["shared"], "priority": 0}, + ]) + _register_repo(registry, "orgA/repo1", url1, [{"name": "p1", "path": "p1"}]) + _register_repo(registry, "orgB/repo2", url2, [{"name": "p2", "path": "p2"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url1, + installed_at=registry.now_iso(), + path="repos/orgA--repo1/p1", + )) + registry.add(InstalledPlugin( + name="p2", version="1.0.0", source=url2, + installed_at=registry.now_iso(), + path="repos/orgB--repo2/p2", + )) + + sync_projects(registry, verbose=False) + + assert not (devbase_root / "projects" / "shared.orgA").exists() + + def test_real_directory_skipped(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["existing"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [{"name": "p1", "path": "p1"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + (devbase_root / "projects" / "existing").mkdir() + + count = sync_projects(registry, verbose=False) + assert count == 0 + assert not (devbase_root / "projects" / "existing").is_symlink() + + def test_missing_plugin_dir_warns(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _register_repo(registry, "testorg/testrepo", url, [{"name": "p1", "path": "p1"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + count = sync_projects(registry, verbose=True) + assert count == 0 + + def test_link_plugin_collision_uses_source_basename(self, registry, devbase_root): + """--link plugin と repos/ plugin の衝突時に . suffix""" + url = "https://github.com/orgA/repo1.git" + _make_repo_dir(devbase_root, "orgA/repo1", [ + {"name": "p1", "path": "p1", "projects": ["shared"], "priority": 10}, + ]) + _register_repo(registry, "orgA/repo1", url, [{"name": "p1", "path": "p1"}]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/orgA--repo1/p1", + )) + + plugins_dir = devbase_root / "plugins" + plugins_dir.mkdir() + local_plugin = devbase_root / "my-local-repo" / "p2" + local_plugin.mkdir(parents=True) + (local_plugin / "plugin.yml").write_text("name: p2\nversion: 1.0.0\npriority: 0\n") + (local_plugin / "projects" / "shared").mkdir(parents=True) + link = plugins_dir / "p2" + link.symlink_to(local_plugin) + + registry.add(InstalledPlugin( + name="p2", version="1.0.0", source=str(devbase_root / "my-local-repo"), + installed_at=registry.now_iso(), + path="plugins/p2", + linked=True, + )) + + sync_projects(registry, verbose=False) + + suffix_link = devbase_root / "projects" / "shared.my-local-repo" + assert suffix_link.is_symlink() + + +class TestExtractOwner: + def test_repos_based(self): + plugin = InstalledPlugin( + name="p1", version="1.0.0", source="url", + installed_at="", path="repos/orgA--repo1/p1", + ) + assert _extract_owner(plugin) == "orgA" + + def test_linked(self): + plugin = InstalledPlugin( + name="p1", version="1.0.0", source="/path/to/my-local-repo", + installed_at="", path="plugins/p1", linked=True, + ) + assert _extract_owner(plugin) == "my-local-repo" + + +class TestMakeRelativeTarget: + def test_repos_based(self): + plugin = InstalledPlugin( + name="p1", version="1.0.0", source="url", + installed_at="", path="repos/orgA--repo1/p1", + ) + target = _make_relative_target(plugin, "myproj") + assert target == Path("..") / "repos" / "orgA--repo1" / "p1" / "projects" / "myproj" + + def test_linked(self): + plugin = InstalledPlugin( + name="p1", version="1.0.0", source="/path/to/repo", + installed_at="", path="plugins/p1", linked=True, + ) + target = _make_relative_target(plugin, "myproj") + assert target == Path("..") / "plugins" / "p1" / "projects" / "myproj" + + +# ── updater.py ────────────────────────────────────────────────── + + +class TestUpdatePlugin: + def test_update_calls_git_pull(self, registry, devbase_root): + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + ]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + + from devbase.plugin.updater import update_plugin + + with patch("devbase.plugin.updater._git_pull") as mock_pull: + update_plugin(registry, "p1") + mock_pull.assert_called_once() + + def test_update_skips_linked(self, registry, devbase_root): + registry.add(InstalledPlugin( + name="linked-plugin", version="1.0.0", source="/local/path", + installed_at=registry.now_iso(), + path="plugins/linked-plugin", + linked=True, + )) + + from devbase.plugin.updater import update_plugin + + with patch("devbase.plugin.updater._git_pull") as mock_pull: + update_plugin(registry, "linked-plugin") + mock_pull.assert_not_called() + + def test_update_deduplicates_git_pull(self, registry, devbase_root): + """Same repo pulled only once even when multiple plugins share it.""" + url = "https://github.com/testorg/testrepo.git" + _make_repo_dir(devbase_root, "testorg/testrepo", [ + {"name": "p1", "path": "p1", "projects": ["proj1"]}, + {"name": "p2", "path": "p2", "projects": ["proj2"]}, + ]) + _register_repo(registry, "testorg/testrepo", url, [ + {"name": "p1", "path": "p1"}, + {"name": "p2", "path": "p2"}, + ]) + registry.add(InstalledPlugin( + name="p1", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p1", + )) + registry.add(InstalledPlugin( + name="p2", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/testorg--testrepo/p2", + )) + + from devbase.plugin.updater import update_plugin + + with patch("devbase.plugin.updater._git_pull") as mock_pull: + update_plugin(registry) + assert mock_pull.call_count == 1 From 59fc306282f60c48ffd9c4f33c49e2825598ce2c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 14:29:46 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix(plugin):=20=E3=82=B5=E3=83=96=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=83=BBdirty=E6=A4=9C=E7=9F=A5=E3=83=BBsuffix=E8=A1=9D?= =?UTF-8?q?=E7=AA=81=E3=81=AE3=E4=BB=B6=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installer.py / updater.py: rel_path を plugin名ではなく plugin_path.relative_to(devbase_root) で算出し、 registry.yml の path が name と異なるサブディレクトリ配置に対応 - repo_manager.py: upstream未設定時に @{u}..HEAD が失敗して dirty=false となりデータ損失の恐れがあった問題を修正。 upstream未設定時は dirty 扱いにして安全側に倒す - syncer.py: collision suffix を owner のみ → owner--repo に変更し、 同一 owner の複数 repo で同名 project が衝突する問題を修正。 既存 symlink 存在チェックも追加 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/plugin/installer.py | 4 +++- lib/devbase/plugin/repo_manager.py | 19 +++++++++++++++---- lib/devbase/plugin/syncer.py | 15 ++++++++++----- lib/devbase/plugin/updater.py | 4 +++- tests/plugin/test_repos_core.py | 4 ++-- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index 45d868a..f12824e 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -280,7 +280,9 @@ def _register_repo_plugin( info = load_plugin_info(plugin_path) version = info.version if info else '0.1.0' - rel_path = f"{repo_local_path}/{name}" + # Use the actual plugin_path relative to devbase_root so that + # subdirectory plugins (registry.yml path != name) resolve correctly. + rel_path = str(plugin_path.relative_to(registry.devbase_root)) registry.add(InstalledPlugin( name=name, diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index 29eb7bc..c3cce52 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -82,12 +82,23 @@ def _is_repo_dirty(repo_dir: Path) -> tuple[bool, str]: pass try: - result = subprocess.run( - ['git', 'log', '--oneline', '@{u}..HEAD'], + # Check if upstream tracking branch exists + upstream_check = subprocess.run( + ['git', 'rev-parse', '--abbrev-ref', '@{u}'], capture_output=True, text=True, cwd=str(repo_dir), ) - if result.returncode == 0 and result.stdout.strip(): - issues.append("unpushed commits") + if upstream_check.returncode == 0: + # Upstream exists — check for unpushed commits + result = subprocess.run( + ['git', 'log', '--oneline', '@{u}..HEAD'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + if result.returncode == 0 and result.stdout.strip(): + issues.append("unpushed commits") + else: + # No upstream tracking branch — local commits may be lost + # if deleted, so treat as dirty to be safe + issues.append("no upstream tracking branch (local-only commits may exist)") except subprocess.CalledProcessError: pass diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 46e822e..1703e57 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -41,9 +41,10 @@ def discover_projects(plugin_dir: Path) -> list[str]: def _extract_owner(plugin: InstalledPlugin) -> str: - """Extract owner identifier from a plugin for suffix generation. + """Extract a unique suffix identifier from a plugin for collision resolution. - For repos/-based plugins: owner part from repos/--/ + For repos/-based plugins: full owner--repo dirname from repos/--/... + This ensures uniqueness when the same owner has multiple repos. For --link plugins: basename of the source path """ if plugin.linked: @@ -51,9 +52,9 @@ def _extract_owner(plugin: InstalledPlugin) -> str: parts = plugin.path.split('/') if len(parts) >= 2 and parts[0] == 'repos': - dir_name = parts[1] - if '--' in dir_name: - return dir_name.split('--', 1)[0] + # Return full dir_name (owner--repo) to avoid collision + # between repos from the same owner + return parts[1] return plugin.name @@ -146,6 +147,10 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: suffix_target = _make_relative_target(loser_plugin, proj_name) suffix_link = projects_dir / suffix_name + if suffix_link.exists() or suffix_link.is_symlink(): + if verbose: + logger.warning(" Skip: %s (symlink already exists)", suffix_name) + continue suffix_link.symlink_to(suffix_target) created += 1 diff --git a/lib/devbase/plugin/updater.py b/lib/devbase/plugin/updater.py index 3e43a24..a922113 100644 --- a/lib/devbase/plugin/updater.py +++ b/lib/devbase/plugin/updater.py @@ -174,7 +174,9 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: info = load_plugin_info(plugin_path) version = info.version if info else '0.1.0' - rel_path = f"{repo_reg.local_path}/{plugin.name}" + # Use actual plugin_path relative to devbase_root so that + # subdirectory plugins (registry.yml path != name) resolve correctly. + rel_path = str(plugin_path.relative_to(registry.devbase_root)) registry.add(InstalledPlugin( name=plugin.name, version=version, diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index b752a96..9578817 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -488,7 +488,7 @@ def test_collision_creates_suffix_links(self, registry, devbase_root): target = os.readlink(str(bare_link)) assert "orgA--repo1" in target - suffix_link = devbase_root / "projects" / "shared.orgB" + suffix_link = devbase_root / "projects" / "shared.orgB--repo2" assert suffix_link.is_symlink() suffix_target = os.readlink(str(suffix_link)) assert "orgB--repo2" in suffix_target @@ -606,7 +606,7 @@ def test_repos_based(self): name="p1", version="1.0.0", source="url", installed_at="", path="repos/orgA--repo1/p1", ) - assert _extract_owner(plugin) == "orgA" + assert _extract_owner(plugin) == "orgA--repo1" def test_linked(self): plugin = InstalledPlugin( From 3cc7716cd711459482f9431d5ca96ff83e211fa1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 14:43:49 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix(plugin):=20pull=E5=89=8D=E3=82=B9?= =?UTF-8?q?=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=83=BBrepo=E5=85=A8=E4=BD=93=E6=9B=B4=E6=96=B0=E3=83=BBclone?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=AF=E3=83=AA=E3=83=BC=E3=83=B3=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updater: git pull 前に旧 plugin の projects をスナップショットし、 pull 後の migration で旧ディレクトリが消えても移行先を検出可能に - updater: name 指定の update でも同一 repo の全 installed plugin の metadata (version/path) を pull 後に再読み込みして整合性を維持 - repo_manager: repo add で clone 後の registry.yml parse や名前衝突 失敗時に clone_dir を自動削除し、リトライ時の詰まりを防止 - syncer: path.split('/') を Path().parts に変更 (OS 非依存化) Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/plugin/repo_manager.py | 33 ++++--- lib/devbase/plugin/syncer.py | 2 +- lib/devbase/plugin/updater.py | 152 +++++++++++++++++++++-------- 3 files changed, 130 insertions(+), 57 deletions(-) diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index c3cce52..9914a94 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -161,24 +161,29 @@ def add_repository( try: reg_info = parse_registry_yml(clone_dir) - except PluginError as e: - raise RepositoryError(str(e)) - if not reg_info: - raise RepositoryError(f"No registry.yml found in {repo_url}") + if not reg_info: + raise RepositoryError(f"No registry.yml found in {repo_url}") - derived_name = _derive_repo_name(repo_url) - candidate_name = name or reg_info.name or derived_name + derived_name = _derive_repo_name(repo_url) + candidate_name = name or reg_info.name or derived_name - if registry.get_repository(candidate_name) and candidate_name != derived_name: - candidate_name = derived_name + if registry.get_repository(candidate_name) and candidate_name != derived_name: + candidate_name = derived_name - repo_name = candidate_name + repo_name = candidate_name - if registry.get_repository(repo_name): - raise RepositoryError( - f"Repository name '{repo_name}' already exists.\n" - "Use --name to specify a different name." - ) + if registry.get_repository(repo_name): + raise RepositoryError( + f"Repository name '{repo_name}' already exists.\n" + "Use --name to specify a different name." + ) + except Exception: + # Clean up the cloned directory so a retry won't fail with + # "Directory already exists". + import shutil as _shutil + if clone_dir.is_dir(): + _shutil.rmtree(clone_dir) + raise plugins = [ AvailablePlugin( diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 1703e57..771b4ed 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -50,7 +50,7 @@ def _extract_owner(plugin: InstalledPlugin) -> str: if plugin.linked: return Path(plugin.source).name if plugin.source else plugin.name - parts = plugin.path.split('/') + parts = Path(plugin.path).parts if len(parts) >= 2 and parts[0] == 'repos': # Return full dir_name (owner--repo) to avoid collision # between repos from the same owner diff --git a/lib/devbase/plugin/updater.py b/lib/devbase/plugin/updater.py index a922113..b60e52a 100644 --- a/lib/devbase/plugin/updater.py +++ b/lib/devbase/plugin/updater.py @@ -32,16 +32,25 @@ def _migrate_removed_plugin( plugin: InstalledPlugin, clone_dir: Path, reg_info: RegistryInfo, + pre_pull_projects: Optional[set[str]] = None, ) -> bool: """Migrate a plugin that no longer exists in the source. Detects which new plugins contain the old plugin's projects and replaces the old plugin with them. + + Args: + pre_pull_projects: Project names captured BEFORE git pull. + If provided, used instead of reading the (now-updated) working tree + so that renamed/moved plugin directories are still detected. """ - old_plugin_dir = registry.devbase_root / plugin.path - old_projects: set[str] = set() - if old_plugin_dir.is_dir(): - old_projects = set(discover_projects(old_plugin_dir)) + if pre_pull_projects is not None: + old_projects = pre_pull_projects + else: + old_plugin_dir = registry.devbase_root / plugin.path + old_projects = set() + if old_plugin_dir.is_dir(): + old_projects = set(discover_projects(old_plugin_dir)) if not old_projects: logger.info(" Plugin '%s' has no projects — removing", plugin.name) @@ -92,6 +101,90 @@ def _migrate_removed_plugin( return True +def _snapshot_plugin_projects( + registry: PluginRegistry, + plugins: list[InstalledPlugin], +) -> dict[str, set[str]]: + """Snapshot project names for each plugin BEFORE git pull. + + Returns {plugin_name: {project_names}} so migration can detect + where old projects moved even after the working tree is updated. + """ + result: dict[str, set[str]] = {} + for plugin in plugins: + plugin_dir = registry.devbase_root / plugin.path + if plugin_dir.is_dir(): + result[plugin.name] = set(discover_projects(plugin_dir)) + else: + result[plugin.name] = set() + return result + + +def _update_repo_plugins( + registry: PluginRegistry, + repo_url: str, + clone_dir: Path, + repo_local_path: str, + pre_pull_projects: Optional[dict[str, set[str]]] = None, +) -> list[str]: + """Re-read registry.yml and update ALL installed plugins from the given repo. + + After git pull updates the working tree, every installed plugin from the + same repository must have its plugins.yml metadata (version, path) refreshed + — not just the one the user asked for. + + Args: + pre_pull_projects: {plugin_name: {project_names}} captured before pull. + Passed to _migrate_removed_plugin so it can detect project moves + even when the old directory was renamed/deleted by pull. + + Returns a list of error messages (empty on full success). + """ + reg_info = parse_registry_yml(clone_dir) + if not reg_info: + return [f"No registry.yml in source for repo '{repo_url}'"] + + errors: list[str] = [] + installed = registry.list_installed() + repo_plugins = [p for p in installed if p.source == repo_url and not p.linked] + + for plugin in repo_plugins: + target_entry = next( + (e for e in reg_info.plugins if e.name == plugin.name), None, + ) + + if not target_entry: + logger.info(" Plugin '%s' no longer exists in source", plugin.name) + snapshot = ( + pre_pull_projects.get(plugin.name) + if pre_pull_projects else None + ) + if not _migrate_removed_plugin( + registry, plugin, clone_dir, reg_info, + pre_pull_projects=snapshot, + ): + errors.append(f"Migration failed for '{plugin.name}'") + continue + + plugin_path = clone_dir / target_entry.path.rstrip('/') + from .syncer import load_plugin_info + info = load_plugin_info(plugin_path) + version = info.version if info else '0.1.0' + + rel_path = str(plugin_path.relative_to(registry.devbase_root)) + registry.add(InstalledPlugin( + name=plugin.name, + version=version, + source=plugin.source, + installed_at=plugin.installed_at, + path=rel_path, + linked=False, + )) + logger.info("Updated plugin '%s' (v%s)", plugin.name, version) + + return errors + + def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: """Update a plugin (or all if name is None) via git pull. @@ -109,6 +202,10 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: if name and not targets: raise PluginError(f"Plugin '{name}' is not installed") + # Snapshot project lists BEFORE pull so migration can detect moves + # even after the working tree is overwritten by git pull. + _pre_pull_projects = _snapshot_plugin_projects(registry, installed) + updated_repos: set[str] = set() errors = [] @@ -147,45 +244,16 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: errors.append(str(e)) continue updated_repos.add(repo_reg.url) - else: - logger.info("Updating '%s' (repo already pulled)...", plugin.name) - reg_info = parse_registry_yml(clone_dir) - if not reg_info: - errors.append(f"No registry.yml in source for '{plugin.name}'") - continue - - target_entry = None - for entry in reg_info.plugins: - if entry.name == plugin.name: - target_entry = entry - break - - if not target_entry: - logger.info(" Plugin '%s' no longer exists in source", plugin.name) - if not _migrate_removed_plugin( - registry, plugin, clone_dir, reg_info, - ): - errors.append(f"Migration failed for '{plugin.name}'") - continue - - plugin_path = clone_dir / target_entry.path.rstrip('/') - from .syncer import load_plugin_info - info = load_plugin_info(plugin_path) - version = info.version if info else '0.1.0' - - # Use actual plugin_path relative to devbase_root so that - # subdirectory plugins (registry.yml path != name) resolve correctly. - rel_path = str(plugin_path.relative_to(registry.devbase_root)) - registry.add(InstalledPlugin( - name=plugin.name, - version=version, - source=plugin.source, - installed_at=plugin.installed_at, - path=rel_path, - linked=False, - )) - logger.info("Updated plugin '%s' (v%s)", plugin.name, version) + # After pull, update ALL installed plugins from this repo + # (not just the named target) so metadata stays in sync. + repo_errors = _update_repo_plugins( + registry, repo_reg.url, clone_dir, repo_reg.local_path, + pre_pull_projects=_pre_pull_projects, + ) + errors.extend(repo_errors) + else: + logger.info("Skip: '%s' (repo already pulled and refreshed)", plugin.name) sync_projects(registry) From 5f3d44ddafe2bf2aac53bbf79eb58b776e088edb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 14:56:54 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix(plugin):=20round=203=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20=E2=80=94=20=E7=9B=B4=E6=8E=A5=20install=20=E3=81=AE?= =?UTF-8?q?=E8=87=AA=E5=8B=95=20repo=20=E7=99=BB=E9=8C=B2=20+=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installer.py: user/repo:plugin-name 形式で未登録リポジトリを指定した際に 自動で repo add を実行し、既存の直接指定形式を維持 (codex round 3 major) - updater.py: _update_repo_plugins の未使用引数 repo_local_path を削除 (gemini round 3 minor) - repo_manager.py: git_clone を try/except ブロック内に移動し、 部分 clone 失敗時もディレクトリを自動クリーンアップ (gemini round 3 minor) 全 210 テスト PASSED Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/plugin/installer.py | 13 +++++++++++++ lib/devbase/plugin/repo_manager.py | 6 ++---- lib/devbase/plugin/updater.py | 3 +-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index f12824e..f7a1c95 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -119,6 +119,19 @@ def install_plugin( _install_from_local(registry, source, plugins_dir) return + # Auto-register the repository if not already registered, so that + # `devbase plugin install user/repo:plugin-name` keeps working without + # a prior `repo add`. + if not registry.get_repository_by_url(repo_url): + from .repo_manager import add_repository + try: + add_repository(registry, repo_url) + except Exception as e: + raise PluginError( + f"Repository '{repo_url}' is not registered and auto-registration failed: {e}\n" + "Use 'devbase plugin repo add ' to register manually." + ) + _install_from_repo( registry, PluginSource( repo=repo_url, plugin_name=source.plugin_name, ref=source.ref, linked=False, diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index 9914a94..f08f759 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -156,10 +156,7 @@ def add_repository( try: git_clone(repo_url, clone_dir, shallow=False) - except PluginError as e: - raise RepositoryError(str(e)) - try: reg_info = parse_registry_yml(clone_dir) if not reg_info: raise RepositoryError(f"No registry.yml found in {repo_url}") @@ -179,7 +176,8 @@ def add_repository( ) except Exception: # Clean up the cloned directory so a retry won't fail with - # "Directory already exists". + # "Directory already exists". This also handles partial clones + # (e.g. disk full, network interruption mid-clone). import shutil as _shutil if clone_dir.is_dir(): _shutil.rmtree(clone_dir) diff --git a/lib/devbase/plugin/updater.py b/lib/devbase/plugin/updater.py index b60e52a..4fdd163 100644 --- a/lib/devbase/plugin/updater.py +++ b/lib/devbase/plugin/updater.py @@ -124,7 +124,6 @@ def _update_repo_plugins( registry: PluginRegistry, repo_url: str, clone_dir: Path, - repo_local_path: str, pre_pull_projects: Optional[dict[str, set[str]]] = None, ) -> list[str]: """Re-read registry.yml and update ALL installed plugins from the given repo. @@ -248,7 +247,7 @@ def update_plugin(registry: PluginRegistry, name: Optional[str] = None) -> None: # After pull, update ALL installed plugins from this repo # (not just the named target) so metadata stays in sync. repo_errors = _update_repo_plugins( - registry, repo_reg.url, clone_dir, repo_reg.local_path, + registry, repo_reg.url, clone_dir, pre_pull_projects=_pre_pull_projects, ) errors.extend(repo_errors) From 6fef35d2b3d749434d5e845f30a2ca73a9bbf0ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 15:08:50 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix(plugin):=20round=204=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20=E2=80=94=20@ref=20=E6=8B=92=E5=90=A6=E3=83=BBrefresh=20?= =?UTF-8?q?=E3=83=A1=E3=82=BF=E3=83=87=E3=83=BC=E3=82=BF=E5=90=8C=E6=9C=9F?= =?UTF-8?q?=E3=83=BBpull=20=E3=82=A8=E3=83=A9=E3=83=BC=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installer.py: 未登録リポジトリの自動登録時に @ref を明示的に拒否 (永続 clone はデフォルトブランチを追跡するため、pinned ref と矛盾する) - repo_manager.py: refresh_repository で git pull 後に installed plugin の metadata (version/path) を再計算し sync_projects() を実行 - repo_manager.py: _git_pull で upstream tracking branch の有無を事前検査し、 未設定時に具体的な修正手順を含むエラーメッセージを返す Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/plugin/installer.py | 8 +++++++ lib/devbase/plugin/repo_manager.py | 34 +++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index f7a1c95..2e4698a 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -123,6 +123,14 @@ def install_plugin( # `devbase plugin install user/repo:plugin-name` keeps working without # a prior `repo add`. if not registry.get_repository_by_url(repo_url): + if source.ref: + raise PluginError( + f"Cannot use @{source.ref} with unregistered repository '{repo_url}'.\n" + "Permanent clones track the default branch and do not support pinned refs.\n" + "Register the repository first, then install without @ref:\n" + f" devbase plugin repo add {url}\n" + f" devbase plugin install {url}:{source.plugin_name}" + ) from .repo_manager import add_repository try: add_repository(registry, repo_url) diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index f08f759..6462ed3 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -110,8 +110,29 @@ def _is_repo_dirty(repo_dir: Path) -> tuple[bool, str]: def _git_pull(repo_dir: Path) -> None: """Run git pull in a repository directory. - Raises PluginError on failure. + Raises PluginError on failure. Detects missing upstream tracking branch + and provides an actionable error message. """ + # Pre-check: verify an upstream tracking branch is set. + # Without it, `git pull` will fail with a confusing message. + upstream = subprocess.run( + ['git', 'rev-parse', '--abbrev-ref', '@{u}'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + if upstream.returncode != 0: + # Attempt to detect default remote branch and set it + branch_result = subprocess.run( + ['git', 'branch', '--show-current'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown" + raise PluginError( + f"git pull failed in {repo_dir}: no upstream tracking branch.\n" + f"Current branch '{current_branch}' has no remote to pull from.\n" + "This can happen if the branch was changed manually in repos/.\n" + f"Fix with: git -C {repo_dir} branch --set-upstream-to=origin/{current_branch}" + ) + try: subprocess.run( ['git', 'pull'], @@ -346,6 +367,17 @@ def refresh_repository( ) registry.add_repository(updated_repo) + # After pull, update installed plugin metadata (version, path) and + # re-sync project symlinks so that registry.yml changes (e.g. renamed + # paths) are reflected in the installed state. + from .updater import _update_repo_plugins + from .syncer import sync_projects + repo_errors = _update_repo_plugins(registry, repo.url, clone_dir) + if repo_errors: + for err in repo_errors: + logger.warning(" %s", err) + sync_projects(registry) + logger.info("Repository refreshed: %s", repo.name) if plugins: print("Available plugins:") From 99bd8f6bbb745f618a03b1ba3c3666e3dabd44de Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 27 May 2026 15:23:22 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix(plugin):=20round=205=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20=E2=80=94=20NameError=E4=BF=AE=E6=AD=A3=E3=83=BB=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=83=BB=E3=83=AC=E3=82=AC=E3=82=B7=E3=83=BC?= =?UTF-8?q?repo=E7=A7=BB=E8=A1=8C=E3=83=BBbatch=20refresh=E5=8A=B9?= =?UTF-8?q?=E7=8E=87=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installer.py:131 — @ref 拒否時の未定義変数 `url` を `repo_url` に修正 (NameError 解消) - repo_manager.py:133 — _git_pull の upstream 未設定エラーで detached HEAD/remote未設定を個別判定、remote名を動的取得 - repo_manager.py:379 — refresh_repository に sync パラメータ追加、batch refresh 時は最後に1回だけ sync_projects 実行 - installer.py:223 — legacy repo (local_path 未設定) の自動移行: 初回 install 時に永続 clone を作成して local_path を設定 - テスト追加: @ref 拒否の PluginError テスト、legacy repo migration テスト (計 212 tests PASSED) Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/commands/plugin.py | 6 +++- lib/devbase/plugin/installer.py | 44 +++++++++++++++++++++++++--- lib/devbase/plugin/repo_manager.py | 37 +++++++++++++++++++++--- tests/plugin/test_repos_core.py | 46 ++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/lib/devbase/commands/plugin.py b/lib/devbase/commands/plugin.py index b2a5953..b20d31d 100644 --- a/lib/devbase/commands/plugin.py +++ b/lib/devbase/commands/plugin.py @@ -182,9 +182,13 @@ def _repo_refresh(registry, args): errors = [] for repo in repos: try: - refresh_repository(registry, repo.name) + refresh_repository(registry, repo.name, sync=False) except DevbaseError as e: logger.error("%s", e) errors.append(str(e)) + + # Sync once after all repos are refreshed (instead of per-repo) + sync_projects(registry) + if errors: raise DevbaseError(f"{len(errors)} repository refresh(es) failed") diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index 2e4698a..3a8b5dd 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -128,8 +128,8 @@ def install_plugin( f"Cannot use @{source.ref} with unregistered repository '{repo_url}'.\n" "Permanent clones track the default branch and do not support pinned refs.\n" "Register the repository first, then install without @ref:\n" - f" devbase plugin repo add {url}\n" - f" devbase plugin install {url}:{source.plugin_name}" + f" devbase plugin repo add {repo_url}\n" + f" devbase plugin install {repo_url}:{source.plugin_name}" ) from .repo_manager import add_repository try: @@ -220,12 +220,48 @@ def _install_from_repo( Raises PluginError on failure. """ repo_reg = registry.get_repository_by_url(source.repo) - if not repo_reg or not repo_reg.local_path: + if not repo_reg: raise PluginError( - f"Repository '{source.repo}' is not registered or has no local clone.\n" + f"Repository '{source.repo}' is not registered.\n" "Use 'devbase plugin repo add ' first." ) + if not repo_reg.local_path: + # Legacy repository registered before persistent-clone support. + # Auto-migrate by creating a persistent clone in repos/. + logger.info( + "Migrating repository '%s' to persistent clone...", repo_reg.name, + ) + from .repo_manager import _url_to_repos_dirname + dir_name = _url_to_repos_dirname(repo_reg.url) + repos_dir = registry.get_repos_dir() + repos_dir.mkdir(exist_ok=True) + clone_dir = repos_dir / dir_name + + if not clone_dir.is_dir(): + try: + git_clone(repo_reg.url, clone_dir, shallow=False) + except PluginError as e: + raise PluginError( + f"Failed to create persistent clone for '{repo_reg.name}': {e}\n" + "Remove and re-add the repository:\n" + f" devbase plugin repo remove {repo_reg.name}\n" + f" devbase plugin repo add {repo_reg.url}" + ) + + from .models import RegisteredRepository, AvailablePlugin + local_path = f"repos/{dir_name}" + updated_repo = RegisteredRepository( + name=repo_reg.name, + url=repo_reg.url, + added_at=repo_reg.added_at, + local_path=local_path, + plugins=repo_reg.plugins, + ) + registry.add_repository(updated_repo) + repo_reg = updated_repo + logger.info("Repository '%s' migrated to %s", repo_reg.name, local_path) + clone_dir = registry.devbase_root / repo_reg.local_path if not clone_dir.is_dir(): raise PluginError( diff --git a/lib/devbase/plugin/repo_manager.py b/lib/devbase/plugin/repo_manager.py index 6462ed3..4032aa6 100644 --- a/lib/devbase/plugin/repo_manager.py +++ b/lib/devbase/plugin/repo_manager.py @@ -120,17 +120,38 @@ def _git_pull(repo_dir: Path) -> None: capture_output=True, text=True, cwd=str(repo_dir), ) if upstream.returncode != 0: - # Attempt to detect default remote branch and set it + # Detect current branch name branch_result = subprocess.run( ['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=str(repo_dir), ) - current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown" + current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "" + + # Detect the first available remote (usually "origin") + remote_result = subprocess.run( + ['git', 'remote'], + capture_output=True, text=True, cwd=str(repo_dir), + ) + remote_name = "" + if remote_result.returncode == 0 and remote_result.stdout.strip(): + remote_name = remote_result.stdout.strip().splitlines()[0] + + if not current_branch: + raise PluginError( + f"git pull failed in {repo_dir}: HEAD is detached.\n" + "This can happen if the branch was changed manually in repos/.\n" + "Check out a branch first, then retry." + ) + if not remote_name: + raise PluginError( + f"git pull failed in {repo_dir}: no remote configured.\n" + f"Current branch '{current_branch}' has no remote to pull from." + ) raise PluginError( f"git pull failed in {repo_dir}: no upstream tracking branch.\n" f"Current branch '{current_branch}' has no remote to pull from.\n" "This can happen if the branch was changed manually in repos/.\n" - f"Fix with: git -C {repo_dir} branch --set-upstream-to=origin/{current_branch}" + f"Fix with: git -C {repo_dir} branch --set-upstream-to={remote_name}/{current_branch}" ) try: @@ -302,9 +323,16 @@ def show_repositories(registry: PluginRegistry) -> None: def refresh_repository( registry: PluginRegistry, name: str, + *, + sync: bool = True, ) -> None: """Refresh plugin list for a registered repository (git pull + re-read registry.yml). + Args: + sync: If True (default), call sync_projects after updating metadata. + Set to False when refreshing multiple repositories in a batch to + avoid redundant sync calls — the caller should sync once at the end. + Raises RepositoryError if not found. """ repo = registry.get_repository(name) @@ -376,7 +404,8 @@ def refresh_repository( if repo_errors: for err in repo_errors: logger.warning(" %s", err) - sync_projects(registry) + if sync: + sync_projects(registry) logger.info("Repository refreshed: %s", repo.name) if plugins: diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index 9578817..1ba1cdb 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -380,6 +380,52 @@ def test_install_not_registered_raises(self, registry): with pytest.raises(PluginError, match="not found in registered"): install_plugin(registry, "nonexistent") + def test_install_ref_rejected_for_unregistered_repo(self, registry): + """@ref with unregistered repo raises PluginError (not NameError).""" + with pytest.raises(PluginError, match="Cannot use @v1.0"): + install_plugin(registry, "testorg/testrepo:myplugin@v1.0") + + def test_install_legacy_repo_without_local_path(self, registry, devbase_root): + """Legacy repos (no local_path) are auto-migrated to persistent clone.""" + url = "https://github.com/testorg/testrepo.git" + # Register a legacy repo (no local_path) + repo = RegisteredRepository( + name="testrepo", url=url, + added_at=registry.now_iso(), + local_path="", + plugins=[AvailablePlugin(name="p1", description="test", path="p1")], + ) + registry.add_repository(repo) + + with patch("devbase.plugin.installer.git_clone") as mock_clone: + def fake_clone(u, dest, **kwargs): + dest.mkdir(parents=True, exist_ok=True) + # Create plugin dir + registry.yml in the clone + pdir = dest / "p1" + pdir.mkdir() + import yaml + (pdir / "plugin.yml").write_text("name: p1\nversion: 2.0.0\n") + (pdir / "projects").mkdir() + (pdir / "projects" / "proj1").mkdir() + with open(dest / "registry.yml", "w") as f: + yaml.dump({ + "name": "testrepo", + "plugins": [{"name": "p1", "path": "p1", "description": "test"}], + }, f) + + mock_clone.side_effect = fake_clone + + install_plugin(registry, "p1") + + # Repo should now have local_path set + updated = registry.get_repository_by_url(url) + assert updated.local_path == "repos/testorg--testrepo" + + # Plugin should be installed + plugin = registry.get("p1") + assert plugin is not None + assert "repos/testorg--testrepo" in plugin.path + class TestUninstallPlugin: def test_uninstall_repos_plugin_preserves_files(self, registry, devbase_root):