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
};