1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""
Per-project git config UI backed by config-options.json (all sections except color and alias).
"""
from __future__ import annotations
import json
from contextlib import suppress
from pathlib import Path
from typing import Any, Final
from urllib.parse import quote
import pygit2
from fastapi import HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from pygitweb import config
from pygitweb.config import EXPORT_OK, PROJECTROOT, STRICT_EXPORT
from pygitweb.formatting import esc_html
from pygitweb.git_helpers import open_repo
from pygitweb.projects import git_get_projects_list
from pygitweb.settings import GIT_CONFIG_LEVEL_LOCAL, GIT_CONFIG_LEVEL_WORKTREE
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
from pygitweb.validation import is_valid_project
_GIT_CONFIG_OPTIONS_PATH: Final[Path] = Path(__file__).resolve().parent / "data" / "config-options.json"
_EXCLUDED_CONFIG_SECTIONS: Final[frozenset[str]] = frozenset({"color", "alias"})
_ORDERED_GIT_CONFIG_OPTIONS: list[dict[str, Any]] = []
_OPTION_BY_KEY: dict[str, dict[str, Any]] = {}
def init_git_config_options_registry() -> None:
"""Load config-options.json and index allowed options (startup). Skips [color], [alias], and subsection '*'."""
global _ORDERED_GIT_CONFIG_OPTIONS, _OPTION_BY_KEY
raw = json.loads(_GIT_CONFIG_OPTIONS_PATH.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise RuntimeError("config-options.json must be a JSON array")
by_key: dict[str, dict[str, Any]] = {}
ordered: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
sec = item.get("section")
if not isinstance(sec, str) or sec in _EXCLUDED_CONFIG_SECTIONS:
continue
if item.get("subsection") == "*":
continue
try:
k = _git_config_key(item)
except (KeyError, TypeError, ValueError):
continue
if k in by_key:
continue
by_key[k] = item
ordered.append(item)
_ORDERED_GIT_CONFIG_OPTIONS = ordered
_OPTION_BY_KEY = by_key
def _git_config_key(item: dict[str, Any]) -> str:
section = str(item["section"])
subsection = item.get("subsection")
leaf = item.get("configName") or item.get("name")
if not isinstance(leaf, str) or not leaf:
raise ValueError("invalid config option entry: missing name")
if subsection and subsection != "*":
return f"{section}.{subsection}.{leaf}"
return f"{section}.{leaf}"
def _project_in_list(project: str) -> bool:
lst = git_get_projects_list(
filter_path="",
paranoid=STRICT_EXPORT,
export_ok=EXPORT_OK,
)
return any(p.get("path") == project for p in lst)
def _validate_project_or_404(project: str | None) -> str:
if not project:
raise HTTPException(status_code=400, detail="Project needed")
if not is_valid_project(
project,
PROJECTROOT,
EXPORT_OK,
STRICT_EXPORT,
_project_in_list,
):
raise HTTPException(status_code=404, detail="No such project")
return project
def _is_option_type(t: str) -> bool:
return t == "select"
def _is_textual_type(t: str) -> bool:
return t in ("text", "email") or not _is_option_type(t)
def _repo_config_local_worktree_values(repo: pygit2.Repository) -> dict[str, str]:
"""
Config values from LOCAL (5) and WORKTREE (6) only; last value wins per name.
Same iteration rules as settings._get_project_config_entries.
"""
cfg = repo.config
by_name: dict[str, str] = {}
for entry in cfg:
if entry.level not in (GIT_CONFIG_LEVEL_LOCAL, GIT_CONFIG_LEVEL_WORKTREE):
continue
by_name[entry.name] = entry.value
return by_name
def _field_dict(item: dict[str, Any], local_worktree: dict[str, str]) -> dict[str, Any]:
key = _git_config_key(item)
current = local_worktree.get(key, "")
opt_type = str(item.get("type") or "text")
return {
"git_key": key,
"display_name": str(item.get("displayName") or item.get("name") or key),
"description": str(item.get("description") or ""),
"control_kind": "select" if _is_option_type(opt_type) else "text",
"current": current,
"options": list(item["options"]) if isinstance(item.get("options"), list) else [],
}
def _group_options_by_section_and_subsection(
items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Same grouping as gitconfig-generator: section → subsection → options (order preserved)."""
section_order: list[str] = []
section_subsection_order: dict[str, list[str]] = {}
buckets: dict[str, dict[str, list[dict[str, Any]]]] = {}
for item in items:
sec = str(item.get("section") or "")
raw_sub = item.get("subsection")
sub = "" if raw_sub in (None, "") else str(raw_sub)
if sub == "*":
continue
if sec not in buckets:
buckets[sec] = {}
section_order.append(sec)
section_subsection_order[sec] = []
if sub not in buckets[sec]:
buckets[sec][sub] = []
section_subsection_order[sec].append(sub)
buckets[sec][sub].append(item)
out: list[dict[str, Any]] = []
for sec in section_order:
subs: list[dict[str, Any]] = []
for sub in section_subsection_order[sec]:
subs.append({"subsection": sub, "items": buckets[sec][sub]})
out.append({"section": sec, "subsections": subs})
return out
def project_git_config_page(project: str) -> HTMLResponse:
"""HTML page listing git config options;
Values shown are LOCAL + WORKTREE only (see settings._get_project_config_entries)."""
project = _validate_project_or_404(project)
if not _OPTION_BY_KEY:
raise HTTPException(status_code=500, detail="Git config registry not loaded")
repo = open_repo(project)
local_worktree = _repo_config_local_worktree_values(repo)
grouped = _group_options_by_section_and_subsection(_ORDERED_GIT_CONFIG_OPTIONS)
sections: list[dict[str, Any]] = []
for sec in grouped:
subsections_out: list[dict[str, Any]] = []
for sub in sec["subsections"]:
fields = [_field_dict(item, local_worktree) for item in sub["items"]]
subsections_out.append({"subsection": sub["subsection"], "fields": fields})
sections.append({"section": sec["section"], "subsections": subsections_out})
pre = PREAMBLE.render(
title=f"{esc_html(config.SITE_NAME)} - Git config — {esc_html(project)}",
site_name=config.SITE_NAME,
)
project_qp = quote(project, safe="/")
body = env.get_template("project_git_config.html").render(
project=project,
project_esc=esc_html(project),
summary_url=f"/project/{project_qp}",
set_config_url=f"/project/{project_qp}?a=set_config",
sections=sections,
)
return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
def _normalize_set_value(opt: dict[str, Any], raw: str) -> str:
opt_type = str(opt.get("type") or "text")
stripped = raw.strip()
if not stripped:
return ""
if _is_option_type(opt_type):
options = opt.get("options")
if not isinstance(options, list) or stripped not in [str(x) for x in options]:
raise HTTPException(
status_code=400,
detail="value must be one of the allowed options for this key",
)
return stripped
if not _is_textual_type(opt_type):
raise HTTPException(status_code=400, detail="unsupported config type for this key")
return raw
def project_git_config_set_post(project: str, key: str, value: str | None = None) -> JSONResponse:
"""Set or unset a single local config key validated against config-options.json."""
project = _validate_project_or_404(project)
if not _OPTION_BY_KEY:
raise HTTPException(status_code=500, detail="Git config registry not loaded")
lookup = (key or "").strip()
if not lookup:
raise HTTPException(status_code=400, detail="key is required")
opt = _OPTION_BY_KEY.get(lookup)
if opt is None:
raise HTTPException(status_code=400, detail="unknown or unsupported config key")
raw_val = value if value is not None else ""
normalized = _normalize_set_value(opt, raw_val)
repo = open_repo(project)
cfg = repo.config
try:
if not normalized:
with suppress(KeyError):
del cfg[lookup]
else:
cfg[lookup] = normalized
except (pygit2.GitError, TypeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"could not update config: {e}") from e
return JSONResponse({"ok": True})