Skip to content

Commit 5a6158a

Browse files
takemi-ohamaclaude
andauthored
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>
1 parent 79b661f commit 5a6158a

8 files changed

Lines changed: 1783 additions & 8 deletions

File tree

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: 4 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')

lib/devbase/commands/plugin.py

Lines changed: 32 additions & 0 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)

lib/devbase/plugin/installer.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,32 @@ def resolve_repo_url(repo: str) -> str:
8181
return f"https://github.com/{repo}.git"
8282

8383

84+
def _auto_migrate(registry: PluginRegistry) -> None:
85+
"""Migrate any legacy plugins/ copy installs to repos/ before proceeding.
86+
87+
Triggered on the first install/update after upgrading to repos/-based
88+
plugin management so users do not have to run `devbase plugin migrate`
89+
manually. No-op when nothing legacy remains.
90+
"""
91+
from .migrator import migrate, needs_migration
92+
if not needs_migration(registry):
93+
return
94+
logger.info("Legacy plugins/ installs detected — migrating to repos/...")
95+
result = migrate(registry)
96+
if result.migrated:
97+
logger.info(" Migrated: %s", ", ".join(result.migrated))
98+
# preserved/skipped recur on every install/update until the user
99+
# reconciles, so avoid re-emitting a loud per-plugin WARNING each time:
100+
# surface a single concise hint pointing at the explicit command, which
101+
# prints the full per-plugin detail when run.
102+
if result.preserved or result.skipped:
103+
pending = len(result.preserved) + len(result.skipped)
104+
logger.info(
105+
" %d plugin(s) still need attention — run 'devbase plugin migrate' "
106+
"for details.", pending,
107+
)
108+
109+
84110
def install_plugin(
85111
registry: PluginRegistry,
86112
source_str: str,
@@ -91,6 +117,8 @@ def install_plugin(
91117
92118
Raises PluginError on failure.
93119
"""
120+
_auto_migrate(registry)
121+
94122
source = PluginSource.parse(source_str, link=link)
95123
plugins_dir = registry.get_plugins_dir()
96124

0 commit comments

Comments
 (0)