Refactor self-update system to use native Git operations instead of file sync

- Replace file-based sync_tree with native Git checkout, fetch, and stash operations - Add Git stash-based rollback protection for tracked and untracked changes - Remove protected path detection logic (now handled by Git ignore rules) - Add stash management helpers: create_rollback_stash, apply_stash, drop_stash - Add repository state restoration via restore_git_state for branch/detached HEAD - Implement clean_repo_wor

frdel committed Mar 24, 2026 at 18:44 UTC eafe51a688c02aea277a4f244a1fe61965f0b614
3 files changed +278 -145
docker/run/fs/exe/self_update_manager.py
+271 -138
@@ -120,7 +120,9 @@ def normalize_describe_to_version(describe: str) -> str:
120 def get_repo_version_info(repo_dir: Path) -> dict[str, str]:
121 describe = git_output(repo_dir, "describe", "--tags", "--always")
122 commit = git_output(repo_dir, "rev-parse", "HEAD")
123 + branch = git_optional_output(repo_dir, "branch", "--show-current")
124 return {
125 + "branch": branch,
126 "describe": describe,
127 "short_tag": normalize_describe_to_version(describe),
128 "commit": commit,
@@ -128,50 +130,17 @@ def get_repo_version_info(repo_dir: Path) -> dict[str, str]:
130 }
131
132
131 -def normalize_rel_path(value: str | Path) -> str:
132 - normalized = str(value).replace("\\", "/").strip("/")
133 - if normalized in {"", "."}:
134 - return ""
135 - while normalized.startswith("./"):
136 - normalized = normalized[2:]
137 - while "//" in normalized:
138 - normalized = normalized.replace("//", "/")
139 - return normalized.rstrip("/")
140 -
141 -
142 -def is_protected_path(relative_path: str, protected_paths: set[str]) -> bool:
143 - normalized = normalize_rel_path(relative_path)
144 - if not normalized:
145 - return False
146 - return any(
147 - normalized == protected or normalized.startswith(f"{protected}/")
148 - for protected in protected_paths
149 - )
150 -
151 -
152 -def list_protected_paths(repo_dir: Path) -> set[str]:
153 - output = git_output(
154 - repo_dir,
155 - "ls-files",
156 - "--others",
157 - "-i",
158 - "--exclude-standard",
159 - "--directory",
133 +def git_optional_output(repo_dir: Path, *args: str) -> str:
134 + completed = subprocess.run(
135 + ["git", "-C", str(repo_dir), *args],
136 + check=False,
137 + text=True,
138 + capture_output=True,
139 + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
140 )
161 - protected: set[str] = set()
162 - for raw_line in output.splitlines():
163 - normalized = normalize_rel_path(raw_line)
164 - if not normalized:
165 - continue
166 - current = Path(normalized)
167 - while True:
168 - candidate = normalize_rel_path(current.as_posix())
169 - if candidate:
170 - protected.add(candidate)
171 - if str(current.parent) in {"", "."}:
172 - break
173 - current = current.parent
174 - return protected
141 + if completed.returncode != 0:
142 + return ""
143 + return completed.stdout.strip()
144
145
146 def remove_path(path: Path) -> None:
@@ -182,53 +151,11 @@ def remove_path(path: Path) -> None:
151 shutil.rmtree(path)
152
153
185 -def sync_tree(source_dir: Path, target_dir: Path, *, protected_paths: set[str]) -> None:
186 - target_dir.mkdir(parents=True, exist_ok=True)
187 - protected = {normalize_rel_path(path) for path in protected_paths if path}
188 -
189 - def _sync(src: Path, dst: Path, relative_root: str) -> None:
190 - source_entries = {item.name: item for item in src.iterdir()} if src.exists() else {}
191 - target_entries = {item.name: item for item in dst.iterdir()} if dst.exists() else {}
192 -
193 - for name, src_entry in source_entries.items():
194 - relative_path = normalize_rel_path(Path(relative_root, name).as_posix())
195 - if is_protected_path(relative_path, protected):
196 - continue
197 -
198 - dst_entry = dst / name
199 - if src_entry.is_symlink():
200 - link_target = os.readlink(src_entry)
201 - if dst_entry.is_symlink() and os.readlink(dst_entry) == link_target:
202 - continue
203 - remove_path(dst_entry)
204 - os.symlink(link_target, dst_entry)
205 - continue
206 -
207 - if src_entry.is_dir():
208 - if dst_entry.exists() and not dst_entry.is_dir():
209 - remove_path(dst_entry)
210 - dst_entry.mkdir(parents=True, exist_ok=True)
211 - _sync(src_entry, dst_entry, relative_path)
212 - try:
213 - shutil.copystat(src_entry, dst_entry, follow_symlinks=False)
214 - except OSError:
215 - pass
216 - continue
217 -
218 - if dst_entry.exists() and dst_entry.is_dir():
219 - remove_path(dst_entry)
220 - dst_entry.parent.mkdir(parents=True, exist_ok=True)
221 - shutil.copy2(src_entry, dst_entry, follow_symlinks=False)
222 -
223 - for name, dst_entry in target_entries.items():
224 - relative_path = normalize_rel_path(Path(relative_root, name).as_posix())
225 - if name in source_entries:
226 - continue
227 - if is_protected_path(relative_path, protected):
228 - continue
229 - remove_path(dst_entry)
230 -
231 - _sync(source_dir, target_dir, "")
154 +def get_repo_relative_path(repo_dir: Path, path: Path) -> str | None:
155 + try:
156 + return path.resolve().relative_to(repo_dir.resolve()).as_posix()
157 + except ValueError:
158 + return None
159
160
161 def sanitize_filename(name: str, default_name: str) -> str:
@@ -318,7 +245,13 @@ def create_usr_backup(
245 temporary_backup.unlink(missing_ok=True)
246
247
321 -def run_command(command: list[str], *, cwd: Path | None, logger: AttemptLogger) -> None:
248 +def run_command(
249 + command: list[str],
250 + *,
251 + cwd: Path | None,
252 + logger: AttemptLogger,
253 + error_message: str | None = None,
254 +) -> subprocess.CompletedProcess[str]:
255 logger.log(f"$ {' '.join(command)}")
256 completed = subprocess.run(
257 command,
@@ -333,53 +266,202 @@ def run_command(command: list[str], *, cwd: Path | None, logger: AttemptLogger)
266 logger.log_block("stderr", completed.stderr)
267 if completed.returncode != 0:
268 raise RuntimeError(
336 - f"Command failed with exit code {completed.returncode}: {' '.join(command)}"
269 + error_message
270 + or f"Command failed with exit code {completed.returncode}: {' '.join(command)}"
271 + )
272 + return completed
273 +
274 +
275 +def has_local_rollback_changes(repo_dir: Path) -> bool:
276 + status = git_output(repo_dir, "status", "--porcelain=v1", "--untracked-files=all")
277 + return bool(status.strip())
278 +
279 +
280 +def get_top_stash_ref(repo_dir: Path) -> str:
281 + return git_optional_output(repo_dir, "stash", "list", "--format=%gd", "-n", "1")
282 +
283 +
284 +def create_rollback_stash(repo_dir: Path, logger: AttemptLogger) -> str | None:
285 + if not has_local_rollback_changes(repo_dir):
286 + logger.log("No tracked or non-ignored untracked changes need rollback protection.")
287 + return None
288 +
289 + previous_top = get_top_stash_ref(repo_dir)
290 + message = f"a0-self-update rollback snapshot {now_iso()}"
291 + run_command(
292 + [
293 + "git",
294 + "-C",
295 + str(repo_dir),
296 + "stash",
297 + "push",
298 + "--include-untracked",
299 + "--message",
300 + message,
301 + ],
302 + cwd=None,
303 + logger=logger,
304 + error_message="Failed to save local tracked/untracked changes before updating.",
305 + )
306 + stash_ref = get_top_stash_ref(repo_dir)
307 + if not stash_ref or stash_ref == previous_top:
308 + raise RuntimeError("Failed to create the pre-update rollback stash.")
309 + logger.log(
310 + f"Saved local tracked/untracked changes into {stash_ref}. "
311 + "Ignored files stay in place and are not stashed."
312 + )
313 + return stash_ref
314 +
315 +
316 +def drop_stash(repo_dir: Path, stash_ref: str, logger: AttemptLogger) -> None:
317 + if not stash_ref:
318 + return
319 + run_command(
320 + ["git", "-C", str(repo_dir), "stash", "drop", stash_ref],
321 + cwd=None,
322 + logger=logger,
323 + error_message=f"Failed to drop temporary rollback stash {stash_ref}.",
324 + )
325 +
326 +
327 +def apply_stash(repo_dir: Path, stash_ref: str, logger: AttemptLogger) -> None:
328 + if not stash_ref:
329 + return
330 + run_command(
331 + ["git", "-C", str(repo_dir), "stash", "apply", "--index", stash_ref],
332 + cwd=None,
333 + logger=logger,
334 + error_message=(
335 + f"Failed to restore local tracked/untracked changes from {stash_ref}. "
336 + "The stash entry has been kept so it can be recovered manually."
337 + ),
338 + )
339 + try:
340 + drop_stash(repo_dir, stash_ref, logger)
341 + except Exception as exc:
342 + logger.log(
343 + f"Rollback stash {stash_ref} was restored but could not be dropped automatically: {exc}"
344 )
345
346
340 -def clone_release(branch: str, tag: str, destination: Path, logger: AttemptLogger) -> None:
347 +def clean_repo_worktree(
348 + repo_dir: Path,
349 + logger: AttemptLogger,
350 + *,
351 + exclude_paths: list[Path] | None = None,
352 +) -> None:
353 + command = ["git", "-C", str(repo_dir), "clean", "-ffd"]
354 + for path in exclude_paths or []:
355 + relative_path = get_repo_relative_path(repo_dir, path)
356 + if relative_path:
357 + command.extend(["-e", relative_path])
358 + run_command(
359 + command,
360 + cwd=None,
361 + logger=logger,
362 + error_message="Failed to remove leftover non-ignored files after checkout.",
363 + )
364 +
365 +
366 +def fetch_release_refs(repo_dir: Path, branch: str, tag: str, logger: AttemptLogger) -> None:
367 + remote_branch_ref = f"refs/remotes/a0-self-update/{branch}"
368 logger.log(f"Fetching branch {branch} and tag {tag} from {OFFICIAL_REPO_URL}")
369 run_command(
370 [
371 "git",
345 - "clone",
346 - "--depth",
347 - "1",
348 - "--branch",
349 - branch,
350 - "--single-branch",
372 + "-C",
373 + str(repo_dir),
374 + "fetch",
375 + "--force",
376 OFFICIAL_REPO_URL,
352 - str(destination),
377 + f"+refs/heads/{branch}:{remote_branch_ref}",
378 + f"+refs/tags/{tag}:refs/tags/{tag}",
379 ],
380 cwd=None,
381 logger=logger,
382 + error_message=f"Failed to fetch branch {branch} and tag {tag} from the official repository.",
383 )
384 run_command(
385 [
386 "git",
387 "-C",
361 - str(destination),
362 - "fetch",
363 - "--depth",
364 - "1",
365 - "origin",
366 - f"refs/tags/{tag}:refs/tags/{tag}",
388 + str(repo_dir),
389 + "merge-base",
390 + "--is-ancestor",
391 + f"refs/tags/{tag}",
392 + remote_branch_ref,
393 ],
394 cwd=None,
395 logger=logger,
396 + error_message=f"Requested tag {tag} is not reachable from official branch {branch}.",
397 )
398 +
399 +
400 +def checkout_target_release(
401 + repo_dir: Path,
402 + branch: str,
403 + tag: str,
404 + logger: AttemptLogger,
405 + *,
406 + exclude_paths: list[Path] | None = None,
407 +) -> None:
408 + logger.log(f"Checking out branch {branch} at tag {tag}")
409 run_command(
410 [
411 "git",
412 "-C",
375 - str(destination),
413 + str(repo_dir),
414 "checkout",
377 - "--detach",
415 + "-B",
416 + branch,
417 f"refs/tags/{tag}",
418 ],
419 cwd=None,
420 logger=logger,
421 + error_message=f"Failed to check out requested tag {tag} on branch {branch}.",
422 )
423 + clean_repo_worktree(repo_dir, logger, exclude_paths=exclude_paths)
424 +
425 +
426 +def restore_git_state(
427 + repo_dir: Path,
428 + *,
429 + head: str,
430 + branch: str,
431 + logger: AttemptLogger,
432 + exclude_paths: list[Path] | None = None,
433 +) -> None:
434 + logger.log(f"Restoring repository state to commit {head}")
435 + if branch:
436 + run_command(
437 + [
438 + "git",
439 + "-C",
440 + str(repo_dir),
441 + "checkout",
442 + "-B",
443 + branch,
444 + head,
445 + ],
446 + cwd=None,
447 + logger=logger,
448 + error_message=f"Failed to restore branch {branch} to commit {head}.",
449 + )
450 + else:
451 + run_command(
452 + [
453 + "git",
454 + "-C",
455 + str(repo_dir),
456 + "checkout",
457 + "--detach",
458 + head,
459 + ],
460 + cwd=None,
461 + logger=logger,
462 + error_message=f"Failed to restore detached HEAD at commit {head}.",
463 + )
464 + clean_repo_worktree(repo_dir, logger, exclude_paths=exclude_paths)
465
466
467 def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen[bytes]:
@@ -514,14 +596,12 @@ def execute_pending_update(
596 ) -> subprocess.Popen[bytes]:
597 source_info = get_repo_version_info(REPO_DIR)
598 started_at = now_iso()
517 - protected_paths = list_protected_paths(REPO_DIR)
518 - temp_root = Path(tempfile.mkdtemp(prefix="a0-self-update-"))
519 - staging_repo = temp_root / "release"
520 - snapshot_dir = temp_root / "snapshot"
599 backup_zip_path = ""
600 + stash_ref: str | None = None
601 repository_changed = False
602 branch = str(request_data.get("branch", "")).strip()
603 tag = str(request_data.get("tag", "")).strip()
604 + backup_exclusions: list[Path] = []
605
606 try:
607 if not branch:
@@ -529,30 +609,38 @@ def execute_pending_update(
609 if not tag:
610 raise ValueError("Update file is missing the tag field.")
611
612 + stash_ref = create_rollback_stash(REPO_DIR, logger)
613 +
614 if bool(request_data.get("backup_usr", True)):
533 - backup_zip_path = str(
534 - create_usr_backup(
535 - repo_dir=REPO_DIR,
536 - backup_path=str(request_data.get("backup_path", "/a0/tmp/self-update-backups")),
537 - backup_name=str(request_data.get("backup_name", "agent-zero-usr-backup.zip")),
538 - conflict_policy=str(request_data.get("backup_conflict_policy", "rename")),
539 - logger=logger,
540 - )
615 + backup_destination = create_usr_backup(
616 + repo_dir=REPO_DIR,
617 + backup_path=str(request_data.get("backup_path", "/a0/tmp/self-update-backups")),
618 + backup_name=str(request_data.get("backup_name", "agent-zero-usr-backup.zip")),
619 + conflict_policy=str(request_data.get("backup_conflict_policy", "rename")),
620 + logger=logger,
621 )
622 + backup_zip_path = str(backup_destination)
623 + backup_exclusions.append(backup_destination)
624
543 - logger.log("Creating rollback snapshot")
544 - sync_tree(REPO_DIR, snapshot_dir, protected_paths=protected_paths)
545 -
546 - clone_release(branch, tag, staging_repo, logger)
625 + fetch_release_refs(REPO_DIR, branch, tag, logger)
626
548 - logger.log("Applying release into /a0 while preserving ignored paths")
549 - sync_tree(staging_repo, REPO_DIR, protected_paths=protected_paths)
627 repository_changed = True
628 + logger.log(
629 + "Applying the requested release with native Git checkout. "
630 + "Ignored files remain untouched; tracked files and non-ignored leftovers are replaced."
631 + )
632 + checkout_target_release(
633 + REPO_DIR,
634 + branch,
635 + tag,
636 + logger,
637 + exclude_paths=backup_exclusions,
638 + )
639
640 current_info = get_repo_version_info(REPO_DIR)
641 if current_info["short_tag"] != tag:
642 raise RuntimeError(
555 - "Release sync completed but the repository version does not match the requested tag. "
643 + "Git checkout completed but the repository version does not match the requested tag. "
644 f"Expected {tag}, got {current_info['short_tag']}."
645 )
646
@@ -576,11 +664,30 @@ def execute_pending_update(
664 backup_zip_path=backup_zip_path,
665 rollback_applied=False,
666 )
667 + if stash_ref:
668 + logger.log(
669 + f"Update succeeded, dropping temporary rollback stash {stash_ref}. "
670 + "Tracked and non-ignored local changes were not reapplied."
671 + )
672 + try:
673 + drop_stash(REPO_DIR, stash_ref, logger)
674 + except Exception as exc:
675 + logger.log(
676 + f"Temporary rollback stash {stash_ref} could not be dropped automatically: {exc}"
677 + )
678 return updated_process
679
680 logger.log(f"Updated UI failed health check, rolling back: {details}")
681 terminate_process(updated_process)
583 - sync_tree(snapshot_dir, REPO_DIR, protected_paths=protected_paths)
682 + restore_git_state(
683 + REPO_DIR,
684 + head=source_info["commit"],
685 + branch=source_info.get("branch", ""),
686 + logger=logger,
687 + exclude_paths=backup_exclusions,
688 + )
689 + apply_stash(REPO_DIR, stash_ref or "", logger)
690 + stash_ref = None
691
692 rollback_process = launch_ui_process(REPO_DIR, logger)
693 rollback_healthy, rollback_details = wait_for_health(
@@ -625,25 +732,45 @@ def execute_pending_update(
732 )
733 raise RuntimeError(str(rollback_details))
734 except Exception as exc:
628 - if repository_changed and snapshot_dir.exists():
629 - logger.log(f"Restoring rollback snapshot after error: {exc}")
630 - sync_tree(snapshot_dir, REPO_DIR, protected_paths=protected_paths)
735 + restore_error = ""
736 + if repository_changed or stash_ref:
737 + logger.log(f"Restoring pre-update repository state after error: {exc}")
738 + try:
739 + restore_git_state(
740 + REPO_DIR,
741 + head=source_info["commit"],
742 + branch=source_info.get("branch", ""),
743 + logger=logger,
744 + exclude_paths=backup_exclusions,
745 + )
746 + if stash_ref:
747 + apply_stash(REPO_DIR, stash_ref, logger)
748 + stash_ref = None
749 + except Exception as restore_exc:
750 + restore_error = str(restore_exc)
751 + logger.log(f"Automatic restore failed: {restore_exc}")
752 +
753 + failure_message = str(exc)
754 + if restore_error:
755 + failure_message = f"{failure_message} | Restore error: {restore_error}"
756 +
757 + failure_status = "failed"
758 + if repository_changed:
759 + failure_status = "rollback_failed" if restore_error else "rolled_back"
760
761 record_result(
633 - status="failed" if not repository_changed else "rolled_back",
634 - message=str(exc),
762 + status=failure_status,
763 + message=failure_message,
764 request_data=request_data,
765 source_info=source_info,
766 current_version=source_info["short_tag"],
767 started_at=started_at,
768 backup_zip_path=backup_zip_path,
769 rollback_applied=repository_changed,
641 - error=str(exc),
770 + error=failure_message,
771 )
643 - logger.log(f"Update flow failed: {exc}")
772 + logger.log(f"Update flow failed: {failure_message}")
773 return launch_ui_process(REPO_DIR, logger)
645 - finally:
646 - shutil.rmtree(temp_root, ignore_errors=True)
774
775
776 def load_request_file() -> tuple[dict[str, Any] | None, str]:
@@ -669,8 +796,14 @@ def docker_run_ui() -> int:
796
797 try:
798 current = get_repo_version_info(REPO_DIR)
799 + requested_branch = str(request_data.get("branch", "")).strip()
800 requested_tag = str(request_data.get("tag", "")).strip()
673 - if requested_tag and current["short_tag"] == requested_tag:
801 + current_branch = current.get("branch", "").strip()
802 + if (
803 + requested_tag
804 + and current["short_tag"] == requested_tag
805 + and (not requested_branch or current_branch == requested_branch)
806 + ):
807 logger.log(
808 "Requested tag already matches the installed version, skipping file replacement."
809 )
webui/components/welcome/welcome-screen.html
+4 -4
@@ -37,6 +37,10 @@
37 <span class="material-symbols-outlined welcome-action-icon">settings</span>
38 <h3 class="welcome-action-title">Settings</h3>
39 </div>
40 + <div class="welcome-action-card" @click="$store.welcomeStore.executeAction('plugins')">
41 + <span class="material-symbols-outlined welcome-action-icon">extension</span>
42 + <h3 class="welcome-action-title">Plugins</h3>
43 + </div>
44
45 <div class="welcome-action-card" @click="$store.welcomeStore.executeAction('files')">
46 <span class="material-symbols-outlined welcome-action-icon">folder_open</span>
@@ -46,10 +50,6 @@
50 <span class="material-symbols-outlined welcome-action-icon">language</span>
51 <h3 class="welcome-action-title">Visit Website</h3>
52 </div>
49 - <div class="welcome-action-card" @click="$store.welcomeStore.executeAction('github')">
50 - <span class="material-symbols-outlined welcome-action-icon">code</span>
51 - <h3 class="welcome-action-title">Visit GitHub</h3>
52 - </div>
53 <x-extension id="welcome-actions-end"></x-extension>
54 </div>
55
webui/components/welcome/welcome-store.js
+3 -3
@@ -202,6 +202,9 @@ const model = {
202 case "settings":
203 window.openModal("settings/settings.html");
204 break;
205 + case "plugins":
206 + window.openModal("components/plugins/list/plugin-list.html");
207 + break;
208 case "projects":
209 projectsStore.openProjectsModal();
210 break;
@@ -214,9 +217,6 @@ const model = {
217 case "website":
218 window.open("https://agent-zero.ai", "_blank");
219 break;
217 - case "github":
218 - window.open("https://github.com/agent0ai/agent-zero", "_blank");
219 - break;
220 }
221 },
222 };