|
1 | 1 | """Plugin installer - handles install/uninstall operations""" |
2 | 2 |
|
| 3 | +import os |
3 | 4 | import shutil |
4 | 5 | import subprocess |
5 | 6 | import tempfile |
@@ -259,26 +260,78 @@ def _install_from_repo( |
259 | 260 | raise PluginError("No plugin name specified") |
260 | 261 |
|
261 | 262 |
|
| 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 | + |
262 | 306 | def copy_plugin( |
263 | 307 | registry: PluginRegistry, |
264 | 308 | name: str, |
265 | 309 | plugin_path: Path, |
266 | 310 | source_display: str, |
267 | 311 | plugins_dir: Path, |
268 | 312 | ) -> 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). |
270 | 319 |
|
271 | 320 | Raises PluginError on failure. |
272 | 321 | """ |
273 | 322 | if not plugin_path.is_dir(): |
274 | 323 | raise PluginError(f"Plugin directory not found: {plugin_path}") |
275 | 324 |
|
276 | 325 | 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) |
282 | 335 |
|
283 | 336 | info = load_plugin_info(dest) |
284 | 337 | version = info.version if info else '0.1.0' |
|
0 commit comments