main
py 594 lines 17.7 KB
Raw
1 from git import Git, Repo
2 from giturlparse import parse
3 from datetime import datetime, timezone
4 from dataclasses import dataclass
5 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
13
14
15 def strip_auth_from_url(url: str) -> str:
16 """Remove any authentication info from URL."""
17 if not url:
18 return url
19 parsed = urlparse(url)
20 if not parsed.hostname:
21 return url
22 clean_netloc = parsed.hostname
23 if parsed.port:
24 clean_netloc += f":{parsed.port}"
25 return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', ''))
26
27
28 def extract_author_repo(url: str) -> tuple[str, str]:
29 parsed = parse(strip_auth_from_url(url.strip()))
30 author = (parsed.owner or "").strip()
31 repo = (parsed.repo or parsed.name or "").strip()
32 if not parsed.valid or not author or not repo:
33 raise ValueError("Could not derive plugin name from URL")
34 if repo.endswith(".git"):
35 repo = repo[:-4]
36 if not author or not repo:
37 raise ValueError("Could not derive plugin name from URL")
38 return author, repo
39
40
41 @dataclass
42 class GitHeadInfo:
43 hash: str
44 short_hash: str
45 message: str
46 author: str
47 committed_at: str
48 authored_at: str
49
50
51 @dataclass
52 class GitReleaseInfo:
53 tag: str
54 short_tag: str
55 version: str
56 released_at: str
57
58
59 @dataclass
60 class GitRemoteReleaseInfo:
61 tag: str
62 commit_hash: str
63 short_commit_hash: str
64 released_at: str
65
66
67 @dataclass
68 class GitRemoteReleasesResult:
69 is_git_repo: bool
70 is_remote: bool
71 author: str
72 repo: str
73 releases: list[GitRemoteReleaseInfo]
74 error: str = ""
75
76
77 @dataclass
78 class GitRemoteCommitsInfo:
79 is_git_repo: bool
80 is_remote: bool
81 path: str
82 branch: str
83 remote_branch: str
84 commits_since_local: int
85 last_remote_commit_at: str
86 error: str = ""
87
88
89 @dataclass
90 class GitRepoReleaseInfo:
91 is_git_repo: bool
92 is_remote: bool
93 path: str
94 author: str
95 repo: str
96 branch: str
97 head: GitHeadInfo | None
98 release: GitReleaseInfo | None
99 error: str = ""
100
101
102 def _format_git_timestamp(timestamp: int) -> str:
103 return datetime.fromtimestamp(
104 timestamp,
105 tz=timezone.utc,
106 ).strftime('%Y-%m-%d %H:%M:%S')
107
108
109 def _split_describe_version(describe: str) -> tuple[str, int]:
110 normalized = describe.strip()
111 match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
112 if not match:
113 return normalized, 0
114 return match.group(1), int(match.group(2))
115
116
117 def _format_release_version(
118 branch: str,
119 short_tag: str,
120 commits_since_tag: int,
121 commit_hash: str,
122 ) -> str:
123 version_prefix = branch[0].upper() if branch else "D"
124 version_core = short_tag or commit_hash[:7]
125
126 if (
127 short_tag
128 and commits_since_tag > 0
129 and branch.strip().lower() != "main"
130 ):
131 version_core = f"{short_tag}+{commits_since_tag}"
132
133 return f"{version_prefix} {version_core}"
134
135
136 def get_remote_releases(author: str, repo: str) -> GitRemoteReleasesResult:
137 try:
138 author = author.strip()
139 repo = repo.strip()
140
141 if not author or not repo:
142 return GitRemoteReleasesResult(
143 is_remote=False,
144 is_git_repo=False,
145 author=author,
146 repo=repo,
147 releases=[],
148 error="Both author and repo are required.",
149 )
150
151 remote_url = f"https://github.com/{author}/{repo}.git"
152
153 env = os.environ.copy()
154 env['GIT_TERMINAL_PROMPT'] = '0'
155
156 try:
157 output = Git().ls_remote('--tags', '--refs', '--', remote_url, with_extended_output=False, env=env)
158 except Exception as e:
159 return GitRemoteReleasesResult(
160 is_remote=True,
161 is_git_repo=False,
162 author=author,
163 repo=repo,
164 releases=[],
165 error=f"Git remote query failed: {str(e)}",
166 )
167
168 releases: list[GitRemoteReleaseInfo] = []
169
170 for line in output.splitlines():
171 line = line.strip()
172 if not line:
173 continue
174
175 parts = line.split()
176 if len(parts) != 2:
177 continue
178
179 commit_hash, ref_name = parts
180 prefix = 'refs/tags/'
181 if not ref_name.startswith(prefix):
182 continue
183
184 tag_name = ref_name[len(prefix):]
185 releases.append(GitRemoteReleaseInfo(
186 tag=tag_name,
187 commit_hash=commit_hash,
188 short_commit_hash=commit_hash[:7],
189 released_at="",
190 ))
191
192 releases.sort(key=lambda release: release.tag, reverse=True)
193
194 return GitRemoteReleasesResult(
195 is_git_repo=True,
196 is_remote=True,
197 author=author,
198 repo=repo,
199 releases=releases,
200 )
201 except Exception as e:
202 return GitRemoteReleasesResult(
203 is_git_repo=False,
204 is_remote=False,
205 author=author,
206 repo=repo,
207 releases=[],
208 error=str(e),
209 )
210
211
212 def get_remote_commits_since_local(repo_path: str) -> GitRemoteCommitsInfo:
213 try:
214 repo = Repo(repo_path)
215 if repo.bare:
216 return GitRemoteCommitsInfo(
217 is_git_repo=False,
218 is_remote=False,
219 path=repo_path,
220 branch="",
221 remote_branch="",
222 commits_since_local=0,
223 last_remote_commit_at="",
224 error=f"Repository at {repo_path} is bare and cannot be used.",
225 )
226
227 if repo.head.is_detached:
228 return GitRemoteCommitsInfo(
229 is_git_repo=True,
230 is_remote=False,
231 path=repo_path,
232 branch="",
233 remote_branch="",
234 commits_since_local=0,
235 last_remote_commit_at="",
236 error="Repository HEAD is detached.",
237 )
238
239 branch = repo.active_branch.name
240
241 tracking_branch = repo.active_branch.tracking_branch()
242 if tracking_branch is None:
243 return GitRemoteCommitsInfo(
244 is_git_repo=True,
245 is_remote=False,
246 path=repo_path,
247 branch=branch,
248 remote_branch="",
249 commits_since_local=0,
250 last_remote_commit_at="",
251 error="Current branch has no tracking remote branch.",
252 )
253
254 remote_name = tracking_branch.remote_name
255 remote = repo.remotes[remote_name]
256 env = os.environ.copy()
257 env['GIT_TERMINAL_PROMPT'] = '0'
258 with repo.git.custom_environment(**env):
259 remote.fetch(repo.active_branch.name)
260
261 remote_commit = tracking_branch.commit
262 commits = list(repo.iter_commits(f"{repo.head.commit.hexsha}..{tracking_branch.path}"))
263
264 return GitRemoteCommitsInfo(
265 is_git_repo=True,
266 is_remote=True,
267 path=repo_path,
268 branch=branch,
269 remote_branch=tracking_branch.path,
270 commits_since_local=len(commits),
271 last_remote_commit_at=_format_git_timestamp(remote_commit.committed_date) if commits else "",
272 )
273 except Exception as e:
274 return GitRemoteCommitsInfo(
275 is_git_repo=False,
276 is_remote=False,
277 path=repo_path,
278 branch="",
279 remote_branch="",
280 commits_since_local=0,
281 last_remote_commit_at="",
282 error=str(e),
283 )
284
285
286 def get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo:
287 try:
288 repo = Repo(repo_path)
289 if repo.bare:
290 return GitRepoReleaseInfo(
291 is_git_repo=False,
292 is_remote=False,
293 path=repo_path,
294 author="",
295 repo="",
296 branch="",
297 head=None,
298 release=None,
299 error=f"Repository at {repo_path} is bare and cannot be used.",
300 )
301
302 commit = repo.head.commit
303 author = ""
304 repo_name = ""
305 is_remote = False
306
307 try:
308 if repo.remotes:
309 author, repo_name = extract_author_repo(repo.remotes.origin.url)
310 is_remote = bool(author and repo_name)
311 except Exception:
312 author = ""
313 repo_name = ""
314 is_remote = False
315
316 branch = ""
317 try:
318 branch = repo.active_branch.name if repo.head.is_detached is False else ""
319 except Exception:
320 branch = ""
321
322 tag = ""
323 short_tag = ""
324 release_time = ""
325 commits_since_tag = 0
326 try:
327 tag = repo.git.describe(tags=True, always=True)
328 short_tag, commits_since_tag = _split_describe_version(tag)
329
330 tag_ref = next((t for t in repo.tags if t.name == short_tag), None)
331 if tag_ref:
332 release_commit = tag_ref.commit
333 release_time = _format_git_timestamp(release_commit.committed_date)
334 except Exception:
335 tag = ""
336 short_tag = ""
337 release_time = ""
338 commits_since_tag = 0
339
340 version = _format_release_version(
341 branch,
342 short_tag,
343 commits_since_tag,
344 commit.hexsha,
345 )
346
347 return GitRepoReleaseInfo(
348 is_git_repo=True,
349 is_remote=is_remote,
350 path=repo_path,
351 author=author,
352 repo=repo_name,
353 branch=branch,
354 head=GitHeadInfo(
355 hash=commit.hexsha,
356 short_hash=commit.hexsha[:7],
357 message=str(commit.message).split("\n")[0][:200],
358 author=str(commit.author),
359 committed_at=_format_git_timestamp(commit.committed_date),
360 authored_at=_format_git_timestamp(commit.authored_date),
361 ),
362 release=GitReleaseInfo(
363 tag=tag,
364 short_tag=short_tag,
365 version=version,
366 released_at=release_time,
367 ),
368 )
369 except Exception as e:
370 return GitRepoReleaseInfo(
371 is_git_repo=False,
372 is_remote=False,
373 path=repo_path,
374 author="",
375 repo="",
376 branch="",
377 head=None,
378 release=None,
379 error=str(e),
380 )
381
382
383 def get_git_info():
384 # Get the current working directory (assuming the repo is in the same folder as the script)
385 repo_path = files.get_base_dir()
386
387 state = get_repo_release_info(repo_path)
388 if not state.is_git_repo:
389 raise ValueError(state.error or f"Repository at {repo_path} is not usable.")
390
391 return {
392 "branch": state.branch,
393 "commit_hash": state.head.hash if state.head else "",
394 "commit_time": state.head.committed_at if state.head else "",
395 "tag": state.release.tag if state.release else "",
396 "short_tag": state.release.short_tag if state.release else "",
397 "version": state.release.version if state.release else "",
398 }
399
400 def get_version():
401 try:
402 git_info = get_git_info()
403 return str(git_info.get("short_tag", "")).strip() or "unknown"
404 except Exception:
405 return "unknown"
406
407
408 def is_official_agent_zero_repo() -> bool:
409 """Return True when origin points to agent0ai/agent-zero."""
410 try:
411 repo = Repo(files.get_base_dir())
412 if not repo.remotes:
413 return False
414
415 remote_url = strip_auth_from_url(repo.remotes.origin.url).lower().rstrip("/")
416
417 if remote_url.endswith(".git"):
418 remote_url = remote_url[:-4]
419
420 allowed_repos = [
421 "agent0ai/agent-zero",
422 "frdel/agent-zero",
423 ]
424 return any(
425 remote_url.endswith(f"github.com/{repo_name}")
426 or remote_url.endswith(f"github.com:{repo_name}")
427 for repo_name in allowed_repos
428 )
429 except Exception:
430 return False
431
432
433 def clone_repo(url: str, dest: str, token: str | None = None):
434 """Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config)."""
435 cmd = ['git']
436
437 if token:
438 # GitHub Git HTTP requires Basic Auth, not Bearer
439 auth_string = f"x-access-token:{token}"
440 auth_base64 = base64.b64encode(auth_string.encode()).decode()
441 cmd.extend(['-c', f'http.extraHeader=Authorization: Basic {auth_base64}'])
442
443 cmd.extend(['clone', '--progress', '--', url, dest])
444
445 env = os.environ.copy()
446 env['GIT_TERMINAL_PROMPT'] = '0'
447
448 result = subprocess.run(cmd, capture_output=True, text=True, env=env)
449
450 if result.returncode != 0:
451 error_msg = result.stderr.strip() or result.stdout.strip() or 'Unknown error'
452 raise Exception(f"Git clone failed: {error_msg}")
453
454 return Repo(dest)
455
456
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.")
490
491 if repo.head.is_detached:
492 raise ValueError("Repository HEAD is detached.")
493
494 branch = repo.active_branch.name
495 tracking_branch = repo.active_branch.tracking_branch()
496 if tracking_branch is None:
497 raise ValueError("Current branch has no tracking remote branch.")
498
499 env = os.environ.copy()
500 env['GIT_TERMINAL_PROMPT'] = '0'
501
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
530
531 # Files to ignore when checking dirty status (A0 project metadata)
532 A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"}
533
534
535 def get_repo_status(repo_path: str) -> dict:
536 """Get Git repository status, ignoring A0 project metadata files."""
537 try:
538 repo = Repo(repo_path)
539 if repo.bare:
540 return {"is_git_repo": False, "error": "Repository is bare"}
541
542 # Remote URL (always strip auth info for security)
543 remote_url = ""
544 try:
545 if repo.remotes:
546 remote_url = strip_auth_from_url(repo.remotes.origin.url)
547 except Exception:
548 pass
549
550 # Current branch
551 try:
552 current_branch = repo.active_branch.name if not repo.head.is_detached else f"HEAD@{repo.head.commit.hexsha[:7]}"
553 except Exception:
554 current_branch = "unknown"
555
556 # Check dirty status, excluding A0 metadata
557 def is_a0_file(path: str) -> bool:
558 return path.startswith(".a0proj") or path == ".a0proj"
559
560 # Filter out A0 files from diff and untracked
561 changed_files = [d.a_path for d in repo.index.diff(None)] + [d.a_path for d in repo.index.diff("HEAD")]
562 untracked = repo.untracked_files
563
564 real_changes = [f for f in changed_files if not is_a0_file(f)]
565 real_untracked = [f for f in untracked if not is_a0_file(f)]
566
567 is_dirty = len(real_changes) > 0 or len(real_untracked) > 0
568 untracked_count = len(real_untracked)
569
570 last_commit = None
571 try:
572 commit = repo.head.commit
573 last_commit = {
574 "hash": commit.hexsha[:7],
575 "message": str(commit.message).split("\n")[0][:80],
576 "author": str(commit.author),
577 "date": datetime.fromtimestamp(
578 commit.committed_date,
579 tz=Localization.get().get_tzinfo(),
580 ).strftime('%Y-%m-%d %H:%M %Z')
581 }
582 except Exception:
583 pass
584
585 return {
586 "is_git_repo": True,
587 "remote_url": remote_url,
588 "current_branch": current_branch,
589 "is_dirty": is_dirty,
590 "untracked_count": untracked_count,
591 "last_commit": last_commit
592 }
593 except Exception as e:
594 return {"is_git_repo": False, "error": str(e)}