fix(plugin-hub): preserve dirty edits during updates

Temporarily stash tracked plugin edits while updating. Restore the original checkout and edits if the update cannot reapply them, and surface the conflict beside the Update button. Add isolated Git regression coverage for merge, no-op, and conflict cases.

spinnakergit committed Apr 18, 2026 at 14:37 UTC d1e1c6494e1cde128fe5f95a99063dd4b6d0aca4
7 files changed +271 -5
helpers/git.py
+56 -3
@@ -6,6 +6,7 @@ import os
6 import subprocess
7 import base64
8 import re
9 +import time
10 from urllib.parse import urlparse, urlunparse
11 from helpers import files
12 from helpers.localization import Localization
@@ -453,7 +454,36 @@ def clone_repo(url: str, dest: str, token: str | None = None):
454 return Repo(dest)
455
456
456 -def update_repo(repo_path: str) -> Repo:
457 +class DirtyTreeConflictError(Exception):
458 + """Raised when a dirty plugin cannot be updated without overwriting local edits."""
459 +
460 + def __init__(self, conflicting_files: list[str]):
461 + super().__init__(
462 + "Local changes conflict with the update. "
463 + "Your plugin was restored without applying the update."
464 + )
465 + self.conflicting_files = conflicting_files
466 +
467 +
468 +def _list_dirty_tracked_files(repo: "Repo") -> list[str]:
469 + """Return tracked files with uncommitted modifications, excluding A0 metadata."""
470 + def _is_a0_file(path: str) -> bool:
471 + return path.startswith(".a0proj") or path == ".a0proj"
472 +
473 + changed = {d.a_path for d in repo.index.diff(None)}
474 + changed.update(d.a_path for d in repo.index.diff("HEAD"))
475 + return sorted(p for p in changed if p and not _is_a0_file(p))
476 +
477 +
478 +def update_repo(repo_path: str, auto_stash: bool = True) -> Repo:
479 + """Fast-forward the repo to its tracking branch.
480 +
481 + When `auto_stash` is True (default) and the working tree has uncommitted
482 + changes to tracked files, those changes are stashed before the pull and
483 + reapplied afterwards. If they conflict with the update, the repo and local
484 + edits are restored to their original state before `DirtyTreeConflictError`
485 + is raised.
486 + """
487 repo = Repo(repo_path)
488 if repo.bare:
489 raise ValueError(f"Repository at {repo_path} is bare and cannot be updated.")
@@ -469,8 +499,31 @@ def update_repo(repo_path: str) -> Repo:
499 env = os.environ.copy()
500 env['GIT_TERMINAL_PROMPT'] = '0'
501
472 - with repo.git.custom_environment(**env):
473 - repo.remotes[tracking_branch.remote_name].pull(branch)
502 + dirty_files = _list_dirty_tracked_files(repo) if auto_stash else []
503 + original_head = repo.head.commit.hexsha
504 + if dirty_files:
505 + stash_msg = f"a0-auto-stash-{int(time.time())}"
506 + repo.git.stash("push", "-m", stash_msg, "--", *dirty_files)
507 +
508 + def restore_original_state():
509 + repo.git.reset("--hard", original_head)
510 + if dirty_files:
511 + repo.git.stash("pop")
512 +
513 + try:
514 + with repo.git.custom_environment(**env):
515 + repo.remotes[tracking_branch.remote_name].pull(branch)
516 + except Exception:
517 + if dirty_files:
518 + restore_original_state()
519 + raise
520 +
521 + if dirty_files:
522 + try:
523 + repo.git.stash("pop")
524 + except Exception:
525 + restore_original_state()
526 + raise DirtyTreeConflictError(dirty_files)
527
528 return repo
529
helpers/git.py.dox.md
+2 -1
@@ -30,7 +30,8 @@
30 - `get_version()`
31 - `is_official_agent_zero_repo() -> bool`: Return True when origin points to agent0ai/agent-zero.
32 - `clone_repo(url: str, dest: str, token: str | None=...)`: Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config).
33 -- `update_repo(repo_path: str) -> Repo`
33 +- `DirtyTreeConflictError`: Reports a plugin update that was rolled back because its local edits conflict with upstream.
34 +- `update_repo(repo_path: str, auto_stash: bool = True) -> Repo`: Temporarily stashes tracked plugin edits for an update, then restores them; on conflict it restores the original checkout and edits before raising `DirtyTreeConflictError`.
35 - `get_repo_status(repo_path: str) -> dict`: Get Git repository status, ignoring A0 project metadata files.
36 - Notable constants/configuration names: `A0_IGNORE_PATTERNS`.
37
plugins/_plugin_installer/helpers/install.py
+10
@@ -250,6 +250,16 @@ def update_from_git(plugin_name: str) -> dict:
250 try:
251 repo = git.update_repo(plugin_dir)
252 meta = plugins.get_plugin_meta(plugin_name)
253 + except git.DirtyTreeConflictError as e:
254 + print_style.PrintStyle.error(f"Failed to update plugin: {e}")
255 + return {
256 + "ok": False,
257 + "success": False,
258 + "error": str(e),
259 + "error_kind": "dirty_tree_conflict",
260 + "plugin_name": plugin_name,
261 + "conflicting_files": e.conflicting_files,
262 + }
263 except Exception as e:
264 print_style.PrintStyle.error(f"Failed to update plugin: {e}")
265 raise
plugins/_plugin_installer/webui/install-detail.html
+16
@@ -193,6 +193,22 @@
193 </template>
194 </div>
195
196 + <template x-if="$store.pluginInstallStore.detailError">
197 + <div class="pi-detail-error">
198 + <div class="pi-detail-error-header">
199 + <x-icon name="error"></x-icon>
200 + <span x-text="$store.pluginInstallStore.detailError.message"></span>
201 + </div>
202 + <template x-if="$store.pluginInstallStore.detailError.conflicting_files?.length">
203 + <ul class="pi-detail-error-files">
204 + <template x-for="f in $store.pluginInstallStore.detailError.conflicting_files" :key="f">
205 + <li x-text="f"></li>
206 + </template>
207 + </ul>
208 + </template>
209 + </div>
210 + </template>
211 +
212 <div class="pi-readme-section" x-show="$store.pluginInstallStore.readmeContent || $store.pluginInstallStore.readmeLoading">
213 <div class="pi-readme-header">Readme</div>
214 <div x-show="$store.pluginInstallStore.readmeLoading" class="pi-loading-text">
plugins/_plugin_installer/webui/install-shared.css
+32
@@ -40,3 +40,35 @@
40 margin-top: 0.25rem;
41 font-family: var(--font-family-code);
42 }
43 +
44 +.pi-detail-error {
45 + margin-top: 0.75rem;
46 + padding: 0.75rem;
47 + background: rgba(239, 68, 68, 0.1);
48 + border: 1px solid rgba(239, 68, 68, 0.35);
49 + border-radius: 4px;
50 + color: var(--color-text-primary);
51 +}
52 +
53 +.pi-detail-error-header {
54 + display: flex;
55 + align-items: flex-start;
56 + gap: 0.5rem;
57 + font-size: 0.9rem;
58 + line-height: 1.4;
59 +}
60 +
61 +.pi-detail-error-header x-icon {
62 + color: #ef4444;
63 + font-size: 1.25rem;
64 + flex-shrink: 0;
65 +}
66 +
67 +.pi-detail-error-files {
68 + margin: 0.5rem 0 0 1.75rem;
69 + padding: 0;
70 + list-style: disc;
71 + font-family: var(--font-family-code);
72 + font-size: 0.8rem;
73 + color: var(--color-text-secondary);
74 +}
plugins/_plugin_installer/webui/pluginInstallStore.js
+15 -1
@@ -61,6 +61,11 @@ const model = {
61
62 detailThumbnailUrl: null,
63
64 + // Inline error for the detail modal (e.g. update failure), structured so
65 + // the UI can render it next to the action button instead of relying on a
66 + // toast the user can miss.
67 + detailError: null,
68 +
69 // Tab state
70 activeTab: "store",
71
@@ -548,6 +553,7 @@ const model = {
553 this.result = null;
554 this.installedPluginInfo = null;
555 this.readmeContent = null;
556 + this.detailError = null;
557 this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin);
558 if (this.selectedPlugin.installed) {
559 this.fetchInstalledPluginInfo(this.selectedPlugin.name);
@@ -831,6 +837,8 @@ const model = {
837 });
838 if (!confirmed) return;
839
840 + this.detailError = null;
841 +
842 try {
843 this.loading = true;
844 this.loadingMessage = "Updating";
@@ -841,7 +849,13 @@ const model = {
849 });
850
851 if (!(data?.ok && data?.success)) {
844 - void toastFrontendError(data?.error || "Update failed", "Plugin Installer");
852 + const message = data?.error || "Update failed";
853 + this.detailError = {
854 + kind: data?.error_kind || "update_failed",
855 + message,
856 + conflicting_files: Array.isArray(data?.conflicting_files) ? data.conflicting_files : [],
857 + };
858 + void toastFrontendError(message, "Plugin Installer");
859 return;
860 }
861
tests/test_plugin_git_update.py new
+140
@@ -0,0 +1,140 @@
1 +import subprocess
2 +import sys
3 +from pathlib import Path
4 +
5 +import pytest
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +from helpers import git as git_helpers
12 +from plugins._plugin_installer.helpers import install
13 +
14 +
15 +def run_git(repo: Path, *args: str) -> str:
16 + return subprocess.run(
17 + ["git", "-C", str(repo), *args],
18 + check=True,
19 + capture_output=True,
20 + text=True,
21 + ).stdout.strip()
22 +
23 +
24 +def git_status(repo: Path) -> str:
25 + return subprocess.run(
26 + ["git", "-C", str(repo), "status", "--porcelain"],
27 + check=True,
28 + capture_output=True,
29 + text=True,
30 + ).stdout.rstrip()
31 +
32 +
33 +def make_plugin_repos(tmp_path: Path) -> tuple[Path, Path, Path]:
34 + remote = tmp_path / "remote.git"
35 + source = tmp_path / "source"
36 + installed = tmp_path / "installed"
37 + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True)
38 + subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
39 + run_git(source, "config", "user.email", "tests@example.com")
40 + run_git(source, "config", "user.name", "Tests")
41 + (source / "plugin.py").write_text("value = 'old'\n", encoding="utf-8")
42 + (source / "README.md").write_text("old\n", encoding="utf-8")
43 + run_git(source, "add", ".")
44 + run_git(source, "commit", "-m", "initial")
45 + run_git(source, "branch", "-M", "main")
46 + run_git(source, "remote", "add", "origin", str(remote))
47 + run_git(source, "push", "-u", "origin", "main")
48 + subprocess.run(
49 + ["git", "-C", str(remote), "symbolic-ref", "HEAD", "refs/heads/main"],
50 + check=True,
51 + capture_output=True,
52 + )
53 + subprocess.run(["git", "clone", str(remote), str(installed)], check=True, capture_output=True)
54 + run_git(installed, "config", "user.email", "tests@example.com")
55 + run_git(installed, "config", "user.name", "Tests")
56 + return remote, source, installed
57 +
58 +
59 +def push_source_change(source: Path, path: str, content: str) -> None:
60 + (source / path).write_text(content, encoding="utf-8")
61 + run_git(source, "add", path)
62 + run_git(source, "commit", "-m", f"update {path}")
63 + run_git(source, "push")
64 +
65 +
66 +def test_update_repo_preserves_non_conflicting_tracked_and_untracked_files(tmp_path: Path):
67 + _, source, installed = make_plugin_repos(tmp_path)
68 + original_head = run_git(installed, "rev-parse", "HEAD")
69 + (installed / "README.md").write_text("local edit\n", encoding="utf-8")
70 + (installed / ".toggle-1").write_text("enabled\n", encoding="utf-8")
71 + push_source_change(source, "plugin.py", "value = 'upstream'\n")
72 +
73 + git_helpers.update_repo(str(installed))
74 +
75 + assert run_git(installed, "rev-parse", "HEAD") != original_head
76 + assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'upstream'\n"
77 + assert (installed / "README.md").read_text(encoding="utf-8") == "local edit\n"
78 + assert (installed / ".toggle-1").read_text(encoding="utf-8") == "enabled\n"
79 + assert run_git(installed, "stash", "list") == ""
80 +
81 +
82 +def test_update_repo_drops_local_edit_that_matches_the_new_upstream_version(tmp_path: Path):
83 + _, source, installed = make_plugin_repos(tmp_path)
84 + (installed / "plugin.py").write_text("value = 'upstream'\n", encoding="utf-8")
85 + push_source_change(source, "plugin.py", "value = 'upstream'\n")
86 +
87 + git_helpers.update_repo(str(installed))
88 +
89 + assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'upstream'\n"
90 + assert git_status(installed) == ""
91 + assert run_git(installed, "stash", "list") == ""
92 +
93 +
94 +def test_update_repo_restores_original_plugin_and_local_edit_after_conflict(tmp_path: Path):
95 + _, source, installed = make_plugin_repos(tmp_path)
96 + original_head = run_git(installed, "rev-parse", "HEAD")
97 + (installed / "plugin.py").write_text("value = 'local'\n", encoding="utf-8")
98 + push_source_change(source, "plugin.py", "value = 'upstream'\n")
99 +
100 + with pytest.raises(git_helpers.DirtyTreeConflictError) as exc_info:
101 + git_helpers.update_repo(str(installed))
102 +
103 + assert exc_info.value.conflicting_files == ["plugin.py"]
104 + assert run_git(installed, "rev-parse", "HEAD") == original_head
105 + assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'local'\n"
106 + assert git_status(installed) == " M plugin.py"
107 + assert run_git(installed, "stash", "list") == ""
108 +
109 +
110 +def test_plugin_hub_renders_dirty_update_errors_inline():
111 + store = (PROJECT_ROOT / "plugins/_plugin_installer/webui/pluginInstallStore.js").read_text(encoding="utf-8")
112 + detail = (PROJECT_ROOT / "plugins/_plugin_installer/webui/install-detail.html").read_text(encoding="utf-8")
113 +
114 + assert "detailError" in store
115 + assert "error_kind" in store
116 + assert "pi-detail-error" in detail
117 + assert "conflicting_files" in detail
118 +
119 +
120 +def test_plugin_update_returns_structured_dirty_tree_error(monkeypatch, tmp_path: Path):
121 + plugin_dir = tmp_path / "plugin"
122 + plugin_dir.mkdir()
123 + monkeypatch.setattr(install.plugins, "find_plugin_dir", lambda _name: str(plugin_dir))
124 + monkeypatch.setattr(install.files, "get_abs_path", lambda *_parts: str(tmp_path))
125 + monkeypatch.setattr(install.files, "is_in_dir", lambda *_paths: True)
126 + monkeypatch.setattr(install, "run_pre_update_hook", lambda _name: None)
127 +
128 + def raise_conflict(_path: str):
129 + raise git_helpers.DirtyTreeConflictError(["plugin.py"])
130 +
131 + monkeypatch.setattr(install.git, "update_repo", raise_conflict)
132 +
133 + assert install.update_from_git("demo") == {
134 + "ok": False,
135 + "success": False,
136 + "error": "Local changes conflict with the update. Your plugin was restored without applying the update.",
137 + "error_kind": "dirty_tree_conflict",
138 + "plugin_name": "demo",
139 + "conflicting_files": ["plugin.py"],
140 + }