🔒 Repository is read-only – file editing is disabled.
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")