🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/blueprints/git.py main

1268 linii Raw ← Powrót
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268
import os, subprocess, io, json, re, time
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, flash, abort, session
from config.settings import GIT_DIR
from database import get_db, get_setting

git_bp = Blueprint("git", __name__)

# ── Search repo packages ──
def _search_packages(query):
    """Szuka pakietów w repo.json lub po plikach .pkg.tar.xz."""
    results = []
    from config.settings import REPO_BASE
    import glob as gmod
    q = query.lower()
    cats = ["core", "desktop", "tools", "network", "multimedia", "gaming", "office", "other"]
    for cat in cats:
        d = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(d):
            continue
        # Najpierw repo.json
        rj = os.path.join(d, "repo.json")
        found = False
        if os.path.exists(rj):
            try:
                pkgs = json.load(open(rj)).get("packages", [])
                for p in pkgs:
                    name = p.get("name", "")
                    if q in name.lower():
                        results.append({
                            "name": name, "version": p.get("version",""), "category": cat,
                            "filename": p.get("filename", ""), "size": p.get("size", 0),
                        })
                        found = True
            except Exception:
                pass
        # Fallback: szukaj po nazwach plików .pkg.tar.xz
        if not found or not os.path.exists(rj):
            for f in sorted(gmod.glob(os.path.join(d, "*.pkg.tar.xz"))):
                fname = os.path.basename(f).replace(".pkg.tar.xz", "")
                if q in fname.lower():
                    results.append({
                        "name": fname.rsplit("-", 1)[0] if "-" in fname else fname,
                        "version": fname.rsplit("-", 1)[1] if "-" in fname else "",
                        "category": cat, "filename": os.path.basename(f),
                        "size": os.path.getsize(f),
                    })
    results.sort(key=lambda x: x["name"])
    return results[:50]

# ── Helpers ──
def _run_git(repo_path, args, timeout=30):
    try:
        r = subprocess.run(
            ["git", f"--git-dir={repo_path}"] + [str(a) for a in args],
            capture_output=True, text=True, timeout=timeout
        )
        return r.stdout if r.returncode == 0 else ""
    except Exception:
        return ""

def _get_branch(repo_path):
    for b in ("main", "master"):
        if _run_git(repo_path, ["rev-parse", "--verify", b]).strip():
            return b
    # Fallback: sprawdź HEAD
    head = _run_git(repo_path, ["symbolic-ref", "--short", "HEAD"]).strip()
    return head if head else "main"

def _get_repos(for_user=None):
    repos = []
    if not os.path.exists(GIT_DIR):
        return repos
    is_admin = for_user and session.get("is_admin")
    for entry in sorted(os.listdir(GIT_DIR)):
        full = os.path.join(GIT_DIR, entry)
        git_path = full if entry.endswith(".git") else os.path.join(full, ".git")
        if os.path.exists(os.path.join(git_path, "HEAD")):
            name = entry[:-4] if entry.endswith(".git") else entry
            # Visibility
            vis_file = os.path.join(git_path, "git-daemon-export-ok")
            visible = os.path.exists(vis_file)
            # Filtruj prywatne repo – tylko admin i uprawnieni widzą
            if not visible and not is_admin:
                if for_user:
                    db = get_db()
                    row = db.execute("SELECT 1 FROM repo_permissions WHERE repo_name=? AND username=?",
                                     (name, for_user)).fetchone()
                    db.close()
                    if not row:
                        continue
                else:
                    continue
            desc = "Brak opisu"
            desc_file = os.path.join(git_path, "description")
            if os.path.exists(desc_file):
                with open(desc_file) as f:
                    d = f.read().strip()
                    if d and "Unnamed" not in d:
                        desc = d
            size = _get_dir_size(git_path)
            branches = _run_git(git_path, ["branch"]).strip()
            branch_count = len([l for l in branches.split("\n") if l]) if branches else 0
            last_commit = _run_git(git_path, ["log", "-1", "--format=%ar"]).strip()
            repos.append({"name": name, "path": git_path, "desc": desc, "visible": visible,
                          "size": size, "branch_count": branch_count, "last_commit": last_commit})
    return repos

def _get_dir_size(path):
    total = 0
    try:
        for dirpath, dirnames, filenames in os.walk(path):
            for f in filenames:
                fp = os.path.join(dirpath, f)
                if os.path.isfile(fp):
                    total += os.path.getsize(fp)
    except Exception:
        pass
    return total

def _format_size(size):
    for unit in ["B", "KB", "MB", "GB"]:
        if size < 1024:
            return f"{size:.1f} {unit}" if unit != "B" else f"{size} B"
        size /= 1024
    return f"{size:.1f} TB"

def _can_edit(username, repo_name):
    if not username:
        return False
    db = get_db()
    row = db.execute("SELECT 1 FROM repo_permissions WHERE repo_name=? AND username=?", (repo_name, username)).fetchone()
    db.close()
    return row is not None or session.get("is_admin")

def _git_readonly():
    """Globalny tryb tylko do odczytu dla gita (ustawiany w panelu admina)."""
    try:
        return get_setting("git_readonly", "0") == "1"
    except Exception:
        return False

def _repo_readonly(repo_name):
    """Per-repo tryb tylko do odczytu (plik-marker w bare repo, jak git-daemon-export-ok)."""
    return os.path.exists(os.path.join(GIT_DIR, f"{repo_name}.git", "pag-readonly"))

def _can_modify(repo_name):
    """Czy bieżący użytkownik może modyfikować pliki repo przez web.

    Blokuje, gdy włączony jest globalny tryb read-only LUB tryb per-repo.
    Admini zachowują dostęp, żeby móc zdjąć blokadę i zarządzać repo.
    """
    if not session.get("is_admin"):
        if _git_readonly() or _repo_readonly(repo_name):
            return False
    return _can_edit(session.get("username"), repo_name)

def _readonly_blocked(repo_name):
    """Odczytowy skrót: pokazuje komunikat i zwraca redirect, gdy zapis zabroniony."""
    if not _can_modify(repo_name):
        if not session.get("is_admin") and _repo_readonly(repo_name):
            flash("🔒 Repozytorium jest w trybie tylko do odczytu – edycja wyłączona", "error")
        elif _git_readonly() and not session.get("is_admin"):
            flash("🔒 Repozytorium w trybie tylko do odczytu – edycja jest wyłączona", "error")
        else:
            flash("Brak uprawnień do edycji tego repo", "error")
        return redirect(f"/git/{repo_name}")
    return None


def _check_repo_access(repo_name):
    """Sprawdza czy użytkownik ma dostęp do repo. Zwraca repo_path lub abort 404."""
    # Path traversal protection
    if ".." in repo_name or "/" in repo_name or "\\" in repo_name:
        abort(404)
    repo_path = os.path.join(GIT_DIR, f"{repo_name}.git")
    if not os.path.exists(repo_path):
        abort(404)
    vis_file = os.path.join(repo_path, "git-daemon-export-ok")
    if not os.path.exists(vis_file):
        username = session.get("username")
        if not _can_edit(username, repo_name):
            flash("Repozytorium prywatne – brak dostępu", "error")
            return None
    return repo_path

def _get_git_env(username):
    email = session.get("email", f"{username}@paganlinux.eu")
    return {"GIT_AUTHOR_NAME": username, "GIT_AUTHOR_EMAIL": email,
            "GIT_COMMITTER_NAME": username, "GIT_COMMITTER_EMAIL": email}

# ── Pomocniki: upload plików / obrazy / świeże (bezcommitowe) repo ──
_IMAGE_MIME = {
    "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
    "gif": "image/gif", "webp": "image/webp", "svg": "image/svg+xml",
    "bmp": "image/bmp", "avif": "image/avif", "ico": "image/x-icon",
}
_TEXT_EXTS = {"txt", "md", "markdown", "py", "sh", "c", "h", "cpp", "hpp", "rs", "go",
              "js", "ts", "json", "yaml", "yml", "toml", "ini", "conf", "css", "html",
              "xml", "svg", "patch", "diff", "license", "gitignore", "cfg", "service",
              "desktop", "1", "5", "po", "pot", "am", "ac", "m4", "mk", "cmake"}

def _file_mime(path):
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    if ext in _IMAGE_MIME:
        return _IMAGE_MIME[ext]
    if ext in _TEXT_EXTS:
        return "text/plain; charset=utf-8"
    return "application/octet-stream"

def _is_image_path(path):
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return ext in _IMAGE_MIME

def _repo_has_commits(repo_path):
    return bool(_run_git(repo_path, ["rev-parse", "--verify", "--quiet", "HEAD"]).strip())

def _prepare_worktree(repo_path, work_tree, branch):
    """Checkout gałęzi do katalogu roboczego; dla repo bez commitów – orphan branch."""
    env = {**os.environ, **(_get_git_env(session["username"]) if session.get("username") else {})}
    if _repo_has_commits(repo_path):
        # Zsynchronizuj indeks bare repo z galezia PRZED checkoutem. Bez tego
        # nieswiezy index (np. po skasowaniu receptur) jest materializowany do
        # work-tree, a kolejny `git add -A` wskrzesza usuniete pliki.
        subprocess.run(["git", f"--git-dir={repo_path}", "read-tree", branch],
                       capture_output=True, text=True, timeout=30, env=env)
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}",
                        "checkout", "-f", branch, "."], capture_output=True, text=True, timeout=30, env=env)
    else:
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}",
                        "checkout", "--orphan", branch], capture_output=True, text=True, timeout=30, env=env)

def _commit_and_push(repo_path, work_tree, branch, message):
    """git add -A + commit + (próba) push – commit zapisuje się wprost w repo."""
    env = {**os.environ, **(_get_git_env(session["username"]) if session.get("username") else {})}
    subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", "-A"],
                   capture_output=True, text=True, timeout=10, env=env)
    subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", message],
                   capture_output=True, text=True, timeout=15, env=env)
    subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch],
                   capture_output=True, text=True, timeout=15, env=env)

# ── Release (tag + tarball w /sources/) ──
SOURCES_DIR = "/var/www/repo.paganlinux.eu/sources"
SOURCES_URL = "https://repo.paganlinux.eu/sources"

def _get_releases(repo_name, repo_path, limit=10):
    """Zwraca release'y repozytorium (tagi, dla których istnieje tarball w /sources/)."""
    releases = []
    tags = _run_git(repo_path, ["tag", "--sort=-creatordate"]).strip()
    if not tags:
        return releases
    for tag in tags.split("\n")[:limit]:
        tag = tag.strip()
        if not tag:
            continue
        version = tag[1:] if tag.startswith("v") else tag
        # Sprawdź czy tarball release'a jest w /sources/ (.tar.gz lub .tar.xz)
        for ext in (".tar.gz", ".tar.xz", ".tar.bz2"):
            fname = f"{repo_name}-{version}{ext}"
            fpath = os.path.join(SOURCES_DIR, fname)
            if os.path.isfile(fpath):
                releases.append({
                    "tag": tag,
                    "version": version,
                    "filename": fname,
                    "url": f"{SOURCES_URL}/{fname}",
                    "size_fmt": _format_size(os.path.getsize(fpath)),
                })
                break
    return releases

# ── Ostatnie commity – JEDNO wywołanie git log zamiast N× `git log -1 -- path` ──
def _last_commit_map(repo_path, branch):
    """Zwraca mapę 'ścieżka -> uniksowy timestamp' ostatniego commita.

    Dla katalogów z setkami plików stare podejście (osobne `git log -1 -- path`
    dla każdego pliku) uruchamiało setki procesów git i trwało sekundy.
    Ten wariant robi to w JEDNYM przebiegu: `git log --name-only` wypisuje
    commity od najnowszego; pierwsze pojawienie się ścieżki = jej ostatni commit.
    """
    out = _run_git(repo_path, ["--no-pager", "log", "--pretty=format:\x01%ct",
                               "--name-only", branch])
    last = {}
    cur = 0
    for line in out.split("\n"):
        if line.startswith("\x01"):
            try:
                cur = int(line[1:].strip())
            except ValueError:
                cur = 0
        elif line.strip():
            p = line.strip()
            if p not in last:
                last[p] = cur
    return last

def _rel_time(ts):
    """Czas względny po polsku (np. '3 godz. temu')."""
    if not ts:
        return ""
    d = max(0, time.time() - ts)
    if d < 60:
        return "przed chwilą"
    if d < 3600:
        return f"{int(d // 60)} min temu"
    if d < 86400:
        return f"{int(d // 3600)} godz. temu"
    if d < 86400 * 30:
        return f"{int(d // 86400)} dni temu"
    if d < 86400 * 365:
        return f"{int(d // (86400 * 30))} mies. temu"
    return f"{int(d // (86400 * 365))} lat temu"

def _last_commit_for(item, commit_map):
    """Ostatni commit pliku/katalogu z gotowej mapy (0 = brak danych)."""
    if item["type"] == "dir":
        prefix = item["path"] + "/"
        return max((t for p, t in commit_map.items() if p.startswith(prefix)),
                   default=0)
    return commit_map.get(item["path"], 0)

# ── Routes ──
@git_bp.route("/")
def index():
    counts = {}
    commit_counts = {}
    db = get_db()
    username = session.get("username")
    repos = _get_repos(for_user=username)
    for repo in repos:
        counts[repo["name"]] = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=? AND status='open'", (repo["name"],)).fetchone()[0]
        c = _run_git(repo["path"], ["rev-list", "--count", "HEAD"]).strip()
        commit_counts[repo["name"]] = c if c and c.isdigit() else "0"
    db.close()
    total_commits = sum(int(c) for c in commit_counts.values() if str(c).isdigit())
    total_size = sum(r.get("size", 0) for r in repos)
    public_count = sum(1 for r in repos if r.get("visible"))
    private_count = len(repos) - public_count
    for r in repos:
        r["commits"] = commit_counts.get(r["name"], "0")
        r["open_issues"] = counts.get(r["name"], 0)
        r["size_fmt"] = _format_size(r.get("size", 0))

    # Activity feed: ostatnie 15 commitów ze wszystkich repo
    activity = []
    for r in _get_repos(for_user=username):
        log = _run_git(r["path"], ["--no-pager", "log", "-n", "5",
                                     "--pretty=format:%h|%an|%ar|%s", "--all"])
        for line in log.splitlines()[:5]:
            p = line.split("|", 3)
            if len(p) == 4:
                activity.append({"repo": r["name"], "hash": p[0], "author": p[1],
                                 "date": p[2], "msg": p[3][:80]})
    activity.sort(key=lambda x: x["date"], reverse=False)
    activity = activity[-20:]

    return render_template("git/index.html", repos=repos, issue_counts=counts,
                           repo_count=len(repos), total_commits=total_commits,
                           is_admin=session.get("is_admin"),
                           total_size=_format_size(total_size),
                           public_count=public_count, private_count=private_count,
                           activity=activity)

@git_bp.route("/help")
def help_page():
    return render_template("git/help.html")

@git_bp.route("/<repo_name>")
@git_bp.route("/<repo_name>/tree/<path:tree_path>")
def view_repo(repo_name, tree_path=""):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")

    branch = request.args.get("branch") or _get_branch(repo_path)
    tree_ref = f"{branch}:{tree_path}" if tree_path else branch
    tree_out = _run_git(repo_path, ["ls-tree", "-l", tree_ref])

    items = []
    if tree_out:
        for line in tree_out.splitlines():
            parts = line.split(maxsplit=4)
            if len(parts) >= 5:
                obj_type = parts[1]
                obj_size = parts[3] if parts[3] != "-" else ""
                name = parts[4].split("\t")[-1] if "\t" in parts[4] else parts[4]
                full_path = f"{tree_path}/{name}".strip("/")
                items.append({"name": name, "type": "dir" if obj_type == "tree" else "file",
                              "path": full_path, "size": obj_size})
        # Ostatnie commity – JEDNO wywołanie git log zamiast osobnego
        # `git log -1` dla każdego pliku (setki procesów git na duże katalogi).
        commit_map = _last_commit_map(repo_path, branch)
        for item in items:
            ts = _last_commit_for(item, commit_map)
            item["ts"] = ts
            item["last_commit"] = _rel_time(ts)
    items.sort(key=lambda x: (x["type"] != "dir", x["name"].lower()))

    dir_count = sum(1 for i in items if i["type"] == "dir")
    file_count = len(items) - dir_count

    # Paginacja commitów
    page = request.args.get("page", 1, type=int)
    per_page = 15
    total_commits = int(_run_git(repo_path, ["rev-list", "--count", branch]).strip() or 0)
    skip = (page - 1) * per_page
    log = _run_git(repo_path, ["log", "-n", str(per_page), "--skip", str(skip),
                               "--pretty=format:%h|%an|%ar|%s", branch])
    commits = []
    for line in log.splitlines():
        p = line.split("|", 3)
        if len(p) == 4:
            commits.append({"hash": p[0], "author": p[1], "date": p[2], "msg": p[3]})
    total_pages = max(1, (total_commits + per_page - 1) // per_page)

    crumbs = []
    if tree_path:
        acc = ""
        for part in tree_path.split("/"):
            acc = f"{acc}/{part}".strip("/")
            crumbs.append({"name": part, "path": acc})

    # Statystyki repo
    branch_count = len([l for l in _run_git(repo_path, ["branch"]).split("\n") if l.strip()])
    contrib_count = len(_run_git(repo_path, ["shortlog", "-s", "--all"]).splitlines())

    can = _can_modify(repo_name)
    db = get_db()
    issue_count = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=?", (repo_name,)).fetchone()[0]
    db.close()

    # Release'y – tagi z tarballem w /sources/
    releases = _get_releases(repo_name, repo_path)

    # README – wybór języka: README.md (PL) / README-EN.md (EN)
    # Priorytet: ?readme=pl|en  →  język interfejsu (cookie/Accept-Language).
    # Gdy w repo jest tylko jeden wariant, pokazujemy go (i brak przełącznika).
    readme_html = ""
    readme_lang = ""
    readme_has = {"pl": False, "en": False}
    if not tree_path:
        _readme_names = {
            "pl": ("README.md", "README.pl.md", "README-PL.md",
                   "Readme.md", "readme.md", "README"),
            "en": ("README-EN.md", "README.en.md", "README-en.md",
                   "README.EN.md", "README-en_US.md"),
        }

        def _has_readme(spec):
            return bool(_run_git(repo_path, ["cat-file", "-t", spec]).strip())

        for _lg, _names in _readme_names.items():
            readme_has[_lg] = any(_has_readme(f"{branch}:{n}") for n in _names)

        _want = (request.args.get("readme") or "").strip().lower()
        if _want not in ("pl", "en"):
            try:
                from i18n import get_lang
                _want = "en" if get_lang() == "en" else "pl"
            except Exception:
                _want = "pl"

        for _lg in (_want, "en" if _want == "pl" else "pl"):
            if not readme_has.get(_lg):
                continue
            for _n in _readme_names[_lg]:
                rcontent = _run_git(repo_path, ["show", f"{branch}:{_n}"])
                if rcontent:
                    from markdown_render import render_markdown
                    readme_html = (
                        '<div class="markdown-body">'
                        + render_markdown(rcontent, escape=True,
                                          plugins=['strikethrough', 'task_lists', 'url'])
                        + '</div>'
                    )
                    readme_lang = _lg
                    break
            if readme_html:
                break

    def _fmt_size(s):
        try:
            s = int(s)
            for u in ["B", "KB", "MB"]:
                if s < 1024: return f"{s} {u}" if u == "B" else f"{s/1024:.1f} {u}"
                s /= 1024
            return f"{s/1024:.1f} GB"
        except: return s

    return render_template("git/repo.html", repo_name=repo_name, items=items,
                           commits=commits, branch=branch, tree_path=tree_path,
                           breadcrumbs=crumbs, can_edit=can, issue_count=issue_count,
                           releases=releases, git_readonly=_git_readonly(), repo_readonly=_repo_readonly(repo_name),
                           readme_html=readme_html, readme_lang=readme_lang,
                           readme_has=readme_has,
                           page=page, total_pages=total_pages,
                           total_commits=total_commits, branch_count=branch_count,
                           contrib_count=contrib_count, fmt_size=_fmt_size,
                           dir_count=dir_count, file_count=file_count)

@git_bp.route("/<repo_name>/file")
def view_file(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    file_path = request.args.get("path", "")
    if not file_path:
        abort(404)

    branch = request.args.get("branch") or _get_branch(repo_path)
    is_markdown = file_path.lower().endswith((".md", ".markdown"))
    is_image = _is_image_path(file_path)
    content_html = ""
    line_count = 0
    content = ""

    if is_image:
        # Binarki/obrazy – bez podświetlania; podgląd <img> w szablonie
        pass
    else:
        content = _run_git(repo_path, ["show", f"{branch}:{file_path}"])
        line_count = len(content.splitlines()) if content else 0
        if is_markdown:
            from markdown_render import render_markdown
            content_html = render_markdown(content or "(pusty)", escape=True,
                                           plugins=['strikethrough', 'task_lists', 'url'])
            content_html = f'<div class="markdown-body">{content_html}</div>'
        else:
            from pygments import highlight
            from pygments.lexers import get_lexer_for_filename, TextLexer
            from pygments.formatters import HtmlFormatter
            try:
                lexer = get_lexer_for_filename(file_path)
            except Exception:
                lexer = TextLexer()
            content_html = highlight(content or "(pusty)", lexer, HtmlFormatter(style="monokai"))

    return render_template("git/file.html", repo_name=repo_name, file_path=file_path,
                           content_html=content_html, content_raw=content or "",
                           branch=branch, can_edit=_can_modify(repo_name),
                           git_readonly=_git_readonly(), repo_readonly=_repo_readonly(repo_name),
                           is_markdown=is_markdown, is_image=is_image, line_count=line_count)

@git_bp.route("/<repo_name>/raw")
def raw_file(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    file_path = request.args.get("path", "")
    if not file_path:
        abort(404)
    branch = request.args.get("branch") or _get_branch(repo_path)
    from flask import Response
    # Obrazy/binarki – oryginalne bajty + właściwy mimetype (git show bez text=True)
    try:
        r = subprocess.run(["git", f"--git-dir={repo_path}", "show", f"{branch}:{file_path}"],
                           capture_output=True, timeout=20)
        data = r.stdout if r.returncode == 0 else b""
    except Exception:
        data = b""
    mime = _file_mime(file_path)
    return Response(data, mimetype=mime)

@git_bp.route("/<repo_name>/edit", methods=["GET", "POST"])
def edit_file(repo_name):
    if not session.get("username"):
        return redirect("/login?next=" + request.url)
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    blocked = _readonly_blocked(repo_name)
    if blocked:
        return blocked
    file_path = request.args.get("path", "")
    if not file_path:
        abort(404)

    if request.method == "POST":
        content = request.form.get("content", "")
        msg = request.form.get("message", f"Edit {file_path}")
        branch = request.args.get("branch") or _get_branch(repo_path)

        # Write to temp, commit via git
        import tempfile
        tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".tmp", delete=False)
        tmp.write(content)
        tmp.close()

        env = {**os.environ, **_get_git_env(session["username"])}
        work_tree = tempfile.mkdtemp(prefix="git-edit-")
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."],
                       capture_output=True, text=True, timeout=30, env=env)
        dest = os.path.join(work_tree, file_path)
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        with open(dest, "w") as f:
            f.write(content)
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", file_path],
                       capture_output=True, text=True, timeout=10, env=env)
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", msg],
                       capture_output=True, text=True, timeout=10, env=env)
        # Push do bara (work-tree commit wymaga pushu przez główną gałąź)
        subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch],
                       capture_output=True, text=True, timeout=15, env=env)

        import shutil
        os.unlink(tmp.name)
        shutil.rmtree(work_tree, ignore_errors=True)

        flash(f"✅ Zapisano: {file_path}", "ok")
        return redirect(f"/git/{repo_name}/file?path={file_path}&branch={branch}")

    branch = request.args.get("branch") or _get_branch(repo_path)
    content = _run_git(repo_path, ["show", f"{branch}:{file_path}"])
    return render_template("git/edit.html", repo_name=repo_name, file_path=file_path,
                           content=content, branch=branch)

@git_bp.route("/<repo_name>/new", methods=["GET", "POST"])
def new_file(repo_name):
    if not session.get("username"):
        return redirect("/login?next=" + request.url)
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    blocked = _readonly_blocked(repo_name)
    if blocked:
        return blocked

    if request.method == "POST":
        file_path = request.form.get("path", "").strip()
        content = request.form.get("content", "")
        msg = request.form.get("message", f"Add {file_path}")
        branch = _get_branch(repo_path)

        if ".." in file_path.split("/") or file_path.startswith("/") or "\\" in file_path:
            flash("❌ Nieprawidłowa ścieżka", "error")
            return redirect(f"/git/{repo_name}/new")

        import tempfile, shutil
        env = {**os.environ, **_get_git_env(session["username"])}
        work_tree = tempfile.mkdtemp(prefix="git-new-")
        _prepare_worktree(repo_path, work_tree, branch)
        dest = os.path.join(work_tree, file_path)
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        with open(dest, "w") as f:
            f.write(content)
        _commit_and_push(repo_path, work_tree, branch, msg)
        shutil.rmtree(work_tree, ignore_errors=True)

        flash(f"✅ Dodano: {file_path}", "ok")
        return redirect(f"/git/{repo_name}")

    return render_template("git/new.html", repo_name=repo_name)

@git_bp.route("/<repo_name>/upload", methods=["GET", "POST"])
def upload_files(repo_name):
    """Upload plików (w tym obrazów) do repozytorium przez przeglądarkę."""
    if not session.get("username"):
        return redirect("/login?next=" + request.url)
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    blocked = _readonly_blocked(repo_name)
    if blocked:
        return blocked

    dir_path = (request.args.get("dir") or request.form.get("dir") or "").strip().strip("/")
    if dir_path:
        parts = dir_path.split("/")
        if any(p in ("", ".", "..") or "\\" in p for p in parts):
            flash("❌ Nieprawidłowy katalog docelowy", "error")
            return redirect(f"/git/{repo_name}")

    if request.method == "POST":
        files = request.files.getlist("files")
        files = [f for f in files if f and f.filename]
        user_msg = (request.form.get("message") or "").strip()
        back = f"/git/{repo_name}/upload" + (f"?dir={dir_path}" if dir_path else "")
        if not files:
            flash("❌ Wybierz co najmniej jeden plik", "error")
            return redirect(back)
        if len(files) > 25:
            flash("❌ Maksymalnie 25 plików na raz", "error")
            return redirect(back)

        branch = _get_branch(repo_path)
        import tempfile, shutil
        env = {**os.environ, **_get_git_env(session["username"])}
        work_tree = tempfile.mkdtemp(prefix="git-up-")
        _prepare_worktree(repo_path, work_tree, branch)

        base = os.path.join(work_tree, dir_path) if dir_path else work_tree
        os.makedirs(base, exist_ok=True)
        added, skipped = [], []
        for f in files:
            name = (f.filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
            if not name or name in (".", "..") or name == ".git":
                skipped.append((f.filename, "niedozwolona nazwa"))
                continue
            dest = os.path.join(base, name)
            if os.path.exists(dest):
                skipped.append((name, "plik już istnieje w repo"))
                continue
            try:
                f.save(dest)
                size = os.path.getsize(dest)
                if size == 0:
                    os.remove(dest)
                    skipped.append((name, "pusty plik"))
                    continue
                if size > 50 * 1024 * 1024:
                    os.remove(dest)
                    skipped.append((name, "plik większy niż 50 MB"))
                    continue
                added.append(name)
            except Exception as e:
                skipped.append((name, f"błąd zapisu: {e}"))

        if added:
            if user_msg:
                msg = user_msg
            else:
                msg = f"Add {len(added)} file(s): " + ", ".join(added[:6]) + ("…" if len(added) > 6 else "")
            _commit_and_push(repo_path, work_tree, branch, msg)
            flash(f"✅ Wgrano {len(added)} plik(ów): " + ", ".join(added), "ok")
        if skipped:
            flash("⚠ Pominięto: " + "; ".join(f"{n} ({why})" for n, why in skipped), "error")
        shutil.rmtree(work_tree, ignore_errors=True)
        dest_dir = f"/git/{repo_name}/tree/{dir_path}" if dir_path else f"/git/{repo_name}"
        return redirect(dest_dir)

    return render_template("git/upload.html", repo_name=repo_name, dir_path=dir_path)

@git_bp.route("/<repo_name>/delete", methods=["POST"])
def delete_item(repo_name):
    if not session.get("username"):
        return redirect("/login")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    blocked = _readonly_blocked(repo_name)
    if blocked:
        return blocked
    file_path = request.form.get("path", "")
    if not file_path:
        abort(400)

    branch = _get_branch(repo_path)
    import tempfile
    env = {**os.environ, **_get_git_env(session["username"])}
    work_tree = tempfile.mkdtemp(prefix="git-del-")
    subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."],
                   capture_output=True, text=True, timeout=30, env=env)
    subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "rm", "-rf", file_path],
                   capture_output=True, text=True, timeout=10, env=env)
    subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", f"Delete {file_path}"],
                   capture_output=True, text=True, timeout=10, env=env)
    subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch],
                   capture_output=True, text=True, timeout=15, env=env)
    import shutil
    shutil.rmtree(work_tree, ignore_errors=True)
    flash(f"Usunięto: {file_path}", "ok")
    return redirect(f"/git/{repo_name}")

@git_bp.route("/<repo_name>/rename", methods=["GET", "POST"])
def rename_item(repo_name):
    """Zmiana nazwy pliku lub katalogu (git mv + commit + push)."""
    if not session.get("username"):
        return redirect("/login?next=" + request.url)
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    blocked = _readonly_blocked(repo_name)
    if blocked:
        return blocked

    old_path = (request.args.get("path") or request.form.get("old_path", "")).strip().strip("/")
    if not old_path or ".." in old_path.split("/") or old_path.startswith("/"):
        abort(400)

    if request.method == "POST":
        new_path = request.form.get("new_path", "").strip().strip("/")
        if not new_path or new_path == old_path:
            flash("❌ Podaj nową nazwę (inną niż obecna)", "error")
            return redirect(f"/git/{repo_name}/rename?path={old_path}")
        if ".." in new_path.split("/") or new_path.startswith("/"):
            flash("❌ Nieprawidłowa ścieżka docelowa", "error")
            return redirect(f"/git/{repo_name}/rename?path={old_path}")

        branch = _get_branch(repo_path)
        import tempfile, shutil
        env = {**os.environ, **_get_git_env(session["username"])}
        work_tree = tempfile.mkdtemp(prefix="git-mv-")
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."],
                       capture_output=True, text=True, timeout=30, env=env)
        src = os.path.join(work_tree, old_path)
        dst = os.path.join(work_tree, new_path)
        if not os.path.exists(src):
            flash("❌ Nie znaleziono: " + old_path, "error")
            shutil.rmtree(work_tree, ignore_errors=True)
            return redirect(f"/git/{repo_name}")
        if os.path.exists(dst):
            flash("❌ Już istnieje: " + new_path, "error")
            shutil.rmtree(work_tree, ignore_errors=True)
            return redirect(f"/git/{repo_name}/rename?path={old_path}")
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        os.rename(src, dst)
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", "-A"],
                       capture_output=True, text=True, timeout=10, env=env)
        subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", f"Rename {old_path} -> {new_path}"],
                       capture_output=True, text=True, timeout=10, env=env)
        subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch],
                       capture_output=True, text=True, timeout=15, env=env)
        shutil.rmtree(work_tree, ignore_errors=True)
        flash(f"✅ Zmieniono nazwę: {old_path}{new_path}", "ok")
        return redirect(f"/git/{repo_name}")

    branch = request.args.get("branch") or _get_branch(repo_path)
    return render_template("git/rename.html", repo_name=repo_name, old_path=old_path, branch=branch)

@git_bp.route("/<repo_name>/branches")
def view_branches(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    out = _run_git(repo_path, ["branch", "-a"])
    branches = []
    for line in out.splitlines():
        name = line.strip().lstrip("* ")
        is_remote = name.startswith("remotes/")
        if is_remote:
            name = name.replace("remotes/origin/", "")
        if "HEAD" in name or not name:
            continue
        if name not in [b["name"] for b in branches]:
            branches.append({"name": name, "is_default": name in ("main", "master"), "is_remote": is_remote})
    default_branch = _get_branch(repo_path)
    return render_template("git/branches.html", repo_name=repo_name, branches=branches,
                           default_branch=default_branch)

@git_bp.route("/<repo_name>/branches/set-default", methods=["POST"])
def set_default_branch(repo_name):
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect(f"/git/{repo_name}")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    branch = request.form.get("branch", "")
    if not branch:
        abort(400)
    _run_git(repo_path, ["symbolic-ref", "HEAD", f"refs/heads/{branch}"])
    flash(f"✅ Domyślna gałąź: {branch}", "ok")
    return redirect(f"/git/{repo_name}/branches")

@git_bp.route("/<repo_name>/branches/delete", methods=["POST"])
def delete_branch(repo_name):
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect(f"/git/{repo_name}")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    branch = request.form.get("branch", "")
    if not branch:
        abort(400)
    default = _get_branch(repo_path)
    if branch == default:
        flash("❌ Nie można usunąć domyślnej gałęzi", "error")
        return redirect(f"/git/{repo_name}/branches")
    _run_git(repo_path, ["branch", "-D", branch])
    _run_git(repo_path, ["push", "origin", "--delete", branch])
    flash(f"Usunięto gałąź: {branch}", "ok")
    return redirect(f"/git/{repo_name}/branches")

@git_bp.route("/search")
def global_search():
    query = request.args.get("q", "").strip()
    if len(query) < 2:
        return redirect("/git")
    results = []
    # Szukaj w kodzie (git grep na bare repo)
    for repo in _get_repos(for_user=session.get("username")):
        git_dir = repo["path"]
        # Sprawdź czy repo ma commity
        if not _run_git(git_dir, ["rev-parse", "HEAD"], timeout=5).strip():
            continue
        out = _run_git(git_dir, ["--no-pager", "grep", "-n", "-i", "-I", "--cached", query, "HEAD"], timeout=30)
        if not out:
            # Fallback: spróbuj bez --cached
            out = _run_git(git_dir, ["--no-pager", "grep", "-n", "-i", "-I", query, "HEAD"], timeout=30)
        for line in out.splitlines():
            line = line.strip()
            # Remove HEAD: prefix
            if line.startswith("HEAD:"):
                line = line[len("HEAD:"):]
            if ":" in line:
                parts = line.split(":", 2)
                if len(parts) >= 2:
                    results.append({
                        "type": "code", "repo": repo["name"], "file": parts[0],
                        "line": parts[1] if len(parts) > 1 else "",
                        "text": parts[2][:200] if len(parts) > 2 else "",
                    })
    # Szukaj w pakietach (repo)
    pkgs = _search_packages(query)
    return render_template("git/search.html", query=query, results=results[:100], packages=pkgs, total=len(results) + len(pkgs))

# ── Issues ──
@git_bp.route("/<repo_name>/issues")
def issues(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    db = get_db()
    status = request.args.get("status", "open")
    rows = db.execute("SELECT * FROM issues WHERE repo_name=? AND status=? ORDER BY created_at DESC",
                      (repo_name, status)).fetchall()
    db.close()
    return render_template("git/issues.html", repo_name=repo_name, issues=rows, status=status)

@git_bp.route("/<repo_name>/issues/new", methods=["GET", "POST"])
def new_issue(repo_name):
    if not session.get("username"):
        return redirect("/login")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    if request.method == "POST":
        title = request.form.get("title", "").strip()
        desc = request.form.get("description", "").strip()
        if not title:
            flash("Tytuł wymagany", "error")
        else:
            db = get_db()
            db.execute("INSERT INTO issues (repo_name, title, description, author) VALUES (?,?,?,?)",
                       (repo_name, title, desc, session["username"]))
            db.commit()
            db.close()
            flash("Issue utworzony", "ok")
            return redirect(f"/git/{repo_name}/issues")
    return render_template("git/issue_new.html", repo_name=repo_name)

@git_bp.route("/<repo_name>/issues/<int:issue_id>")
def view_issue(repo_name, issue_id):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    db = get_db()
    issue = db.execute("SELECT * FROM issues WHERE id=? AND repo_name=?", (issue_id, repo_name)).fetchone()
    if not issue:
        abort(404)
    replies = db.execute("SELECT * FROM issue_replies WHERE issue_id=? ORDER BY created_at", (issue_id,)).fetchall()
    db.close()
    return render_template("git/issue.html", repo_name=repo_name, issue=issue, replies=replies)

@git_bp.route("/<repo_name>/issues/<int:issue_id>/reply", methods=["POST"])
def reply_issue(repo_name, issue_id):
    if not session.get("username"):
        return redirect("/login")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    content = request.form.get("content", "").strip()
    if content:
        db = get_db()
        db.execute("INSERT INTO issue_replies (issue_id, content, author) VALUES (?,?,?)",
                   (issue_id, content, session["username"]))
        db.commit()
        db.close()
    return redirect(f"/git/{repo_name}/issues/{issue_id}")

@git_bp.route("/<repo_name>/issues/<int:issue_id>/toggle")
def toggle_issue(repo_name, issue_id):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    db = get_db()
    issue = db.execute("SELECT * FROM issues WHERE id=?", (issue_id,)).fetchone()
    if issue:
        new_status = "closed" if issue["status"] == "open" else "open"
        db.execute("UPDATE issues SET status=? WHERE id=?", (new_status, issue_id))
        db.commit()
    db.close()
    return redirect(f"/git/{repo_name}/issues")

# ── Commit detail + diff ──
@git_bp.route("/<repo_name>/commit/<commit_hash>")
def view_commit(repo_name, commit_hash):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    import re as _re
    # Pobierz diff i statystyki osobno
    diff = _run_git(repo_path, ["--no-pager", "show", "--format=", "-p", commit_hash])
    if not diff:
        diff = _run_git(repo_path, ["--no-pager", "show", "--format=fuller", "-p", commit_hash])
    if not diff:
        flash("Commit nie istnieje", "error")
        return redirect(f"/git/{repo_name}")
    # Statystyki z git diff --stat
    stat_out = _run_git(repo_path, ["--no-pager", "diff", "--stat", f"{commit_hash}~1", commit_hash])
    if not stat_out:
        stat_out = _run_git(repo_path, ["--no-pager", "show", "--stat", "--format=", commit_hash])
    files_changed = 0
    insertions = 0
    deletions = 0
    stat_line = ""
    if stat_out:
        for line in stat_out.splitlines():
            line_s = line.strip()
            if "files changed" in line_s and "insertion" in line_s:
                stat_line = line_s
                m = _re.search(r'(\d+)\s+files?\s+changed[,\s]*(\d+)\s+insertion[^\d]*(\d+)\s+deletion', line_s)
                if m:
                    files_changed, insertions, deletions = int(m.group(1)), int(m.group(2)), int(m.group(3))
                else:
                    m2 = _re.search(r'(\d+)\s+files?\s+changed[,\s]*(\d+)\s+insertion', line_s)
                    if m2:
                        files_changed, insertions = int(m2.group(1)), int(m2.group(2))

    # Parsuj diff na struktury z numerami linii
    hunks = []  # [{header, lines: [{type, old_ln, new_ln, text}]}]
    current_hunk = None
    old_ln, new_ln = 0, 0
    for line in diff.splitlines():
        if line.startswith("diff ") or line.startswith("index "):
            continue  # pomijamy nagłówki plików
        if line.startswith("---") or line.startswith("+++"):
            continue
        if line.startswith("@@"):
            # Parsuj @@ -old,count +new,count @@
            m = _re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
            if m:
                old_ln = int(m.group(1))
                new_ln = int(m.group(3))
            current_hunk = {"header": line, "lines": []}
            hunks.append(current_hunk)
        elif current_hunk is not None:
            if line.startswith("\\"):
                continue  # \ No newline at end of file
            if line.startswith("+"):
                current_hunk["lines"].append({"type": "add", "old": "", "new": str(new_ln), "text": line})
                new_ln += 1
            elif line.startswith("-"):
                current_hunk["lines"].append({"type": "del", "old": str(old_ln), "new": "", "text": line})
                old_ln += 1
            else:
                current_hunk["lines"].append({"type": "ctx", "old": str(old_ln), "new": str(new_ln), "text": line})
                old_ln += 1
                new_ln += 1

    return render_template("git/commit.html", repo_name=repo_name, commit_hash=commit_hash,
                           diff_raw=diff, hunks=hunks, stat_line=stat_line,
                           files_changed=files_changed, insertions=insertions, deletions=deletions)

# ── Contributors ──
@git_bp.route("/<repo_name>/contributors")
def contributors(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    out = _run_git(repo_path, ["shortlog", "-sne", "--all"])
    contribs = []
    for line in out.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("\t", 1)
        if len(parts) == 2:
            count = parts[0].strip()
            name_email = parts[1].strip()
            contribs.append({"count": int(count), "name": name_email})
    return render_template("git/contributors.html", repo_name=repo_name, contributors=contribs,
                           total=sum(c["count"] for c in contribs))

# ── Download archive ──
@git_bp.route("/<repo_name>/archive")
def download_archive(repo_name):
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")
    branch = request.args.get("branch") or _get_branch(repo_path)
    fmt = request.args.get("format", "tar.gz")
    import tempfile, time
    tmp = os.path.join(tempfile.gettempdir(), f"{repo_name}-{branch}.{fmt}")
    subprocess.run(
        ["git", f"--git-dir={repo_path}", "archive", f"--format={fmt}",
         f"--output={tmp}", f"--prefix={repo_name}-{branch}/", branch],
        capture_output=True, timeout=30
    )
    if os.path.exists(tmp):
        from flask import send_file
        return send_file(tmp, as_attachment=True, download_name=f"{repo_name}-{branch}.{fmt}",
                         mimetype="application/gzip" if fmt == "tar.gz" else "application/zip")
    flash("Nie udało się wygenerować archiwum", "error")
    return redirect(f"/git/{repo_name}")

# ── Create Release (tag + tarball) ──
@git_bp.route("/<repo_name>/release", methods=["GET", "POST"])
def create_release(repo_name):
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect(f"/git/{repo_name}")
    repo_path = _check_repo_access(repo_name)
    if repo_path is None:
        return redirect("/git")

    if request.method == "POST":
        version = request.form.get("version", "").strip().lstrip("v")
        if not version:
            flash("Wersja wymagana", "error")
            return redirect(f"/git/{repo_name}/release")

        branch = _get_branch(repo_path)
        tag_name = f"v{version}"

        # Sprawdź czy tag już istnieje
        existing = _run_git(repo_path, ["tag", "-l", tag_name]).strip()
        if existing:
            flash(f"Tag {tag_name} już istnieje", "error")
            return redirect(f"/git/{repo_name}/release")

        # Stwórz tag
        _run_git(repo_path, ["tag", "-a", tag_name, "-m", f"Release {tag_name}", branch])

        # Stwórz tarball z tego taga
        import io
        from flask import send_file
        tarball_name = f"{repo_name}-{version}.tar.gz"
        tarball_path = os.path.join("/tmp", tarball_name)
        subprocess.run(
            ["git", f"--git-dir={repo_path}", "archive", "--format=tar.gz",
             f"--output={tarball_path}", f"--prefix={repo_name}-{version}/", tag_name],
            capture_output=True, timeout=30
        )

        if os.path.exists(tarball_path):
            # Kopiuj do sources
            sources_dir = "/var/www/repo.paganlinux.eu/sources"
            os.makedirs(sources_dir, exist_ok=True)
            import shutil
            shutil.copy2(tarball_path, os.path.join(sources_dir, tarball_name))
            os.unlink(tarball_path)
            size = os.path.getsize(os.path.join(sources_dir, tarball_name))
            flash(f"✅ Release {tag_name} utworzony! Tarball: {tarball_name} ({round(size/1024)} KB)", "ok")
        else:
            flash(f"⚠ Tag {tag_name} utworzony, ale tarball się nie udał", "error")

        return redirect(f"/git/{repo_name}")

    # GET – formularz
    branch = _get_branch(repo_path)
    last_tag = _run_git(repo_path, ["describe", "--tags", "--abbrev=0"]).strip()
    commit_count = _run_git(repo_path, ["rev-list", "--count", "HEAD"]).strip() or "0"
    return render_template("git/release.html", repo_name=repo_name,
                           branch=branch, last_tag=last_tag, commit_count=commit_count)

# ── New repo (admin) ──
@git_bp.route("/new-repo", methods=["GET", "POST"])
def new_repo():
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect("/git")
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        desc = request.form.get("description", "").strip()
        if not name:
            flash("Nazwa wymagana", "error")
        else:
            repo_path = os.path.join(GIT_DIR, f"{name}.git")
            os.makedirs(repo_path, exist_ok=True)
            subprocess.run(["git", "--git-dir", repo_path, "init", "--bare", "--initial-branch=main"], capture_output=True, timeout=10)
            if desc:
                with open(os.path.join(repo_path, "description"), "w") as f:
                    f.write(desc)
            # Give admin access
            db = get_db()
            db.execute("INSERT OR IGNORE INTO repo_permissions (repo_name, username, role) VALUES (?,?,?)",
                       (name, session["username"], "owner"))
            db.commit()
            db.close()
            flash(f"✅ Repo {name} utworzone", "ok")
            return redirect(f"/git/{name}")
    return render_template("git/new_repo.html")

@git_bp.route("/<repo_name>/settings", methods=["GET", "POST"])
def repo_settings(repo_name):
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect(f"/git/{repo_name}")
    repo_path = os.path.join(GIT_DIR, f"{repo_name}.git")
    if not os.path.exists(repo_path):
        abort(404)

    if request.method == "POST":
        action = request.form.get("action", "")
        if action == "save_desc":
            desc = request.form.get("description", "").strip()
            with open(os.path.join(repo_path, "description"), "w") as f:
                f.write(desc or "Unnamed repository")
            flash("✅ Opis zapisany", "ok")
        elif action == "toggle_visibility":
            vis_file = os.path.join(repo_path, "git-daemon-export-ok")
            if os.path.exists(vis_file):
                os.remove(vis_file)
                flash("Repo ustawione jako PRYWATNE", "ok")
            else:
                with open(vis_file, "w") as f:
                    f.write("")
                flash("Repo ustawione jako PUBLICZNE", "ok")
        elif action == "toggle_readonly":
            ro_file = os.path.join(repo_path, "pag-readonly")
            if os.path.exists(ro_file):
                os.remove(ro_file)
                flash("Repo ustawione jako EDYTOWALNE (read-write)", "ok")
            else:
                with open(ro_file, "w") as f:
                    f.write("")
                flash("Repo ustawione jako TYLKO DO ODCZYTU", "ok")
        return redirect(f"/git/{repo_name}/settings")

    desc = ""
    desc_file = os.path.join(repo_path, "description")
    if os.path.exists(desc_file):
        with open(desc_file) as f:
            desc = f.read().strip()

    visible = os.path.exists(os.path.join(repo_path, "git-daemon-export-ok"))
    readonly = os.path.exists(os.path.join(repo_path, "pag-readonly"))
    count = _run_git(repo_path, ["rev-list", "--count", "HEAD"]).strip() or "0"
    size = _format_size(_get_dir_size(repo_path))
    branches = _run_git(repo_path, ["branch"]).strip()
    branch_count = len([l for l in branches.split("\n") if l]) if branches else 0
    last_commit = _run_git(repo_path, ["log", "-1", "--format=%ar"]).strip() or "nigdy"
    default_branch = _get_branch(repo_path)

    # Ostatnie tagi
    tags = _run_git(repo_path, ["tag", "--sort=-creatordate"]).strip()
    tag_list = [t for t in tags.split("\n")[:10] if t] if tags else []

    db = get_db()
    maintainers = db.execute("SELECT username, role FROM repo_permissions WHERE repo_name=?", (repo_name,)).fetchall()
    issue_count = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=?", (repo_name,)).fetchone()[0]
    db.close()

    return render_template("git/settings.html", repo_name=repo_name, description=desc,
                           commit_count=count, size=size, branch_count=branch_count,
                           last_commit=last_commit, default_branch=default_branch,
                           visible=visible, tags=tag_list, maintainers=maintainers,
                           issue_count=issue_count, readonly=readonly)

@git_bp.route("/<repo_name>/delete-repo", methods=["POST"])
def delete_repo(repo_name):
    if not session.get("is_admin"):
        flash("Tylko admin", "error")
        return redirect("/git")
    import shutil
    repo_path = os.path.join(GIT_DIR, f"{repo_name}.git")
    if os.path.exists(repo_path):
        shutil.rmtree(repo_path)
        db = get_db()
        db.execute("DELETE FROM repo_permissions WHERE repo_name=?", (repo_name,))
        db.execute("DELETE FROM issues WHERE repo_name=?", (repo_name,))
        db.commit()
        db.close()
        flash(f"Repo {repo_name} usunięte", "ok")
    return redirect("/git")