Universal Git Analyzer V16 OOP final
This commit is contained in:
97
CLASS_DIAGRAM.md
Normal file
97
CLASS_DIAGRAM.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Диаграмма классов Universal Git Analyzer OOP
|
||||||
|
|
||||||
|
## Основные классы
|
||||||
|
|
||||||
|
```text
|
||||||
|
UniversalGitAnalyzerApp
|
||||||
|
├── storage: StorageManager
|
||||||
|
├── git_analyzer: GitAnalyzer
|
||||||
|
├── current_user
|
||||||
|
├── current_repo
|
||||||
|
├── current_index
|
||||||
|
└── current_data
|
||||||
|
|
||||||
|
StorageManager
|
||||||
|
├── data_file
|
||||||
|
├── register_user(login, password)
|
||||||
|
├── check_user(login, password)
|
||||||
|
├── get_repositories(login)
|
||||||
|
├── add_repository(login, link)
|
||||||
|
└── delete_repository(login, link)
|
||||||
|
|
||||||
|
UserProfile
|
||||||
|
├── login
|
||||||
|
├── password_hash
|
||||||
|
├── repositories
|
||||||
|
└── to_dict()
|
||||||
|
|
||||||
|
GitAnalyzer
|
||||||
|
├── languages
|
||||||
|
├── ignore_dirs
|
||||||
|
├── normalize_link(link)
|
||||||
|
├── clone_repo(link)
|
||||||
|
├── get_commits(repo_dir)
|
||||||
|
├── get_visible_branches_at_commit(repo_dir, selected_commit)
|
||||||
|
├── analyze_files_for_branches(repo_dir, selected_commit)
|
||||||
|
├── analyze_repository(link, selected_index)
|
||||||
|
└── format_analysis(data)
|
||||||
|
|
||||||
|
RepositoryInfo
|
||||||
|
├── link
|
||||||
|
├── repo_name
|
||||||
|
├── default_branch
|
||||||
|
├── branches
|
||||||
|
├── commits
|
||||||
|
├── total_files
|
||||||
|
└── total_lines
|
||||||
|
|
||||||
|
CommitInfo
|
||||||
|
├── hash
|
||||||
|
├── short
|
||||||
|
├── author
|
||||||
|
├── date
|
||||||
|
├── timestamp
|
||||||
|
└── message
|
||||||
|
|
||||||
|
BranchInfo
|
||||||
|
├── name
|
||||||
|
├── ref
|
||||||
|
├── total_files
|
||||||
|
└── total_lines
|
||||||
|
```
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
|
||||||
|
```text
|
||||||
|
UniversalGitAnalyzerApp --> StorageManager
|
||||||
|
UniversalGitAnalyzerApp --> GitAnalyzer
|
||||||
|
StorageManager --> UserProfile
|
||||||
|
GitAnalyzer --> RepositoryInfo
|
||||||
|
RepositoryInfo --> CommitInfo
|
||||||
|
RepositoryInfo --> BranchInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Объекты системы во время работы
|
||||||
|
|
||||||
|
Пример объектов, которые появляются при запуске:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app = UniversalGitAnalyzerApp()
|
||||||
|
storage = StorageManager()
|
||||||
|
git_analyzer = GitAnalyzer()
|
||||||
|
user = UserProfile("egor", password_hash, repositories)
|
||||||
|
repository = RepositoryInfo(link, repo_name, default_branch, branches, commits)
|
||||||
|
commit = CommitInfo(hash, short, author, date, timestamp, message)
|
||||||
|
branch = BranchInfo(name, ref, total_files, total_lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что сказать на защите
|
||||||
|
|
||||||
|
В OOP-версии приложение разделено на классы:
|
||||||
|
|
||||||
|
- `UniversalGitAnalyzerApp` отвечает за интерфейс.
|
||||||
|
- `StorageManager` отвечает за локальное хранение пользователей и ссылок.
|
||||||
|
- `GitAnalyzer` отвечает за анализ Git-репозитория.
|
||||||
|
- `UserProfile`, `RepositoryInfo`, `CommitInfo`, `BranchInfo` являются объектами предметной области.
|
||||||
|
|
||||||
|
То есть система теперь представлена не набором отдельных функций, а объектами, у каждого из которых есть своя зона ответственности.
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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;
|
||||||
|
- правое поле информации только для чтения.
|
||||||
|
|
||||||
|
|
||||||
|
V15: удалены CSS и Rust; добавлены Batchfile, PowerShell и C#.
|
||||||
|
|
||||||
|
|
||||||
|
V12: исправлен запуск git на Windows — теперь при обновлении не появляются всплывающие cmd-окна.
|
||||||
|
|
||||||
|
|
||||||
|
V15: исправлено определение Batchfile/PowerShell/C#, добавлена UTF-8 кодировка для сообщений git-коммитов на Windows.
|
||||||
64
README_START.md
Normal file
64
README_START.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 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` можно кинуть на рабочий стол и запускать кликом.
|
||||||
|
|
||||||
|
|
||||||
|
V15: удалены CSS и Rust; добавлены Batchfile, PowerShell и C#.
|
||||||
|
|
||||||
|
|
||||||
|
V12: исправлен запуск git на Windows — теперь при обновлении не появляются всплывающие cmd-окна.
|
||||||
|
|
||||||
|
|
||||||
|
V15: исправлено определение Batchfile/PowerShell/C#, добавлена UTF-8 кодировка для сообщений git-коммитов на Windows.
|
||||||
|
|
||||||
|
## OOP-версия
|
||||||
|
|
||||||
|
В этой версии Python-код переделан под классы:
|
||||||
|
|
||||||
|
- `UniversalGitAnalyzerApp`
|
||||||
|
- `StorageManager`
|
||||||
|
- `GitAnalyzer`
|
||||||
|
- `UserProfile`
|
||||||
|
- `RepositoryInfo`
|
||||||
|
- `CommitInfo`
|
||||||
|
- `BranchInfo`
|
||||||
|
|
||||||
|
Диаграмма классов описана в `CLASS_DIAGRAM.md`.
|
||||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/startup.wav
Normal file
BIN
assets/startup.wav
Normal file
Binary file not shown.
8
build_windows_exe.bat
Normal file
8
build_windows_exe.bat
Normal file
@@ -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
|
||||||
629
git_analyzer.py
Normal file
629
git_analyzer.py
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommitInfo:
|
||||||
|
"""Объект одного коммита для UML-диаграммы и логики системы."""
|
||||||
|
hash: str
|
||||||
|
short: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
timestamp: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BranchInfo:
|
||||||
|
"""Объект ветки репозитория."""
|
||||||
|
name: str
|
||||||
|
ref: str
|
||||||
|
total_files: int = 0
|
||||||
|
total_lines: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepositoryInfo:
|
||||||
|
"""Объект результата анализа репозитория."""
|
||||||
|
link: str
|
||||||
|
repo_name: str
|
||||||
|
default_branch: str
|
||||||
|
branches: list = field(default_factory=list)
|
||||||
|
commits: list = field(default_factory=list)
|
||||||
|
total_files: int = 0
|
||||||
|
total_lines: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
LANG_EXTENSIONS = {
|
||||||
|
"Python": [".py"],
|
||||||
|
"C": [".c", ".h"],
|
||||||
|
"C++": [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"],
|
||||||
|
"C#": [".cs", ".csx"],
|
||||||
|
"Java": [".java"],
|
||||||
|
"JavaScript": [".js", ".jsx", ".mjs", ".cjs"],
|
||||||
|
"TypeScript": [".ts", ".tsx"],
|
||||||
|
"Bash / Shell": [".sh", ".bash", ".zsh", ".ksh"],
|
||||||
|
"Batchfile": [".bat", ".cmd", ".btm"],
|
||||||
|
"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 safe_text(value, default=""):
|
||||||
|
"""Безопасно превращает None/числа/странные значения в строку."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_strip(value, default=""):
|
||||||
|
return safe_text(value, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_no_window_kwargs():
|
||||||
|
"""Настройки для Windows, чтобы git не открывал отдельные cmd-окна.
|
||||||
|
На Linux/macOS возвращается пустой словарь.
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
try:
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
startupinfo.wShowWindow = 0
|
||||||
|
kwargs["startupinfo"] = startupinfo
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(args, cwd=None):
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-c", "core.quotepath=false"] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
timeout=60,
|
||||||
|
**_subprocess_no_window_kwargs(),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(safe_strip(result.stderr) or safe_strip(result.stdout) or "Ошибка git")
|
||||||
|
return safe_strip(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_allow_fail(args, cwd=None):
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-c", "core.quotepath=false"] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
timeout=60,
|
||||||
|
**_subprocess_no_window_kwargs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_link(link):
|
||||||
|
"""Приводит ссылку к git-репозиторию.
|
||||||
|
Можно случайно вставить GitHub release/tree/blob — программа сама обрежет до репы.
|
||||||
|
"""
|
||||||
|
link = safe_strip(link)
|
||||||
|
if not link:
|
||||||
|
raise RuntimeError("Пустая ссылка на репозиторий")
|
||||||
|
|
||||||
|
# Убираем лишний пробел/перенос и хвосты страниц GitHub, которые не являются git repo
|
||||||
|
if "github.com/" in link:
|
||||||
|
before = link
|
||||||
|
proto = ""
|
||||||
|
rest = link
|
||||||
|
if "://" in rest:
|
||||||
|
proto, rest = rest.split("://", 1)
|
||||||
|
proto += "://"
|
||||||
|
parts = rest.split("/")
|
||||||
|
# github.com / owner / repo / ...
|
||||||
|
if len(parts) >= 3 and parts[0].lower() == "github.com":
|
||||||
|
repo = parts[2]
|
||||||
|
if repo.endswith(".git"):
|
||||||
|
repo = repo[:-4]
|
||||||
|
link = proto + "/".join([parts[0], parts[1], repo])
|
||||||
|
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def repo_name_from_url(url):
|
||||||
|
url = normalize_link(url)
|
||||||
|
name = url.rstrip("/").split("/")[-1]
|
||||||
|
if name.endswith(".git"):
|
||||||
|
name = name[:-4]
|
||||||
|
return name or "repository"
|
||||||
|
|
||||||
|
|
||||||
|
def count_code_lines(text):
|
||||||
|
text = safe_text(text)
|
||||||
|
return sum(1 for line in text.splitlines() if safe_strip(line))
|
||||||
|
|
||||||
|
|
||||||
|
def format_russian_datetime(value):
|
||||||
|
value = safe_strip(value)
|
||||||
|
if not value or value == "—":
|
||||||
|
return "—"
|
||||||
|
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 = safe_strip(line)
|
||||||
|
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": safe_strip(parts[1], "—"),
|
||||||
|
"author": safe_strip(parts[2], "—"),
|
||||||
|
"date": format_russian_datetime(parts[3]),
|
||||||
|
"timestamp": int(safe_strip(parts[4])) if safe_strip(parts[4]).isdigit() else 0,
|
||||||
|
"message": safe_strip(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(safe_strip(x) for x in out.splitlines() if safe_strip(x)))
|
||||||
|
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": safe_strip(parts[0]), "author": safe_strip(parts[1], "—"), "date": format_russian_datetime(parts[2]), "timestamp": int(safe_strip(parts[3])) if safe_strip(parts[3]).isdigit() else 0, "message": safe_strip(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": safe_strip(parts[0]), "author": safe_strip(parts[1], "—"), "date": format_russian_datetime(parts[2]), "timestamp": int(safe_strip(parts[3])) if safe_strip(parts[3]).isdigit() else 0, "message": safe_strip(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(safe_strip(x) for x in out.splitlines() if safe_strip(x))
|
||||||
|
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 [safe_strip(x) for x in out.splitlines() if safe_strip(x)]
|
||||||
|
|
||||||
|
|
||||||
|
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": safe_strip(parts[1], "—"),
|
||||||
|
"author": safe_strip(parts[2], "—"),
|
||||||
|
"date": format_russian_datetime(parts[3]),
|
||||||
|
"timestamp": int(safe_strip(parts[4])) if safe_strip(parts[4]).isdigit() else 0,
|
||||||
|
"message": safe_strip(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 = safe_strip(line)
|
||||||
|
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": normalize_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)
|
||||||
|
|
||||||
|
|
||||||
|
class GitAnalyzer:
|
||||||
|
"""Главный класс анализа Git-репозиториев.
|
||||||
|
|
||||||
|
В старых версиях проекта логика была набором функций.
|
||||||
|
В OOP-версии интерфейс создаёт объект GitAnalyzer и вызывает его методы.
|
||||||
|
Так проще строить диаграмму классов и объяснять систему как набор объектов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.languages = LANG_EXTENSIONS
|
||||||
|
self.ignore_dirs = IGNORE_DIRS
|
||||||
|
|
||||||
|
def safe_text(self, value, default=""):
|
||||||
|
return safe_text(value, default)
|
||||||
|
|
||||||
|
def safe_strip(self, value, default=""):
|
||||||
|
return safe_strip(value, default)
|
||||||
|
|
||||||
|
def run_git(self, args, cwd=None):
|
||||||
|
return run_git(args, cwd)
|
||||||
|
|
||||||
|
def run_git_allow_fail(self, args, cwd=None):
|
||||||
|
return run_git_allow_fail(args, cwd)
|
||||||
|
|
||||||
|
def normalize_link(self, link):
|
||||||
|
return normalize_link(link)
|
||||||
|
|
||||||
|
def repo_name_from_url(self, url):
|
||||||
|
return repo_name_from_url(url)
|
||||||
|
|
||||||
|
def detect_language(self, path):
|
||||||
|
return detect_language(path)
|
||||||
|
|
||||||
|
def clone_repo(self, link):
|
||||||
|
return clone_repo(link)
|
||||||
|
|
||||||
|
def get_default_branch(self, repo_dir):
|
||||||
|
return get_default_branch(repo_dir)
|
||||||
|
|
||||||
|
def get_all_remote_branches(self, repo_dir):
|
||||||
|
return get_all_remote_branches(repo_dir)
|
||||||
|
|
||||||
|
def get_commits(self, repo_dir, limit=10):
|
||||||
|
return get_commits(repo_dir, limit)
|
||||||
|
|
||||||
|
def get_all_authors(self, repo_dir):
|
||||||
|
return get_all_authors(repo_dir)
|
||||||
|
|
||||||
|
def first_commit_info(self, repo_dir):
|
||||||
|
return first_commit_info(repo_dir)
|
||||||
|
|
||||||
|
def last_commit_info(self, repo_dir):
|
||||||
|
return last_commit_info(repo_dir)
|
||||||
|
|
||||||
|
def files_changed_in_commit(self, repo_dir, commit_hash):
|
||||||
|
return files_changed_in_commit(repo_dir, commit_hash)
|
||||||
|
|
||||||
|
def get_visible_branches_at_commit(self, repo_dir, selected_commit):
|
||||||
|
return get_visible_branches_at_commit(repo_dir, selected_commit)
|
||||||
|
|
||||||
|
def analyze_files_for_branches(self, repo_dir, selected_commit):
|
||||||
|
return analyze_files_for_branches(repo_dir, selected_commit)
|
||||||
|
|
||||||
|
def analyze_repository(self, link, selected_index=None):
|
||||||
|
data = analyze_repository(link, selected_index)
|
||||||
|
# Дополнительно создаём объект RepositoryInfo для диаграммы объектов.
|
||||||
|
# В интерфейс по-прежнему возвращается словарь, чтобы не ломать старый вывод.
|
||||||
|
self.last_repository_object = RepositoryInfo(
|
||||||
|
link=data.get("link", ""),
|
||||||
|
repo_name=data.get("repo_name", ""),
|
||||||
|
default_branch=data.get("default_branch", ""),
|
||||||
|
branches=data.get("branches", []),
|
||||||
|
commits=data.get("commits", []),
|
||||||
|
total_files=data.get("total_files", 0),
|
||||||
|
total_lines=data.get("total_lines", 0),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def format_analysis(self, data):
|
||||||
|
return format_analysis(data)
|
||||||
381
main.py
Normal file
381
main.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
from storage import StorageManager
|
||||||
|
from git_analyzer import GitAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
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.storage = StorageManager()
|
||||||
|
self.git_analyzer = GitAnalyzer()
|
||||||
|
|
||||||
|
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 self.storage.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 = self.storage.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("<<ListboxSelect>>", 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 self.storage.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 = self.storage.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
|
||||||
|
self.storage.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 = self.storage.get_repositories(self.current_user)
|
||||||
|
if not repos:
|
||||||
|
messagebox.showinfo("Информация", "Сохранённых репозиториев нет")
|
||||||
|
return
|
||||||
|
self.set_text("Обновление всех репозиториев...\n")
|
||||||
|
def worker():
|
||||||
|
results = []
|
||||||
|
for repo in repos:
|
||||||
|
try:
|
||||||
|
data = self.git_analyzer.analyze_repository(repo)
|
||||||
|
results.append(self.git_analyzer.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 = self.git_analyzer.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(self.git_analyzer.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()
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Зависимости не нужны. Используются стандартные библиотеки Python и установленный git.
|
||||||
3
run_linux.sh
Executable file
3
run_linux.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
python3 main.py
|
||||||
36
run_windows.bat
Normal file
36
run_windows.bat
Normal file
@@ -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
|
||||||
122
storage.py
Normal file
122
storage.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile:
|
||||||
|
"""Объект пользователя системы.
|
||||||
|
|
||||||
|
Хранит логин, хэш пароля и список сохранённых репозиториев.
|
||||||
|
Используется как логическая модель для диаграммы классов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, login, password_hash="", repositories=None):
|
||||||
|
self.login = login
|
||||||
|
self.password_hash = password_hash
|
||||||
|
self.repositories = repositories or []
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"password": self.password_hash,
|
||||||
|
"repositories": self.repositories,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StorageManager:
|
||||||
|
"""Класс локального хранилища.
|
||||||
|
|
||||||
|
Отвечает за регистрацию, вход, сохранение и удаление ссылок.
|
||||||
|
Данные хранятся в JSON-файле рядом с программой.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_file="users_data.json"):
|
||||||
|
self.data_file = data_file
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if not os.path.exists(self.data_file):
|
||||||
|
return {"users": {}}
|
||||||
|
try:
|
||||||
|
with open(self.data_file, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {"users": {}}
|
||||||
|
|
||||||
|
def _save(self, data):
|
||||||
|
with open(self.data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def _hash_password(self, password):
|
||||||
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def register_user(self, login, password):
|
||||||
|
data = self._load()
|
||||||
|
if login in data["users"]:
|
||||||
|
return False, "Такой пользователь уже существует"
|
||||||
|
|
||||||
|
user = UserProfile(
|
||||||
|
login=login,
|
||||||
|
password_hash=self._hash_password(password),
|
||||||
|
repositories=[],
|
||||||
|
)
|
||||||
|
data["users"][login] = user.to_dict()
|
||||||
|
self._save(data)
|
||||||
|
return True, "Профиль создан"
|
||||||
|
|
||||||
|
def check_user(self, login, password):
|
||||||
|
data = self._load()
|
||||||
|
user = data["users"].get(login)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
return user.get("password") == self._hash_password(password)
|
||||||
|
|
||||||
|
def get_repositories(self, login):
|
||||||
|
data = self._load()
|
||||||
|
return data["users"].get(login, {}).get("repositories", [])
|
||||||
|
|
||||||
|
def add_repository(self, login, link):
|
||||||
|
data = self._load()
|
||||||
|
repos = data["users"].setdefault(
|
||||||
|
login,
|
||||||
|
{"password": "", "repositories": []},
|
||||||
|
).setdefault("repositories", [])
|
||||||
|
|
||||||
|
if link in repos:
|
||||||
|
return False
|
||||||
|
|
||||||
|
repos.append(link)
|
||||||
|
self._save(data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_repository(self, login, link):
|
||||||
|
data = self._load()
|
||||||
|
repos = data["users"].get(login, {}).get("repositories", [])
|
||||||
|
if link in repos:
|
||||||
|
repos.remove(link)
|
||||||
|
self._save(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Обратная совместимость: если старый код где-то импортирует функции,
|
||||||
|
# они всё равно работают через объект StorageManager.
|
||||||
|
_default_storage = StorageManager()
|
||||||
|
|
||||||
|
|
||||||
|
def register_user(login, password):
|
||||||
|
return _default_storage.register_user(login, password)
|
||||||
|
|
||||||
|
|
||||||
|
def check_user(login, password):
|
||||||
|
return _default_storage.check_user(login, password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_repositories(login):
|
||||||
|
return _default_storage.get_repositories(login)
|
||||||
|
|
||||||
|
|
||||||
|
def add_repository(login, link):
|
||||||
|
return _default_storage.add_repository(login, link)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_repository(login, link):
|
||||||
|
return _default_storage.delete_repository(login, link)
|
||||||
10
users_data.json
Normal file
10
users_data.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"123": {
|
||||||
|
"password": "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4",
|
||||||
|
"repositories": [
|
||||||
|
"https://iux-gitea.myddns.me/24_LitvintsevaVD/hui"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user