Skip to content

Commit 1ca1c85

Browse files
takemi-ohamaclaude
andauthored
release: PLAN04 plugin repo persistence (#26)
* chore: PLAN04 release branch 作成 * feat(plugin): repos/ 永続クローンによるプラグイン管理と projects/ 直接シンボリックリンク (#29) * chore: PLAN04-repos-core Draft PR 作成 * feat(plugin): repos/ 永続クローン + 直接リンク install (PLAN04-repos-core) 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 ベース走査、同名衝突時の .<owner> suffix リンク - updater.py: git pull ベース update - cli.py: repo remove に --force オプション追加 - .gitignore: repos/ 追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): サブディレクトリ配置・dirty検知・suffix衝突の3件修正 - 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) <noreply@anthropic.com> * fix(plugin): pull前スナップショット・repo全体更新・clone失敗クリーンアップ - 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) <noreply@anthropic.com> * fix(plugin): round 3 レビュー指摘対応 — 直接 install の自動 repo 登録 + cleanup - 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) <noreply@anthropic.com> * fix(plugin): round 4 レビュー指摘対応 — @ref 拒否・refresh メタデータ同期・pull エラー改善 - 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) <noreply@anthropic.com> * fix(plugin): round 5 レビュー指摘対応 — NameError修正・エラーメッセージ改善・レガシーrepo移行・batch refresh効率化 - 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) <noreply@anthropic.com> * fix(plugin): レガシー移行時の整合性修正 + UX改善 (round 6 review) - installer.py: レガシーrepo移行時に parse_registry_yml で検証してから plugins.yml へ保存するように変更。plugins リストも registry.yml から 最新情報を取得して更新 (major x2 対応) - repo_manager.py: 複数リモート時に origin を優先選択 (minor) - repo_manager.py: detached HEAD エラーに具体的な復帰コマンド例を追加 (minor) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): print→logger 統一 + SSH/HTTPS URL 重複登録検知 (deferred nit) - installer.py: _install_from_repo 内の print() を logger.info() に統一 - repo_manager.py: add_repository で SSH/HTTPS 形式の URL バリアント重複を _url_to_repos_dirname 正規化により検知し、RepositoryError で拒否 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): round 1 レビュー指摘対応 — @ref拒否・host付きdirname・refresh snapshot・print→logger・migration テスト - 登録済みrepoへの @ref 指定を PluginError で拒否(codex + gemini 指摘) - _url_to_repos_dirname に host を含め、異なるホストの同名 repo の衝突を防止 - refresh_repository で git pull 前に _snapshot_plugin_projects を取得し _update_repo_plugins に渡すことで、pull 後のディレクトリ変更時も移行可能に - repo_manager.py の残存 print() を logger.info() に統一 - _migrate_removed_plugin / _snapshot_plugin_projects のテスト追加(3件) - refresh の pre_pull_projects 受け渡し検証テスト追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(plugin): 旧 plugins/ コピーインストールを repos/ 永続クローンへ移行する devbase plugin migrate を追加 (#31) * chore: PLAN04-migration Draft PR 作成 * feat(plugin): 既存 plugins/ コピーインストールを repos/ 永続クローンへ移行 PLAN04 PR2。PR1 (#29) で repos/ 永続クローン方式に切り替えたが、PR1 以前に plugins/<name>/ へファイルコピーされた既存インストールは移行されないため、その 移行ロジックを追加する。 - migrator.py (新規): - needs_migration / _is_legacy_plugin: legacy plugins/ インストールの検出 (linked は --link 専用として除外) - _dirs_differ: コピーとクローンの差分検出 (内容変更・追加ファイルを保守的に差分扱い) - migrate: 未クローン repo の永続クローン作成、InstalledPlugin.path の repos/ 書き換え、 差分なしは plugins/<name> 削除・差分ありは <name>.bak 保全、sync_projects 再実行、 --link/.bak/skip が無ければ plugins/ を .gitkeep のみに正規化 - plugin migrate サブコマンド (cli.py / commands/plugin.py) - install/update 初回実行時に _auto_migrate で自動移行 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(plugin): migration の symlink 差分検知漏れ / .bak 上書き / registry 先行更新を修正 cross-review round 1 の major 指摘 4 件 (codex 2 / gemini 2) に対応。 - _dirs_differ: regular file のみ比較していたため legacy copy のみに存在する symlink / 空ディレクトリ / 型不一致を差分として検知できず、後続の shutil.rmtree で silently 削除される恐れがあった。全エントリを対象に 型 + 内容 (file は byte, symlink は target) を比較するよう厳密化 - _unique_bak_path: 既存の <name>.bak を無条件に rmtree していたため、 前回 migration で保全した未整理バックアップが消失する恐れがあった。 存在時は .bak-2, .bak-3 ... と一意名に退避するよう変更 - migrate: filesystem の退避/削除が成功してから registry.add で plugins.yml を repos/ path に書き換えるよう順序を入れ替え。失敗時に registry だけ先行更新され retry も効かなくなる partial state を防止 - _cleanup_plugins_dir: .bak-N 形式も保全 .bak として検知するよう調整 - 上記挙動を網羅するテスト 6 件追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): migration の差分判定厳密化 / partial clone 復旧 / cleanup 報告修正 cross-review round 2 の指摘対応。 - _dirs_differ: upstream 専用追加 (clone にのみ存在) を差分扱いしないよう 変更。コピー側にのみ存在するエントリ・共通エントリの型/target/内容差分の みを preserved 判定に使い、通常の upstream 更新で不要な .bak 退避が発生 しないようにした (codex#91 / gemini#91 重複指摘)。 - _files_equal: read_bytes() の全読み込みを 64KB チャンクのストリーム比較に 置き換え、巨大ファイルでのメモリ枯渇リスクを排除 (gemini#105)。 - _ensure_repo_cloned: 前回 clone 失敗で残った partial dir (.git 無し / registry.yml 不正) を検知して削除・再 clone するよう修正。無限に parse 失敗を繰り返す経路を解消 (codex#132)。 - _cleanup_plugins_dir: .gitkeep でも .bak でもない想定外エントリが残る場合 は cleaned=True と報告せず False を返すよう修正 (gemini#176)。 - docs: devbase plugin migrate の CLI リファレンスを追加 (codex review body)。 テスト 4 件追加 (upstream 専用追加は差分なし / 同サイズ内容差 / partial clone 再 clone / cleanup の想定外エントリ保持)。全 252 件 pass。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): migration の保全判定・clone 健全性・registry 保存効率を改善 (PR2 round3) cross-review round 3 の指摘に対応: - _cleanup_plugins_dir の `.bak` 判定を `'.bak' in name` から `<name>.bak[-N]` 末尾一致 (_is_bak_name) に修正。my.bakery 等の誤マッチを排除 - migrate ループ内の per-plugin `registry.add` を loop 末尾の単一 `registry.add_many` に集約し plugins.yml の保存頻発を解消 (各 plugin の fs 移動と entry 構築は同一 try 内のため失敗時の retry 性は維持) - _ensure_repo_cloned で local_path 設定済みでも .git/registry.yml を 検証 (_clone_is_healthy)。壊れた既存 dir は除去して再 clone - _files_equal で S_IMODE を比較。旧コピーの実行ビット変更を差分扱いし保全 - _auto_migrate の preserved/skipped 再通知を loud な per-plugin WARNING から 簡潔な INFO ヒント 1 行に抑制 (詳細は devbase plugin migrate 側で出力) migrator テスト 12 件追加 (.bak 末尾判定 / clone 健全性 / 実行ビット差分 / batched save / broken local_path 再 clone / 警告抑制)。全 264 件 pass。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): migration の registry 重複排除 / exec ビット限定比較 / 健全 clone 保全 (PR2 round4) - registry.add_many: 引数内で名前が重複する場合 last-wins で一意化してから 反映し、plugins.yml に矛盾エントリが残らないようにした - _files_equal: 全権限ビット比較を exec ビット (+x) 限定に変更し、umask / group 設定差による誤った .bak 退避を防止 - _ensure_repo_cloned: local_path 記録済みだが unhealthy な既存 dir でも .git があれば未コミット/未 push のローカル変更を失わないよう rmtree せず PluginError を送出し、.git 欠落 (真に壊れている) 場合のみ再 clone する test: add_many 重複排除 / exec ビット限定 / .git 付き clone 保全の 6 件を追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): migration の derived clone 保全 / registry 先行永続化 / 特殊ファイル差分検知 (PR2 round5) cross-review round 5 で指摘された 4 件に対応: - derived clone 経路 (.git 保護): repo.local_path 未設定でも repos/<derived> に .git 付き既存 clone があり registry.yml だけ欠ける場合、無条件 rmtree で 未コミット/未 push のローカル変更を失っていた。_reclaim_or_protect_existing を新設し local_path 経路と同じく .git 有りは削除せず PluginError で復旧案内 する (freshly clone した分のみ破棄)。 - registry 先行永続化: 旧 plugins/ コピーの削除/.bak 退避 → add_many の順序を 逆転し、検証済み path rewrite を破壊的 fs 操作の前に 1 回保存する二相構成へ。 保存失敗時はコピー無傷で abort (次回 retry 可能)、phase2 の retire 失敗は registry が既に有効な repos/ clone を指すため lingering copy として _cleanup_plugins_dir が surface する (silent data loss を排除)。 - clone_dir がファイル/symlink で squat: clone_dir.is_dir() のみでは git_clone が失敗するため、ファイル/symlink は unlink して再 clone (git tree を持たない ため損失なし)。 - _entry_kind == 'other' (socket/pipe/device): 内容比較できず identical を 証明できないため diverged 扱いとし .bak 保全に倒す。 migrator テスト 5 件追加 (derived .git 保護 / clone_dir ファイル squat / registry 保存失敗でコピー無傷 / retire は保存後 / fifo は差分扱い)。 全 275 件 pass。ruff (E9,F63,F7,F82) / compileall pass。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin): derived clone 経路で健全 clone を reuse する (round 6) round 5 で derived 経路 (local_path 未設定) の `.git` 付き既存 dir を 無条件に保護 (PluginError) していたが過剰だった。`repos/<derived>` に .git + registry.yml が両方そろった健全 clone が残っている場合は PluginError で migration を skip せず、そのまま reuse して local_path を 永続化するよう修正。 - 健全 clone (.git + registry.yml) → reuse + local_path 永続化 - .git ありだが unhealthy (registry.yml 欠落) → 従来どおり保護 (PluginError) - .git 無し / file・symlink squat → reclaim して再 clone local_path 経路と derived 経路で挙動を揃え、fresh clone 後と healthy reuse 後の local_path 永続化を `_persist_repo_local_path` に共通化した。 健全 derived clone が reuse され migration が skip されないことを検証する テスト `test_derived_path_with_healthy_clone_is_reused` を追加。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(plugin): migration の clone 永続化を単一保存に集約 (round 7) _ensure_repo_cloned が clone のたびに add_repository で plugins.yml を 保存していたため、多数リポジトリ移行時に保存回数が repo 数に比例していた。 clone 済み repo 行を pending_repos に貯め、path rewrite と合わせて Phase2 (破壊的 cleanup) の直前に save_migration で 1 回だけ保存するよう変更。 二相アトミシティは維持: 旧 copy 削除より前に registry が必ず flush 済みで あること (clone を指す local_path / plugin path の両方) を不変条件として 保持。save_migration は repos + plugins を単一 load+save で upsert する。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(plugin): migration の registry.yml パースをリポジトリあたり 1 回に集約 (PR2 round8) gemini review (migrator.py:428 [minor/performance]) 対応。 _ensure_repo_cloned が clone/reuse 時に parse_registry_yml した RegistryInfo を戻り値で返すようにし、migrate ループ側で再パースしていた重複を解消した。 さらに _build_persisted_repo もパース済み reg_info を受け取る形に変更し、 fresh-clone 経路での二重パース (helper 内 + ループ) も排除。結果として registry.yml の読み込みはリポジトリあたり最大 1 回 (local_path fast path は lazy fallback) に削減。未使用になった _build_persisted_repo の registry 引数も 除去。挙動・テスト (plugin 203 / migrator 60) は不変。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(plugin): migration の repo 解決をループ前に 1 回へ集約 + migrate を prefix 解決対象に追加 (PR2 round9) - migrate ループ内の registry.get_repository_by_url (毎回 plugins.yml を再読込) を ループ前の URL→repo 辞書索引 1 回に置換し、O(N) ディスク I/O を O(1) に集約 - SUBCMD_MAP[('plugin','pl')] に 'migrate' を追加し、devbase plugin mi / pl mi の prefix 解決が効くよう修正 (従来は argparse エラー) - 再読込が plugin 数に比例しないことを検証する回帰テストを追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(plugin): migration の repo 解決が plugin 数に比例して plugins.yml を再読込しない回帰テストを追加 (PR2 round9) round9 で migrate ループ内の get_repository_by_url (毎回 plugins.yml 再読込) を ループ前の URL→repo 辞書索引 1 回に置換した変更の回帰防止。_load 呼び出し回数を 計数し plugin 数より少ないことを検証する。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(plugin): repo refresh の壊れた移行を成功扱いにせず移行時の registry.yml 重複パースを抑制 - refresh_repository: _update_repo_plugins が repo_errors を返した場合は warning で握りつぶさず RepositoryError として伝播。pull 後に削除された プラグインの移行失敗を成功扱いにしない (major) - migrate: 同一リポジトリの複数プラグイン移行時、local_path fast path で 返る registry.yml の遅延パースを URL 単位でキャッシュしリポジトリあたり 1 回に抑制 (minor / performance) - 両挙動の回帰テストを追加 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugin): update 時に registry.yml の指す plugin directory 存在を検証する _update_repo_plugins が registry.yml の path をそのまま plugins.yml に書き 込むため、path が実在しないディレクトリを指していても repos/.../missing で 成功扱いになっていた。_register_repo_plugin と同様に plugin_path.is_dir() を 検証し、存在しない場合は registry を更新せず errors に積むよう修正。 回帰テスト test_update_errors_when_registry_path_missing を追加。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(status): repos/ ベースプラグインの project_count を plugin.path 基準で算出する devbase status の _get_plugin_info が project_count を旧レイアウト plugins/<name>/projects から数えていたため、PLAN04 で repos/<repo>/<subdir> へ移行したプラグインの project_count が常に 0 表示されていた。 registry.devbase_root / plugin.path / projects を基準に数えるよう変更し、 plugin.py の表示ロジックと整合させた(repos/ と --link 両方を解決)。 回帰テスト test_status_project_count.py を追加(repos ベース / --link / projects 無しの 3 ケース)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugin): 名前指定インストールの @ref 拒否と status の空 path ガード - installer.py: `devbase plugin install myplugin@v1` の名前指定インストール分岐で source.ref を _install_from_repo() に渡し既定ブランチを黙ってインストールしていた 問題を修正。未登録/登録済みリポジトリと同様に @ref を PluginError で拒否する (major) - status.py: plugin.path が空文字列の場合に環境ルートの projects/ を誤参照する 可能性を防ぐため事前ガードを追加し 0 件扱いとする (minor / 堅牢性) - 回帰テスト追加: test_install_ref_rejected_for_name_only / test_project_count_zero_when_path_empty Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(plugin): migrate ループでクローン済み repo と registry.yml パースを同一リポジトリのプラグイン間で再利用 更新後の repo を repos_by_url に書き戻し、後続プラグインが local_path fast path を通るようにして clone-reuse 分岐の再入 (registry.yml 再パース + pending_repos 重複登録) を回避。clone/reuse パスの reg_info も reg_info_by_url にキャッシュし、同一リポジトリの複数プラグイン移行時の registry.yml パースを 1 回に抑制。 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 83942e4 commit 1ca1c85

16 files changed

Lines changed: 3841 additions & 458 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ __pycache__/
55
.gemini/
66
.docker-compose.scale.yml
77
plugins.yml
8+
repos/
89
plugins/*/
910
!plugins/.gitkeep
1011
projects/*

docs/user/cli-reference.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,24 @@ devbase plugin info <name>
404404
devbase plugin sync
405405
```
406406

407+
### `devbase plugin migrate`
408+
409+
旧形式 (`plugins/<name>` へのコピー) でインストールされたプラグインを、`repos/` 配下の永続クローンへ移行します。`install` / `update` 実行時にも自動で呼び出されるため、通常は手動実行不要です。
410+
411+
```
412+
devbase plugin migrate
413+
```
414+
415+
移行の挙動:
416+
417+
| 状況 | 動作 |
418+
|---|---|
419+
| コピーがクローンと一致 | 旧コピーを削除し `repos/` へ移行 (migrated) |
420+
| コピーにローカル変更あり | 旧コピーを `plugins/<name>.bak` として保全 (preserved、手動で reconcile) |
421+
| 移行できない (ソース未登録 等) | スキップしてエラーを表示 (skipped) |
422+
423+
`--link` でインストールしたプラグインは移行対象外です。
424+
407425
### `devbase plugin repo add`
408426

409427
プラグインリポジトリを登録します。

lib/devbase/cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
SUBCMD_MAP = {
3838
('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
3939
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'],
40-
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'],
40+
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'],
4141
('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'],
4242
}
4343

@@ -234,6 +234,9 @@ def _add_plugin_parser(subparsers):
234234

235235
pl_sub.add_parser('sync', help='Resync project symlinks')
236236

237+
pl_sub.add_parser('migrate',
238+
help='Migrate legacy plugins/ installs to repos/ clones')
239+
237240
# Plugin repo sub-subcommands
238241
pl_repo = pl_sub.add_parser('repo', help='Manage plugin repositories')
239242
pl_repo_sub = pl_repo.add_subparsers(dest='repo_command')
@@ -244,6 +247,8 @@ def _add_plugin_parser(subparsers):
244247

245248
r_remove = pl_repo_sub.add_parser('remove', help='Unregister a repository')
246249
r_remove.add_argument('name', help='Repository name')
250+
r_remove.add_argument('--force', action='store_true',
251+
help='Force removal even if repo has uncommitted/unpushed changes')
247252

248253
pl_repo_sub.add_parser('list', help='List repositories')
249254

lib/devbase/commands/plugin.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from devbase.plugin.updater import update_plugin
1010
from devbase.plugin.info import show_plugin_info, show_available_plugins
1111
from devbase.plugin.syncer import sync_projects
12+
from devbase.plugin.migrator import migrate
1213
from devbase.plugin.repo_manager import (
1314
add_repository,
1415
remove_repository,
@@ -34,6 +35,7 @@ def cmd_plugin(devbase_root: Path, args) -> int:
3435
'update': lambda: cmd_plugin_update(devbase_root, getattr(args, 'name', None)),
3536
'info': lambda: cmd_plugin_info(devbase_root, getattr(args, 'name', '')),
3637
'sync': lambda: cmd_sync(devbase_root),
38+
'migrate': lambda: cmd_plugin_migrate(devbase_root),
3739
'repo': lambda: cmd_repo(devbase_root, args),
3840
}
3941

@@ -138,6 +140,36 @@ def cmd_sync(devbase_root: Path) -> int:
138140
return 0
139141

140142

143+
def cmd_plugin_migrate(devbase_root: Path) -> int:
144+
"""Migrate legacy plugins/ copy installs to repos/ persistent clones"""
145+
registry = PluginRegistry(devbase_root)
146+
try:
147+
result = migrate(registry)
148+
except DevbaseError as e:
149+
logger.error("%s", e)
150+
return 1
151+
152+
if not (result.migrated or result.preserved or result.skipped):
153+
logger.info("No legacy plugins/ installs to migrate.")
154+
return 0
155+
156+
if result.migrated:
157+
logger.info("Migrated %d plugin(s) to repos/: %s",
158+
len(result.migrated), ", ".join(result.migrated))
159+
if result.preserved:
160+
logger.warning(
161+
"Preserved %d plugin(s) with local changes as plugins/<name>.bak "
162+
"(reconcile manually): %s",
163+
len(result.preserved), ", ".join(result.preserved))
164+
if result.skipped:
165+
logger.warning("Could not migrate %d plugin(s): %s",
166+
len(result.skipped), ", ".join(result.skipped))
167+
for err in result.errors:
168+
logger.warning(" %s", err)
169+
return 1
170+
return 0
171+
172+
141173
def cmd_repo(devbase_root: Path, args) -> int:
142174
"""Dispatch repo subcommands"""
143175
registry = PluginRegistry(devbase_root)
@@ -149,7 +181,8 @@ def cmd_repo(devbase_root: Path, args) -> int:
149181

150182
handlers = {
151183
'add': lambda: add_repository(registry, args.url, name=args.name),
152-
'remove': lambda: remove_repository(registry, args.name),
184+
'remove': lambda: remove_repository(registry, args.name,
185+
force=getattr(args, 'force', False)),
153186
'list': lambda: show_repositories(registry),
154187
'refresh': lambda: _repo_refresh(registry, args),
155188
}
@@ -181,9 +214,13 @@ def _repo_refresh(registry, args):
181214
errors = []
182215
for repo in repos:
183216
try:
184-
refresh_repository(registry, repo.name)
217+
refresh_repository(registry, repo.name, sync=False)
185218
except DevbaseError as e:
186219
logger.error("%s", e)
187220
errors.append(str(e))
221+
222+
# Sync once after all repos are refreshed (instead of per-repo)
223+
sync_projects(registry)
224+
188225
if errors:
189226
raise DevbaseError(f"{len(errors)} repository refresh(es) failed")

lib/devbase/commands/status.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,17 @@ def _get_plugin_info(registry: PluginRegistry) -> list[dict]:
100100
"""インストール済みプラグインとプロジェクト数を取得する"""
101101
results = []
102102
plugins = registry.list_installed()
103-
plugins_dir = registry.get_plugins_dir()
104103

105104
for plugin in plugins:
106-
plugin_projects_dir = plugins_dir / plugin.name / "projects"
105+
# plugin.path は devbase_root からの相対パス。
106+
# repos/ ベース (repos/<repo>/<subdir>) と --link ベース
107+
# (plugins/<name>) の両方を同じロジックで解決する。
108+
# path が空の場合 (旧/破損エントリ) は devbase_root/projects を
109+
# 誤参照してしまうため、先にガードして 0 件扱いとする。
110+
if not plugin.path:
111+
results.append({"name": plugin.name, "project_count": 0})
112+
continue
113+
plugin_projects_dir = registry.devbase_root / plugin.path / "projects"
107114
if plugin_projects_dir.is_dir():
108115
project_count = sum(
109116
1 for p in plugin_projects_dir.iterdir() if p.is_dir()

0 commit comments

Comments
 (0)