Skip to content

Commit 431fce8

Browse files
takemi-ohamaclaude
andcommitted
fix(plugin): copy_plugin で rmtree+copytree を rsync 同期に置換
ユーザが `projects/<name>` シンボリックリンク経由で `plugins/<plugin>/projects/<name>/` 内部に `cd` した状態で `devbase plugin update` を実行すると、`copy_plugin()` の `shutil.rmtree(dest); shutil.copytree(plugin_path, dest)` がプラグインディレクトリの inode ごと置換し、ユーザのシェル CWD が "宙ぶらりんの旧 inode" を掴んだままになって ファイルが消えたように見える問題があった。 `_sync_dir(src, dst)` を新設し、rmtree せずに既存ディレクトリの inode を保ったまま ファイル差分を反映する (rsync 風)。差分は: - src に存在しない dst エントリは削除 (orphan) - src の symlink は再生成 (target が変わっていれば追従) - src の dir は再帰的に同期 (両方に存在する dir は inode 維持) - src の file は shutil.copy2 で上書き これにより、ユーザの CWD が更新対象プラグイン配下にあっても、シェルは引き続き 同じ inode を参照し続け、`ll` で正しく中身を表示できる。 linked プラグインで dest が symlink の場合は従来どおり unlink + copytree。 新規インストールも従来どおり copytree。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aca7927 commit 431fce8

1 file changed

Lines changed: 59 additions & 6 deletions

File tree

lib/devbase/plugin/installer.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Plugin installer - handles install/uninstall operations"""
22

3+
import os
34
import shutil
45
import subprocess
56
import tempfile
@@ -259,26 +260,78 @@ def _install_from_repo(
259260
raise PluginError("No plugin name specified")
260261

261262

263+
def _replace_entry(path: Path) -> None:
264+
"""Remove ``path`` (file, symlink, or directory) so it can be replaced."""
265+
if path.is_symlink() or not path.is_dir():
266+
path.unlink()
267+
else:
268+
shutil.rmtree(path)
269+
270+
271+
def _sync_dir(src: Path, dst: Path) -> None:
272+
"""rsync-like merge: make ``dst``'s contents match ``src``'s.
273+
274+
Preserves the inode of ``dst`` and of any subdirectories that exist in
275+
both ``src`` and ``dst``. Compared to ``rmtree(dst) + copytree(src, dst)``,
276+
a process whose CWD is inside ``dst`` (or a subdir present in both) keeps
277+
a valid CWD across this operation — important when the user is working
278+
inside a ``projects/<name>`` symlink that resolves into the plugin tree.
279+
"""
280+
dst.mkdir(parents=True, exist_ok=True)
281+
282+
src_entries = {e.name: e for e in src.iterdir()}
283+
dst_entries = {e.name: e for e in dst.iterdir()}
284+
285+
for name, dst_entry in dst_entries.items():
286+
if name not in src_entries:
287+
_replace_entry(dst_entry)
288+
289+
for name, src_entry in src_entries.items():
290+
dst_entry = dst / name
291+
if src_entry.is_symlink():
292+
link_target = os.readlink(src_entry)
293+
if dst_entry.is_symlink() or dst_entry.exists():
294+
_replace_entry(dst_entry)
295+
os.symlink(link_target, dst_entry)
296+
elif src_entry.is_dir():
297+
if dst_entry.exists() and not dst_entry.is_dir():
298+
_replace_entry(dst_entry)
299+
_sync_dir(src_entry, dst_entry)
300+
else:
301+
if dst_entry.is_symlink() or (dst_entry.exists() and dst_entry.is_dir()):
302+
_replace_entry(dst_entry)
303+
shutil.copy2(src_entry, dst_entry)
304+
305+
262306
def copy_plugin(
263307
registry: PluginRegistry,
264308
name: str,
265309
plugin_path: Path,
266310
source_display: str,
267311
plugins_dir: Path,
268312
) -> None:
269-
"""Copy a plugin from cloned repo to plugins/.
313+
"""Install or update a plugin from a cloned repo into ``plugins/``.
314+
315+
For updates, the existing plugin directory inode is preserved by
316+
syncing contents in place (rsync-like) instead of rmtree+copytree.
317+
This avoids invalidating any shell or editor whose CWD is inside the
318+
plugin (typically via a ``projects/<name>`` symlink).
270319
271320
Raises PluginError on failure.
272321
"""
273322
if not plugin_path.is_dir():
274323
raise PluginError(f"Plugin directory not found: {plugin_path}")
275324

276325
dest = plugins_dir / name
277-
if dest.exists():
278-
logger.warning("Removing existing plugin '%s'", name)
279-
shutil.rmtree(dest)
280-
281-
shutil.copytree(plugin_path, dest)
326+
if dest.is_symlink():
327+
logger.warning("Removing existing plugin '%s' (symlink)", name)
328+
dest.unlink()
329+
shutil.copytree(plugin_path, dest)
330+
elif dest.exists():
331+
logger.info("Updating existing plugin '%s'", name)
332+
_sync_dir(plugin_path, dest)
333+
else:
334+
shutil.copytree(plugin_path, dest)
282335

283336
info = load_plugin_info(dest)
284337
version = info.version if info else '0.1.0'

0 commit comments

Comments
 (0)