Files
Kur3/git_analyzer.py

502 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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", ".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=""):
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():
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):
link = safe_strip(link)
if not link:
raise RuntimeError("Пустая ссылка на репозиторий")
if "github.com/" in link:
before = link
proto = ""
rest = link
if "://" in rest:
proto, rest = rest.split("://", 1)
proto += "://"
parts = rest.split("/")
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)