commit 3c382f4e9eb7491be502741306bc1a47ebe14add Author: 24_OskinEA <24_OskinEA@iux.local> Date: Fri May 22 01:10:05 2026 +0300 Final Universal Git Analyzer v11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c14970a --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Universal Git Analyzer V7 + +Финальная GUI-версия анализатора публичных Git-репозиториев. + +## Запуск + +```bash +sudo apt install python3-tk git pulseaudio-utils -y +python3 main.py +``` + +Или: + +```bash +./run.sh +``` + +## Что есть + +- заставка с логотипом; +- один звук запуска `assets/startup.wav`; +- вход и регистрация; +- сохранение ссылок на репозитории; +- анализ любых публичных Git-репозиториев через `git clone`; +- поддержка веток; +- поддержка последних 10 коммитов; +- переключение версий стрелками; +- подсчёт строк по языкам Python/C/C++/C#/Java/JavaScript/TypeScript/Bash/Shell/Go/PHP/PowerShell/Batchfile/Dockerfile; +- правое поле информации только для чтения. + + +V11: добавлены языки C++, Batchfile, PowerShell, Go, PHP, Dockerfile, C#, Java, JavaScript, TypeScript. diff --git a/README_START.md b/README_START.md new file mode 100644 index 0000000..47fd8b1 --- /dev/null +++ b/README_START.md @@ -0,0 +1,44 @@ +# Universal Git Analyzer V10 — запуск кликом + +## Ubuntu / Linux +Двойной клик по `run_linux.sh` или запуск: + +```bash +./run_linux.sh +``` + +Если не запускается: + +```bash +sudo apt install python3-tk git pulseaudio-utils -y +python3 main.py +``` + +## Windows +Двойной клик по файлу: + +```text +run_windows.bat +``` + +Нужно, чтобы на Windows были установлены: +- Python 3 +- Git for Windows + +## Настоящий `.exe` для Windows +На Windows запусти двойным кликом: + +```text +build_windows_exe.bat +``` + +После сборки файл будет здесь: + +```text +dist\UniversalGitAnalyzer.exe +``` + +Этот `.exe` можно кинуть на рабочий стол и запускать кликом. + + +V11: добавлены языки C++, Batchfile, PowerShell, Go, PHP, Dockerfile, C#, Java, JavaScript, TypeScript. diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..43f7c36 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/startup.wav b/assets/startup.wav new file mode 100644 index 0000000..265748c Binary files /dev/null and b/assets/startup.wav differ diff --git a/build_windows_exe.bat b/build_windows_exe.bat new file mode 100644 index 0000000..1e5e7a3 --- /dev/null +++ b/build_windows_exe.bat @@ -0,0 +1,8 @@ +@echo off +cd /d "%~dp0" +py -3 -m pip install --upgrade pyinstaller +py -3 -m PyInstaller --noconfirm --onefile --windowed --name UniversalGitAnalyzer --add-data "assets;assets" main.py +echo. +echo Готовый exe будет тут: dist\UniversalGitAnalyzer.exe +echo Важно: Git for Windows всё равно должен быть установлен на компьютере. +pause diff --git a/git_analyzer.py b/git_analyzer.py new file mode 100644 index 0000000..635e9c0 --- /dev/null +++ b/git_analyzer.py @@ -0,0 +1,444 @@ +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from datetime import datetime + +LANG_EXTENSIONS = { + "Python": [".py"], + "C": [".c", ".h"], + "C++": [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"], + "C#": [".cs"], + "Java": [".java"], + "JavaScript": [".js", ".jsx", ".mjs", ".cjs"], + "TypeScript": [".ts", ".tsx"], + "Bash / Shell": [".sh", ".bash", ".zsh", ".ksh"], + "Batchfile": [".bat", ".cmd"], + "PowerShell": [".ps1", ".psm1", ".psd1"], + "Go": [".go"], + "PHP": [".php", ".phtml", ".php3", ".php4", ".php5", ".phps"], +} + +SPECIAL_FILENAMES = { + "dockerfile": "Dockerfile", +} + +IGNORE_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"} + + +def run_git(args, cwd=None): + result = subprocess.run( + ["git"] + args, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "Ошибка git") + return result.stdout.strip() + + +def run_git_allow_fail(args, cwd=None): + return subprocess.run( + ["git"] + args, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + ) + + +def normalize_link(link): + return link.strip() + + +def repo_name_from_url(url): + name = url.rstrip("/").split("/")[-1] + if name.endswith(".git"): + name = name[:-4] + return name or "repository" + + +def count_code_lines(text): + return sum(1 for line in text.splitlines() if line.strip()) + + +def format_russian_datetime(value): + if not value or value == "—": + return "—" + value = value.strip() + for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(value, fmt) + return dt.strftime("%d.%m.%Y %H:%M") + except ValueError: + continue + parts = value.split() + if len(parts) >= 2: + date_part = parts[0] + time_part = parts[1][:5] + date_bits = date_part.split("-") + if len(date_bits) == 3: + return f"{date_bits[2]}.{date_bits[1]}.{date_bits[0]} {time_part}" + return value + + +def detect_language(path): + lower = path.lower() + filename = Path(path).name.lower() + + if filename in SPECIAL_FILENAMES: + return SPECIAL_FILENAMES[filename] + + for language, exts in LANG_EXTENSIONS.items(): + if any(lower.endswith(ext) for ext in exts): + return language + return None + + +def safe_branch_name(remote_branch): + if "/" in remote_branch: + return remote_branch.split("/", 1)[1] + return remote_branch + + +def clone_repo(link): + temp_dir = tempfile.mkdtemp(prefix="uga_repo_") + repo_dir = os.path.join(temp_dir, "repo") + try: + run_git(["clone", "--quiet", normalize_link(link), repo_dir]) + run_git(["fetch", "--all", "--prune", "--quiet"], cwd=repo_dir) + return temp_dir, repo_dir + except Exception: + shutil.rmtree(temp_dir, ignore_errors=True) + raise + + +def get_default_branch(repo_dir): + try: + ref = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_dir) + return ref.replace("refs/remotes/origin/", "") + except Exception: + try: + return run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir) + except Exception: + return "main" + + +def get_all_remote_branches(repo_dir): + output = run_git(["branch", "-r"], cwd=repo_dir) + branches = [] + for line in output.splitlines(): + b = line.strip() + if not b or "->" in b: + continue + if b.startswith("origin/"): + branches.append(b) + branches = list(dict.fromkeys(branches)) + + def key(b): + name = safe_branch_name(b) + if name in ("main", "master"): + return (0, name) + return (1, name) + + return sorted(branches, key=key) + + +def get_commits(repo_dir, limit=10): + fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%at%x1f%s" + out = run_git(["log", "--all", f"--max-count={limit}", "--date=iso", f"--pretty=format:{fmt}"], cwd=repo_dir) + raw_lines = out.splitlines() + commits = [] + for idx, line in enumerate(raw_lines, start=1): + parts = line.split("\x1f") + if len(parts) >= 6: + commits.append({ + "number": len(raw_lines) - idx + 1, + "hash": parts[0], + "short": parts[1], + "author": parts[2], + "date": format_russian_datetime(parts[3]), + "timestamp": int(parts[4]) if parts[4].isdigit() else 0, + "message": parts[5], + }) + commits.reverse() + return commits + + +def get_all_authors(repo_dir): + try: + out = run_git(["log", "--all", "--format=%an"], cwd=repo_dir) + return sorted(set(x.strip() for x in out.splitlines() if x.strip())) + except Exception: + return [] + + +def first_commit_info(repo_dir): + fmt = "%H%x1f%an%x1f%ad%x1f%at%x1f%s" + out = run_git(["log", "--all", "--reverse", "--date=iso", f"--pretty=format:{fmt}"], cwd=repo_dir) + line = out.splitlines()[0] if out else "" + parts = line.split("\x1f") + if len(parts) >= 5: + return {"hash": parts[0], "author": parts[1], "date": format_russian_datetime(parts[2]), "timestamp": int(parts[3]) if parts[3].isdigit() else 0, "message": parts[4]} + return {"hash": "", "author": "—", "date": "—", "timestamp": 0, "message": "—"} + + +def last_commit_info(repo_dir): + fmt = "%H%x1f%an%x1f%ad%x1f%at%x1f%s" + out = run_git(["log", "--all", "--max-count=1", "--date=iso", f"--pretty=format:{fmt}"], cwd=repo_dir) + parts = out.split("\x1f") + if len(parts) >= 5: + return {"hash": parts[0], "author": parts[1], "date": format_russian_datetime(parts[2]), "timestamp": int(parts[3]) if parts[3].isdigit() else 0, "message": parts[4]} + return {"hash": "", "author": "—", "date": "—", "timestamp": 0, "message": "—"} + + +def files_changed_in_commit(repo_dir, commit_hash): + if not commit_hash: + return set() + for args in (["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", commit_hash], + ["diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash]): + try: + out = run_git(args, cwd=repo_dir) + return set(x.strip() for x in out.splitlines() if x.strip()) + except Exception: + continue + return set() + + +def list_files_at_ref(repo_dir, ref): + try: + out = run_git(["ls-tree", "-r", "--name-only", ref], cwd=repo_dir) + except Exception: + return [] + return [x.strip() for x in out.splitlines() if x.strip()] + + +def read_file_at_ref(repo_dir, ref, path): + try: + return run_git(["show", f"{ref}:{path}"], cwd=repo_dir) + except Exception: + return "" + + +def get_branch_commits(repo_dir, branch_ref): + """Коммиты конкретной ветки от старых к новым, с timestamp. + Это нужно, чтобы на старой версии не показывать ветку, которой тогда ещё не было. + """ + fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%at%x1f%s" + try: + out = run_git(["log", branch_ref, "--reverse", "--date=iso", f"--pretty=format:{fmt}"], cwd=repo_dir) + except Exception: + return [] + result = [] + for line in out.splitlines(): + parts = line.split("\x1f") + if len(parts) >= 6: + result.append({ + "hash": parts[0], + "short": parts[1], + "author": parts[2], + "date": format_russian_datetime(parts[3]), + "timestamp": int(parts[4]) if parts[4].isdigit() else 0, + "message": parts[5], + }) + return result + + +def branch_first_unique_timestamp(repo_dir, branch_ref, default_ref): + """Когда ветка реально стала отдельной. + Git не хранит дату создания ветки, поэтому берём первый коммит ветки, + которого нет в основной ветке. Если таких нет — ветка считается старой. + """ + if branch_ref == default_ref: + return None + result = run_git_allow_fail(["log", "--reverse", "--format=%at", f"{default_ref}..{branch_ref}"], cwd=repo_dir) + if result.returncode == 0: + for line in result.stdout.splitlines(): + line = line.strip() + if line.isdigit(): + return int(line) + return None + + +def latest_branch_commit_at_time(repo_dir, branch_ref, selected_timestamp): + commits = get_branch_commits(repo_dir, branch_ref) + if not commits: + return None + current = None + for commit in commits: + if commit["timestamp"] <= selected_timestamp: + current = commit + else: + break + return current + + +def get_visible_branches_at_commit(repo_dir, selected_commit): + all_branches = get_all_remote_branches(repo_dir) + default_branch = get_default_branch(repo_dir) + default_ref = f"origin/{default_branch}" + if default_ref not in all_branches and all_branches: + default_ref = all_branches[0] + + selected_ts = selected_commit.get("timestamp", 0) + visible = [] + for branch_ref in all_branches: + if branch_ref == default_ref: + visible.append(branch_ref) + continue + first_unique_ts = branch_first_unique_timestamp(repo_dir, branch_ref, default_ref) + # Если у ветки есть собственные коммиты, она появляется только начиная с первого такого коммита. + if first_unique_ts is not None and first_unique_ts <= selected_ts: + visible.append(branch_ref) + # Если уникальных коммитов нет, показываем только если в этой ветке уже есть коммит на выбранный момент. + elif first_unique_ts is None and latest_branch_commit_at_time(repo_dir, branch_ref, selected_ts): + visible.append(branch_ref) + return visible + + +def analyze_files_for_branches(repo_dir, selected_commit): + selected_hash = selected_commit.get("hash") + selected_ts = selected_commit.get("timestamp", 0) + branches = get_visible_branches_at_commit(repo_dir, selected_commit) + changed = files_changed_in_commit(repo_dir, selected_hash) if selected_hash else set() + result = [] + + for branch_ref in branches: + branch_name = safe_branch_name(branch_ref) + branch_commit = latest_branch_commit_at_time(repo_dir, branch_ref, selected_ts) + if not branch_commit: + continue + ref_for_files = branch_commit["hash"] + ref_note = "состояние на выбранной версии" + + files = list_files_at_ref(repo_dir, ref_for_files) + languages = {} + total_files = 0 + total_lines = 0 + + for path in files: + if any(part in IGNORE_DIRS for part in Path(path).parts): + continue + language = detect_language(path) + if not language: + continue + content = read_file_at_ref(repo_dir, ref_for_files, path) + lines = count_code_lines(content) + total_files += 1 + total_lines += lines + languages.setdefault(language, []).append({ + "path": path, + "lines": lines, + "changed": path in changed, + }) + + result.append({ + "name": branch_name, + "ref": branch_ref, + "ref_note": ref_note, + "branch_commit": branch_commit, + "languages": languages, + "total_files": total_files, + "total_lines": total_lines, + }) + + return result + + +def analyze_repository(link, selected_index=None): + temp_dir, repo_dir = clone_repo(link) + try: + commits = get_commits(repo_dir, limit=10) + if not commits: + raise RuntimeError("В репозитории нет коммитов") + if selected_index is None: + selected_index = len(commits) - 1 + selected_index = max(0, min(selected_index, len(commits) - 1)) + selected_commit = commits[selected_index] + + default_branch = get_default_branch(repo_dir) + visible_branches = get_visible_branches_at_commit(repo_dir, selected_commit) + first = first_commit_info(repo_dir) + last = last_commit_info(repo_dir) + authors = get_all_authors(repo_dir) + branch_files = analyze_files_for_branches(repo_dir, selected_commit) + + total_files = sum(b["total_files"] for b in branch_files) + total_lines = sum(b["total_lines"] for b in branch_files) + + return { + "link": link, + "repo_name": repo_name_from_url(link), + "default_branch": default_branch, + "branches_count": len(visible_branches), + "branches": [safe_branch_name(b) for b in visible_branches], + "first": first, + "last": last, + "authors": authors, + "commits": commits, + "selected_index": selected_index, + "selected_commit": selected_commit, + "branch_files": branch_files, + "total_files": total_files, + "total_lines": total_lines, + } + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def format_analysis(data): + lines = [] + lines.append(f"Ссылка: {data['link']}") + lines.append(f"Название репозитория: {data['repo_name']}") + lines.append(f"Основная ветка: {data['default_branch']}") + lines.append(f"Количество веток в выбранной версии: {data['branches_count']}") + lines.append("Ветки в выбранной версии: " + (", ".join(data['branches']) if data['branches'] else "—")) + lines.append("") + lines.append(f"Автор по первому коммиту: {data['first']['author']}") + lines.append("Авторы / соавторы по коммитам:") + for author in data["authors"]: + lines.append(f"- {author}") + lines.append("") + lines.append(f"Дата создания по первому коммиту: {data['first']['date']}") + lines.append(f"Дата последнего изменения: {data['last']['date']}") + lines.append(f"Всего коммитов в списке: {len(data['commits'])}") + lines.append("") + lines.append("Последние 10 коммитов:") + for idx, commit in enumerate(data["commits"], start=1): + marker = " <= текущая версия" if idx - 1 == data["selected_index"] else "" + lines.append(f"{idx}. {commit['short']} | {commit['author']} | {commit['date']} | {commit['message']}{marker}") + lines.append("") + + selected = data["selected_commit"] + lines.append("Текущая выбранная версия:") + lines.append(f"Коммит: {data['selected_index'] + 1} ({selected['short']})") + lines.append(f"Автор коммита: {selected['author']}") + lines.append(f"Дата: {selected['date']}") + lines.append(f"Комментарий: {selected['message']}") + lines.append("") + lines.append("Файлы по языкам и веткам:") + lines.append("") + + for branch in data["branch_files"]: + branch_commit = branch.get("branch_commit", {}) + commit_note = f", коммит ветки: {branch_commit.get('short', '—')}" if branch_commit else "" + lines.append(f"Ветка: {branch['name']} ({branch['ref_note']}{commit_note})") + lines.append(f"Файлов кода: {branch['total_files']}, строк кода: {branch['total_lines']}") + if not branch["languages"]: + lines.append(" Файлов поддерживаемых языков нет") + for language, files in branch["languages"].items(): + lang_lines = sum(f["lines"] for f in files) + lines.append(f" {language}: файлов {len(files)}, строк {lang_lines}") + for f in files: + changed = " (изменён в этом коммите)" if f["changed"] else "" + lines.append(f" - {f['path']}: {f['lines']} строк{changed}") + lines.append("") + + lines.append(f"Итого файлов кода по всем веткам выбранной версии: {data['total_files']}") + lines.append(f"Итого строк кода по всем веткам выбранной версии: {data['total_lines']}") + return "\n".join(lines) diff --git a/main.py b/main.py new file mode 100644 index 0000000..6f812ec --- /dev/null +++ b/main.py @@ -0,0 +1,378 @@ +import os +import subprocess +import threading +import tkinter as tk +from tkinter import messagebox + +from storage import register_user, check_user, get_repositories, add_repository, delete_repository +from git_analyzer import analyze_repository, format_analysis + + +class UniversalGitAnalyzerApp: + def __init__(self): + self.root = tk.Tk() + self.root.title("Universal Git Analyzer") + self.root.geometry("1920x1080+0+0") + self.root.minsize(1200, 720) + self.root.resizable(True, True) + try: + self.root.attributes("-zoomed", True) + except Exception: + pass + + self.current_user = None + self.current_repo = None + self.current_index = None + self.current_data = None + + self.bg = "#eef4ff" + self.panel = "#ffffff" + self.button = "#6aa6ff" + self.logo_image = None + self.loading_canvas = None + self.loading_cat = None + self.loading_step = 0 + self.root.configure(bg=self.bg) + + self.show_splash() + self.root.mainloop() + + def clear(self): + for widget in self.root.winfo_children(): + widget.destroy() + + def resource_path(self, relative_path): + base_path = getattr(__import__("sys"), "_MEIPASS", os.path.abspath(os.path.dirname(__file__))) + return os.path.join(base_path, relative_path) + + def play_sound(self, filename): + path = self.resource_path(os.path.join("assets", filename)) + if not os.path.exists(path): + return + + # Windows: проигрываем wav без внешних программ + if os.name == "nt": + try: + import winsound + winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC) + return + except Exception: + return + + # Linux: пробуем стандартные проигрыватели звука + for player in ("paplay", "aplay"): + try: + subprocess.Popen([player, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + except FileNotFoundError: + continue + except Exception: + break + + def play_startup_sound(self): + self.play_sound("startup.wav") + + def make_button(self, parent, text, command, width=22): + return tk.Button( + parent, + text=text, + command=command, + width=width, + bg=self.button, + fg="white", + activebackground="#4d8ee8", + font=("Arial", 10, "bold"), + relief="flat", + cursor="hand2", + pady=6, + wraplength=360, + ) + + def show_splash(self): + self.clear() + self.play_startup_sound() + + frame = tk.Frame(self.root, bg="#07101f") + frame.pack(fill="both", expand=True) + + logo_path = self.resource_path(os.path.join("assets", "logo.png")) + if os.path.exists(logo_path): + try: + self.logo_image = tk.PhotoImage(file=logo_path) + if self.logo_image.width() > 560: + factor = max(1, (self.logo_image.width() + 519) // 520) + self.logo_image = self.logo_image.subsample(factor, factor) + tk.Label(frame, image=self.logo_image, bg="#07101f").pack(pady=(25, 8)) + except Exception: + tk.Label(frame, text="Universal Git Analyzer", font=("Arial", 30, "bold"), bg="#07101f", fg="white").pack(pady=80) + else: + tk.Label(frame, text="Universal Git Analyzer", font=("Arial", 30, "bold"), bg="#07101f", fg="white").pack(pady=80) + + tk.Label(frame, text="Загрузка проекта...", font=("Arial", 13, "bold"), bg="#07101f", fg="#dce8ff").pack(pady=5) + + self.loading_canvas = tk.Canvas(frame, width=640, height=100, bg="#07101f", highlightthickness=0) + self.loading_canvas.pack(pady=(45, 0)) + self.draw_loading_bar() + self.animate_loading_cat() + + self.root.after(4500, self.show_login) + + + def draw_loading_bar(self): + if not self.loading_canvas: + return + c = self.loading_canvas + c.delete("all") + c.create_line(70, 58, 545, 58, fill="#2a3e63", width=6, capstyle="round") + c.create_line(70, 58, 545, 58, fill="#4e8cff", width=2, capstyle="round") + # Чашка Gitea справа + c.create_oval(545, 30, 595, 80, fill="#24551f", outline="#7dd36f", width=2) + c.create_arc(585, 42, 620, 70, start=-70, extent=140, outline="#7dd36f", width=4, style="arc") + c.create_rectangle(559, 45, 582, 69, fill="#24551f", outline="#24551f") + c.create_text(570, 57, text="☕", fill="#e8ffe4", font=("Arial", 18, "bold")) + c.create_text(570, 88, text="Gitea", fill="#9dc8ff", font=("Arial", 9, "bold")) + + def animate_loading_cat(self): + if not self.loading_canvas: + return + c = self.loading_canvas + c.delete("cat") + steps = 90 + x = 70 + int((455 * self.loading_step) / steps) + y = 58 + # Чёрный кот, который бежит к чаю + c.create_oval(x - 18, y - 22, x + 18, y + 14, fill="#02050a", outline="white", width=2, tags="cat") + c.create_polygon(x - 16, y - 16, x - 7, y - 34, x + 0, y - 16, fill="#02050a", outline="white", width=2, tags="cat") + c.create_polygon(x + 4, y - 16, x + 13, y - 34, x + 20, y - 16, fill="#02050a", outline="white", width=2, tags="cat") + c.create_line(x - 18, y - 1, x - 36, y - 8, fill="white", width=2, tags="cat") + c.create_line(x - 18, y + 5, x - 38, y + 5, fill="white", width=2, tags="cat") + c.create_line(x + 18, y - 1, x + 36, y - 8, fill="white", width=2, tags="cat") + c.create_line(x + 18, y + 5, x + 38, y + 5, fill="white", width=2, tags="cat") + c.create_arc(x - 36, y - 2, x - 5, y + 35, start=190, extent=170, outline="white", width=3, style="arc", tags="cat") + c.create_text(x, y + 31, text="🐾", fill="#dce8ff", font=("Arial", 12), tags="cat") + + self.loading_step += 1 + if self.loading_step <= steps: + self.root.after(45, self.animate_loading_cat) + + def show_login(self): + self.clear() + frame = tk.Frame(self.root, bg=self.panel, padx=35, pady=35) + frame.place(relx=0.5, rely=0.5, anchor="center") + tk.Label(frame, text="Вход в аккаунт", font=("Arial", 22, "bold"), bg=self.panel).pack(pady=10) + tk.Label(frame, text="Логин", bg=self.panel, font=("Arial", 11)).pack(anchor="w") + login_entry = tk.Entry(frame, width=35, font=("Arial", 12)) + login_entry.pack(pady=5) + tk.Label(frame, text="Пароль", bg=self.panel, font=("Arial", 11)).pack(anchor="w") + password_entry = tk.Entry(frame, width=35, font=("Arial", 12), show="*") + password_entry.pack(pady=5) + + def login(): + login_value = login_entry.get().strip() + password_value = password_entry.get().strip() + if not login_value or not password_value: + messagebox.showerror("Ошибка", "Введите логин и пароль") + return + if check_user(login_value, password_value): + self.current_user = login_value + self.show_main_screen() + else: + messagebox.showerror("Ошибка", "Неверный логин или пароль") + + self.make_button(frame, "Войти", login).pack(pady=12) + self.make_button(frame, "Создать профиль", self.show_register).pack(pady=5) + + def show_register(self): + self.clear() + frame = tk.Frame(self.root, bg=self.panel, padx=35, pady=35) + frame.place(relx=0.5, rely=0.5, anchor="center") + tk.Label(frame, text="Создание профиля", font=("Arial", 22, "bold"), bg=self.panel).pack(pady=10) + tk.Label(frame, text="Логин", bg=self.panel, font=("Arial", 11)).pack(anchor="w") + login_entry = tk.Entry(frame, width=35, font=("Arial", 12)) + login_entry.pack(pady=5) + tk.Label(frame, text="Пароль", bg=self.panel, font=("Arial", 11)).pack(anchor="w") + password_entry = tk.Entry(frame, width=35, font=("Arial", 12), show="*") + password_entry.pack(pady=5) + + def register(): + login_value = login_entry.get().strip() + password_value = password_entry.get().strip() + if not login_value or not password_value: + messagebox.showerror("Ошибка", "Введите логин и пароль") + return + if len(password_value) < 4: + messagebox.showerror("Ошибка", "Пароль должен быть минимум 4 символа") + return + ok, text = register_user(login_value, password_value) + if ok: + messagebox.showinfo("Готово", text) + self.show_login() + else: + messagebox.showerror("Ошибка", text) + + self.make_button(frame, "Зарегистрироваться", register).pack(pady=12) + self.make_button(frame, "Назад", self.show_login).pack(pady=5) + + def show_main_screen(self): + self.clear() + top = tk.Frame(self.root, bg="#1d3557", height=55) + top.pack(fill="x") + tk.Label(top, text=f"Universal Git Analyzer | Пользователь: {self.current_user}", bg="#1d3557", fg="white", font=("Arial", 16, "bold")).pack(side="left", padx=15, pady=12) + tk.Button(top, text="Выйти", command=self.show_login, bg="#e63946", fg="white", relief="flat", font=("Arial", 11)).pack(side="right", padx=15) + + content = tk.Frame(self.root, bg=self.bg) + content.pack(fill="both", expand=True, padx=10, pady=10) + left = tk.Frame(content, bg=self.panel, padx=12, pady=12, width=520) + left.pack(side="left", fill="y") + left.pack_propagate(False) + right = tk.Frame(content, bg=self.panel, padx=10, pady=10) + right.pack(side="right", fill="both", expand=True, padx=(10, 0)) + + tk.Label(left, text="Мои репозитории", bg=self.panel, font=("Arial", 20, "bold")).pack(anchor="w") + self.repo_listbox = tk.Listbox(left, width=58, height=20, font=("Arial", 11)) + self.repo_listbox.pack(fill="x", pady=8) + self.repo_listbox.bind("<>", lambda event: self.on_repo_click()) + + tk.Label(left, text="Новая ссылка", bg=self.panel, font=("Arial", 11)).pack(anchor="w") + self.repo_entry = tk.Entry(left, font=("Arial", 11)) + self.repo_entry.pack(fill="x", pady=5) + + self.make_button(left, "Сохранить ссылку", self.save_repo, width=42).pack(fill="x", pady=4) + self.make_button(left, "Обновить выбранный репозиторий", self.refresh_selected, width=42).pack(fill="x", pady=4) + self.make_button(left, "Обновить все репозитории", self.refresh_all, width=42).pack(fill="x", pady=4) + self.make_button(left, "Удалить ссылку", self.remove_repo, width=42).pack(fill="x", pady=4) + + text_frame = tk.Frame(right, bg=self.panel) + text_frame.pack(fill="both", expand=True) + self.result_text = tk.Text(text_frame, font=("Consolas", 11), wrap="word") + self.result_text.pack(side="left", fill="both", expand=True) + scrollbar = tk.Scrollbar(text_frame, command=self.result_text.yview) + scrollbar.pack(side="right", fill="y") + self.result_text.config(yscrollcommand=scrollbar.set) + self.result_text.config(state="disabled") + + nav = tk.Frame(right, bg=self.panel) + nav.pack(fill="x", pady=(6, 0)) + self.prev_button = self.make_button(nav, "<-", self.prev_commit, width=8) + self.prev_button.pack(side="left") + self.version_label = tk.Label(nav, text="Версия: —", bg=self.panel, font=("Arial", 11, "bold")) + self.version_label.pack(side="left", padx=12) + self.next_button = self.make_button(nav, "->", self.next_commit, width=8) + self.next_button.pack(side="left") + + self.refresh_list() + + def set_text(self, text): + self.result_text.config(state="normal") + self.result_text.delete("1.0", tk.END) + self.result_text.insert(tk.END, text) + self.result_text.config(state="disabled") + + def refresh_list(self): + self.repo_listbox.delete(0, tk.END) + for repo in get_repositories(self.current_user): + self.repo_listbox.insert(tk.END, repo) + + def get_selected_repo(self): + selection = self.repo_listbox.curselection() + if not selection: + return None + return self.repo_listbox.get(selection[0]) + + def save_repo(self): + link = self.repo_entry.get().strip() + if not link: + messagebox.showerror("Ошибка", "Введите ссылку") + return + added = add_repository(self.current_user, link) + if added: + self.repo_entry.delete(0, tk.END) + self.refresh_list() + messagebox.showinfo("Готово", "Ссылка сохранена") + else: + messagebox.showinfo("Информация", "Эта ссылка уже сохранена") + + def remove_repo(self): + link = self.get_selected_repo() + if not link: + messagebox.showerror("Ошибка", "Выберите репозиторий") + return + delete_repository(self.current_user, link) + self.refresh_list() + self.current_repo = None + self.current_index = None + self.current_data = None + self.set_text("") + self.version_label.config(text="Версия: —") + + def on_repo_click(self): + link = self.get_selected_repo() + if link: + self.load_repo(link, selected_index=None) + + def refresh_selected(self): + link = self.get_selected_repo() or self.current_repo + if not link: + messagebox.showerror("Ошибка", "Выберите репозиторий") + return + self.load_repo(link, self.current_index) + + def refresh_all(self): + repos = get_repositories(self.current_user) + if not repos: + messagebox.showinfo("Информация", "Сохранённых репозиториев нет") + return + self.set_text("Обновление всех репозиториев...\n") + def worker(): + results = [] + for repo in repos: + try: + data = analyze_repository(repo) + results.append(format_analysis(data)) + except Exception as e: + results.append(f"Ссылка: {repo}\nОшибка: {e}") + self.root.after(0, lambda: self.set_text("\n\n" + "=" * 70 + "\n\n".join(results))) + threading.Thread(target=worker, daemon=True).start() + + def load_repo(self, link, selected_index=None): + self.current_repo = link + self.set_text("Загрузка данных...\n") + self.version_label.config(text="Версия: загрузка...") + + def worker(): + try: + data = analyze_repository(link, selected_index) + self.root.after(0, lambda: self.show_analysis(data)) + except Exception as e: + self.root.after(0, lambda: self.show_error(e)) + threading.Thread(target=worker, daemon=True).start() + + def show_analysis(self, data): + self.current_data = data + self.current_index = data["selected_index"] + self.set_text(format_analysis(data)) + commit = data["selected_commit"] + self.version_label.config(text=f"Версия: {self.current_index + 1} / {len(data['commits'])} ({commit['short']})") + + def show_error(self, error): + self.set_text("") + self.version_label.config(text="Версия: —") + messagebox.showerror("Ошибка", str(error)) + + def prev_commit(self): + if not self.current_repo or self.current_index is None: + return + if self.current_index <= 0: + return + self.load_repo(self.current_repo, self.current_index - 1) + + def next_commit(self): + if not self.current_repo or self.current_index is None or not self.current_data: + return + if self.current_index >= len(self.current_data["commits"]) - 1: + return + self.load_repo(self.current_repo, self.current_index + 1) + + +if __name__ == "__main__": + UniversalGitAnalyzerApp() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e2ac38d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# Зависимости не нужны. Используются стандартные библиотеки Python и установленный git. diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..30e69fa --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")" +python3 main.py diff --git a/run_linux.sh b/run_linux.sh new file mode 100755 index 0000000..30e69fa --- /dev/null +++ b/run_linux.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd "$(dirname "$0")" +python3 main.py diff --git a/run_windows.bat b/run_windows.bat new file mode 100644 index 0000000..71b4790 --- /dev/null +++ b/run_windows.bat @@ -0,0 +1,36 @@ +@echo off +cd /d "%~dp0" + +where git >nul 2>nul +if errorlevel 1 ( + echo Git не найден. Установи Git for Windows: https://git-scm.com/download/win + pause + exit /b 1 +) + +where pyw >nul 2>nul +if not errorlevel 1 ( + start "" pyw -3 "%~dp0main.py" + exit /b 0 +) + +where pythonw >nul 2>nul +if not errorlevel 1 ( + start "" pythonw "%~dp0main.py" + exit /b 0 +) + +where py >nul 2>nul +if not errorlevel 1 ( + py -3 "%~dp0main.py" + exit /b 0 +) + +where python >nul 2>nul +if not errorlevel 1 ( + python "%~dp0main.py" + exit /b 0 +) + +echo Python не найден. Установи Python 3: https://www.python.org/downloads/ +pause diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..9601d60 --- /dev/null +++ b/storage.py @@ -0,0 +1,69 @@ +import json +import os +import hashlib + +DATA_FILE = "users_data.json" + + +def _load(): + if not os.path.exists(DATA_FILE): + return {"users": {}} + try: + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"users": {}} + + +def _save(data): + with open(DATA_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def _hash_password(password): + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + +def register_user(login, password): + data = _load() + if login in data["users"]: + return False, "Такой пользователь уже существует" + data["users"][login] = { + "password": _hash_password(password), + "repositories": [] + } + _save(data) + return True, "Профиль создан" + + +def check_user(login, password): + data = _load() + user = data["users"].get(login) + if not user: + return False + return user.get("password") == _hash_password(password) + + +def get_repositories(login): + data = _load() + return data["users"].get(login, {}).get("repositories", []) + + +def add_repository(login, link): + data = _load() + repos = data["users"].setdefault(login, {"password": "", "repositories": []}).setdefault("repositories", []) + if link in repos: + return False + repos.append(link) + _save(data) + return True + + +def delete_repository(login, link): + data = _load() + repos = data["users"].get(login, {}).get("repositories", []) + if link in repos: + repos.remove(link) + _save(data) + return True + return False