esys-gitlog 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- esys_gitlog/__init__.py +3 -0
- esys_gitlog/__main__.py +6 -0
- esys_gitlog/app.py +215 -0
- esys_gitlog/cli.py +323 -0
- esys_gitlog/config.py +202 -0
- esys_gitlog/detection.py +145 -0
- esys_gitlog/display.py +82 -0
- esys_gitlog/git_adapter.py +370 -0
- esys_gitlog/identifiers.py +46 -0
- esys_gitlog/interpret.py +308 -0
- esys_gitlog/links.py +87 -0
- esys_gitlog/model.py +138 -0
- esys_gitlog/pager.py +106 -0
- esys_gitlog/pipeline.py +44 -0
- esys_gitlog/preferences.py +380 -0
- esys_gitlog/qa.py +196 -0
- esys_gitlog/render.py +372 -0
- esys_gitlog/report.py +117 -0
- esys_gitlog/tool_paths.py +62 -0
- esys_gitlog-0.1.0.dist-info/METADATA +77 -0
- esys_gitlog-0.1.0.dist-info/RECORD +25 -0
- esys_gitlog-0.1.0.dist-info/WHEEL +5 -0
- esys_gitlog-0.1.0.dist-info/entry_points.txt +2 -0
- esys_gitlog-0.1.0.dist-info/licenses/LICENSE.txt +18 -0
- esys_gitlog-0.1.0.dist-info/top_level.txt +1 -0
esys_gitlog/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Resolved analysis configuration for one run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from esys_gitlog.detection import detect_links
|
|
11
|
+
from esys_gitlog.preferences import (
|
|
12
|
+
Preferences,
|
|
13
|
+
empty_preferences,
|
|
14
|
+
fill_link_gaps,
|
|
15
|
+
load_layered_preferences,
|
|
16
|
+
merge_missing_links_into_user_file,
|
|
17
|
+
resolved_ref_id_width,
|
|
18
|
+
resolved_show_author,
|
|
19
|
+
resolved_show_body,
|
|
20
|
+
resolved_show_conventional,
|
|
21
|
+
resolved_show_date,
|
|
22
|
+
resolved_show_id,
|
|
23
|
+
resolved_type_width,
|
|
24
|
+
)
|
|
25
|
+
from esys_gitlog.tool_paths import profile_search_roots_high_to_low
|
|
26
|
+
|
|
27
|
+
QaMode = Literal["strict", "permissive"]
|
|
28
|
+
DisplayMode = Literal["subject-precedence", "trailer-precedence"]
|
|
29
|
+
|
|
30
|
+
DEFAULT_TYPES: tuple[str, ...] = (
|
|
31
|
+
"adr",
|
|
32
|
+
"build",
|
|
33
|
+
"chore",
|
|
34
|
+
"ci",
|
|
35
|
+
"docs",
|
|
36
|
+
"feat",
|
|
37
|
+
"fix",
|
|
38
|
+
"perf",
|
|
39
|
+
"refactor",
|
|
40
|
+
"revert",
|
|
41
|
+
"style",
|
|
42
|
+
"test",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ProfileRules:
|
|
48
|
+
"""Active profile rules for interpretation and QA."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
conventional: bool = True
|
|
52
|
+
conventional_locations: tuple[str, ...] = ("title",)
|
|
53
|
+
references: bool = True
|
|
54
|
+
reference_locations: tuple[str, ...] = ("refs",)
|
|
55
|
+
identifier_family: str = "jira-id"
|
|
56
|
+
signed_off_by: bool = True
|
|
57
|
+
types: tuple[str, ...] = DEFAULT_TYPES
|
|
58
|
+
reference_keys: tuple[str, ...] = ("Refs",)
|
|
59
|
+
# Populated from preference ``links`` at resolve time (not from profile JSON).
|
|
60
|
+
ref_links: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
commit_link: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AnalysisConfig:
|
|
66
|
+
"""Fully resolved settings for one analysis."""
|
|
67
|
+
|
|
68
|
+
profile: ProfileRules
|
|
69
|
+
qa: QaMode = "strict"
|
|
70
|
+
display: DisplayMode = "subject-precedence"
|
|
71
|
+
preferences: Preferences = field(default_factory=empty_preferences)
|
|
72
|
+
# Human history: fixed-width refs / minimum Type(Scope) columns.
|
|
73
|
+
ref_id_width: int = 10
|
|
74
|
+
type_width: int = 10
|
|
75
|
+
show_author: bool = False
|
|
76
|
+
show_date: bool = False
|
|
77
|
+
show_body: bool = False
|
|
78
|
+
show_id: bool = True
|
|
79
|
+
show_conventional: bool = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _default_profile() -> ProfileRules:
|
|
83
|
+
return ProfileRules(name="default")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _trailer_profile() -> ProfileRules:
|
|
87
|
+
return ProfileRules(
|
|
88
|
+
name="trailer",
|
|
89
|
+
conventional_locations=("trailer",),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
KNOWN_PROFILES: dict[str, ProfileRules] = {
|
|
94
|
+
"default": _default_profile(),
|
|
95
|
+
"trailer": _trailer_profile(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _profile_from_object(data: dict[str, Any], *, name: str = "custom") -> ProfileRules:
|
|
100
|
+
conventional_locations = data.get("conventionalLocations", ["title"])
|
|
101
|
+
reference_locations = data.get("referenceLocations", ["refs"])
|
|
102
|
+
types = data.get("types", list(DEFAULT_TYPES))
|
|
103
|
+
reference_keys = data.get("referenceKeys", ["Refs"])
|
|
104
|
+
# Legacy profile link keys are ignored (CADR-0012); prefer preference files.
|
|
105
|
+
return ProfileRules(
|
|
106
|
+
name=name,
|
|
107
|
+
conventional=bool(data.get("conventional", True)),
|
|
108
|
+
conventional_locations=tuple(conventional_locations),
|
|
109
|
+
references=bool(data.get("references", True)),
|
|
110
|
+
reference_locations=tuple(reference_locations),
|
|
111
|
+
identifier_family=str(data.get("identifierFamily", "jira-id")),
|
|
112
|
+
signed_off_by=bool(data.get("signedOffBy", True)),
|
|
113
|
+
types=tuple(types),
|
|
114
|
+
reference_keys=tuple(reference_keys),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_profile_file(path: Path) -> ProfileRules:
|
|
119
|
+
"""Load a custom profile object from a JSON file."""
|
|
120
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
121
|
+
if not isinstance(payload, dict):
|
|
122
|
+
raise ValueError("profile config must be a JSON object")
|
|
123
|
+
return _profile_from_object(payload, name=str(payload.get("name", "custom")))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_named_profile(
|
|
127
|
+
name: str,
|
|
128
|
+
*,
|
|
129
|
+
cwd: Path,
|
|
130
|
+
config_path: Path | None = None,
|
|
131
|
+
) -> ProfileRules:
|
|
132
|
+
"""Resolve built-in, ``--config`` file, or layered ``profiles/<name>.json``."""
|
|
133
|
+
if config_path is not None:
|
|
134
|
+
return load_profile_file(config_path)
|
|
135
|
+
if name in KNOWN_PROFILES:
|
|
136
|
+
return KNOWN_PROFILES[name]
|
|
137
|
+
for root in profile_search_roots_high_to_low(cwd):
|
|
138
|
+
path = root / "profiles" / f"{name}.json"
|
|
139
|
+
if path.is_file():
|
|
140
|
+
return load_profile_file(path)
|
|
141
|
+
raise ValueError(f"unknown profile: {name}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_config(
|
|
145
|
+
*,
|
|
146
|
+
profile: str | None = None,
|
|
147
|
+
qa: QaMode | None = None,
|
|
148
|
+
display: DisplayMode | None = None,
|
|
149
|
+
config_path: Path | None = None,
|
|
150
|
+
preferences_path: Path | None = None,
|
|
151
|
+
cwd: Path | None = None,
|
|
152
|
+
apply_detection: bool = True,
|
|
153
|
+
persist_detection: bool | None = None,
|
|
154
|
+
) -> AnalysisConfig:
|
|
155
|
+
"""Resolve preferences, named profile, and optional link detection."""
|
|
156
|
+
root = (cwd or Path.cwd()).resolve()
|
|
157
|
+
prefs = load_layered_preferences(root, preferences_path=preferences_path)
|
|
158
|
+
|
|
159
|
+
if apply_detection:
|
|
160
|
+
discovered = detect_links(root, detection=prefs.detection)
|
|
161
|
+
prefs = fill_link_gaps(prefs, discovered)
|
|
162
|
+
should_persist = (
|
|
163
|
+
persist_detection if persist_detection is not None else prefs.detection.resolved()[2]
|
|
164
|
+
)
|
|
165
|
+
if should_persist and (discovered.ref_links or discovered.commit_link is not None):
|
|
166
|
+
merge_missing_links_into_user_file(discovered=discovered)
|
|
167
|
+
|
|
168
|
+
profile_name = profile or prefs.defaults.profile or "default"
|
|
169
|
+
qa_mode: QaMode = qa or prefs.defaults.qa or "strict"
|
|
170
|
+
rules = resolve_named_profile(
|
|
171
|
+
profile_name,
|
|
172
|
+
cwd=root,
|
|
173
|
+
config_path=config_path,
|
|
174
|
+
)
|
|
175
|
+
rules = replace(
|
|
176
|
+
rules,
|
|
177
|
+
ref_links=dict(prefs.links.ref_links),
|
|
178
|
+
commit_link=prefs.links.commit_link,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if display is not None:
|
|
182
|
+
display_mode = display
|
|
183
|
+
elif prefs.defaults.display is not None:
|
|
184
|
+
display_mode = prefs.defaults.display
|
|
185
|
+
elif "trailer" in rules.conventional_locations and "title" not in rules.conventional_locations:
|
|
186
|
+
display_mode = "trailer-precedence"
|
|
187
|
+
else:
|
|
188
|
+
display_mode = "subject-precedence"
|
|
189
|
+
|
|
190
|
+
return AnalysisConfig(
|
|
191
|
+
profile=rules,
|
|
192
|
+
qa=qa_mode,
|
|
193
|
+
display=display_mode,
|
|
194
|
+
preferences=prefs,
|
|
195
|
+
ref_id_width=resolved_ref_id_width(prefs.defaults),
|
|
196
|
+
type_width=resolved_type_width(prefs.defaults),
|
|
197
|
+
show_author=resolved_show_author(prefs.defaults),
|
|
198
|
+
show_date=resolved_show_date(prefs.defaults),
|
|
199
|
+
show_body=resolved_show_body(prefs.defaults),
|
|
200
|
+
show_id=resolved_show_id(prefs.defaults),
|
|
201
|
+
show_conventional=resolved_show_conventional(prefs.defaults),
|
|
202
|
+
)
|
esys_gitlog/detection.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Optional local CLI / git-remote link-base detection (SADR-0010)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from esys_gitlog.links import detect_commit_link_from_remote, read_origin_url
|
|
12
|
+
from esys_gitlog.preferences import DetectionSettings, LinkPreferences
|
|
13
|
+
|
|
14
|
+
_JIRA_SERVER = re.compile(r"""(?mx)^\s*(?:server|endpoint|url)\s*[:=]\s*["']?(https?://[^\s"']+)""")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_text(argv: list[str]) -> str | None:
|
|
18
|
+
executable = shutil.which(argv[0])
|
|
19
|
+
if not executable:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
completed = subprocess.run(
|
|
23
|
+
[executable, *argv[1:]],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
check=False,
|
|
26
|
+
text=True,
|
|
27
|
+
timeout=5,
|
|
28
|
+
)
|
|
29
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
30
|
+
return None
|
|
31
|
+
if completed.returncode != 0:
|
|
32
|
+
return None
|
|
33
|
+
text = (completed.stdout or "").strip()
|
|
34
|
+
return text or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _browse_template(base: str) -> str:
|
|
38
|
+
return base.rstrip("/") + "/browse/{id}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_jira_ref_link() -> str | None:
|
|
42
|
+
"""Return a ``jira-id`` browse template from local jira CLI config if possible."""
|
|
43
|
+
host = _run_text(["jira", "me", "--plain"]) # may fail; try config files next
|
|
44
|
+
# Prefer config files — avoid network. `jira me` can hit the API.
|
|
45
|
+
for candidate in _jira_config_candidates():
|
|
46
|
+
if not candidate.is_file():
|
|
47
|
+
continue
|
|
48
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
49
|
+
match = _JIRA_SERVER.search(text)
|
|
50
|
+
if match:
|
|
51
|
+
return _browse_template(match.group(1))
|
|
52
|
+
# Some jira CLIs expose server without network:
|
|
53
|
+
for argv in (
|
|
54
|
+
["jira", "config", "get", "server"],
|
|
55
|
+
["acli", "jira", "config", "get", "site"],
|
|
56
|
+
):
|
|
57
|
+
value = _run_text(list(argv))
|
|
58
|
+
if value and value.startswith("http"):
|
|
59
|
+
return _browse_template(value)
|
|
60
|
+
_ = host # unused; kept for future plain local probes
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _jira_config_candidates() -> list[Path]:
|
|
65
|
+
home = Path.home()
|
|
66
|
+
return [
|
|
67
|
+
home / ".config" / ".jira" / ".config.yml",
|
|
68
|
+
home / ".jira" / ".config.yml",
|
|
69
|
+
home / ".config" / "jira" / "config.yml",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_github_commit_link(*, cwd: Path | None = None) -> str | None:
|
|
74
|
+
"""Prefer ``gh`` host config; fall back to origin remote when appropriate."""
|
|
75
|
+
host = _run_text(["gh", "config", "get", "host"])
|
|
76
|
+
if host and "github" in host:
|
|
77
|
+
# Repo-specific commit URL still needs remote path; leave to git remote.
|
|
78
|
+
return None
|
|
79
|
+
# ``gh repo view --json url`` needs network; skip.
|
|
80
|
+
_ = cwd
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect_gitlab_commit_link() -> str | None:
|
|
85
|
+
host = _run_text(["glab", "config", "get", "host"])
|
|
86
|
+
if host and host.startswith("http"):
|
|
87
|
+
# Project path still needs git remote; host alone is not a commit template.
|
|
88
|
+
return None
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def detect_links(
|
|
93
|
+
cwd: Path,
|
|
94
|
+
*,
|
|
95
|
+
detection: DetectionSettings | None = None,
|
|
96
|
+
git_executable: str = "git",
|
|
97
|
+
) -> LinkPreferences:
|
|
98
|
+
"""Discover link templates for unset preference gaps (best-effort, local)."""
|
|
99
|
+
settings = detection or DetectionSettings()
|
|
100
|
+
cli_on, git_on, _persist = settings.resolved()
|
|
101
|
+
ref_links: dict[str, str] = {}
|
|
102
|
+
commit_link: str | None = None
|
|
103
|
+
|
|
104
|
+
if cli_on:
|
|
105
|
+
jira = detect_jira_ref_link()
|
|
106
|
+
if jira:
|
|
107
|
+
ref_links["jira-id"] = jira
|
|
108
|
+
# gh/glab currently contribute via git remote for commit URLs.
|
|
109
|
+
detect_github_commit_link(cwd=cwd)
|
|
110
|
+
detect_gitlab_commit_link()
|
|
111
|
+
|
|
112
|
+
if git_on:
|
|
113
|
+
remote = read_origin_url(cwd, git_executable=git_executable)
|
|
114
|
+
if remote:
|
|
115
|
+
commit_link = detect_commit_link_from_remote(remote)
|
|
116
|
+
|
|
117
|
+
return LinkPreferences(ref_links=ref_links, commit_link=commit_link)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_gh_host_file() -> str | None:
|
|
121
|
+
"""Test helper: parse ``gh`` hosts.yml for a github.com entry."""
|
|
122
|
+
path = Path.home() / ".config" / "gh" / "hosts.yml"
|
|
123
|
+
if not path.is_file():
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
# Avoid requiring PyYAML; look for github.com key.
|
|
127
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
128
|
+
except OSError:
|
|
129
|
+
return None
|
|
130
|
+
if "github.com:" in text or "github.com" in text:
|
|
131
|
+
return "https://github.com"
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dump_detection_debug() -> str:
|
|
136
|
+
"""Return a short JSON summary of what would be detected (for CLI)."""
|
|
137
|
+
return json.dumps(
|
|
138
|
+
{
|
|
139
|
+
"jiraConfigCandidates": [str(p) for p in _jira_config_candidates()],
|
|
140
|
+
"ghOnPath": bool(shutil.which("gh")),
|
|
141
|
+
"glabOnPath": bool(shutil.which("glab")),
|
|
142
|
+
"jiraOnPath": bool(shutil.which("jira")),
|
|
143
|
+
},
|
|
144
|
+
indent=2,
|
|
145
|
+
)
|
esys_gitlog/display.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Display selection. Does not erase claims or diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from esys_gitlog.config import AnalysisConfig
|
|
8
|
+
from esys_gitlog.identifiers import matches_family, ref_identity_key
|
|
9
|
+
from esys_gitlog.interpret import title_pattern
|
|
10
|
+
from esys_gitlog.links import resolve_ref_url
|
|
11
|
+
from esys_gitlog.model import CommitResult, DisplayRef, DisplaySelection
|
|
12
|
+
|
|
13
|
+
_SUFFIX_REF = re.compile(r"(?:\s+|\[)(?P<token>[A-Za-z][A-Za-z0-9._-]*-\d+)\]?\s*$")
|
|
14
|
+
_PREFIX_REF = re.compile(r"^(?:\[)?(?P<token>[A-Za-z][A-Za-z0-9._-]*-\d+)(?:\])?(?::|\s+)\s*")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pick_single(
|
|
18
|
+
result: CommitResult,
|
|
19
|
+
field_name: str,
|
|
20
|
+
reason: str,
|
|
21
|
+
) -> str | None:
|
|
22
|
+
claims = result.claims_for(field_name)
|
|
23
|
+
subject = [claim for claim in claims if claim.source == "subject"]
|
|
24
|
+
trailer = [claim for claim in claims if claim.source == "trailer"]
|
|
25
|
+
if reason == "trailer-precedence":
|
|
26
|
+
if trailer:
|
|
27
|
+
return trailer[0].value
|
|
28
|
+
if subject:
|
|
29
|
+
return subject[0].value
|
|
30
|
+
return None
|
|
31
|
+
if subject:
|
|
32
|
+
return subject[0].value
|
|
33
|
+
if trailer:
|
|
34
|
+
return trailer[0].value
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cleaned_title(subject: str, config: AnalysisConfig) -> str:
|
|
39
|
+
line = subject.splitlines()[0] if subject else ""
|
|
40
|
+
rules = config.profile
|
|
41
|
+
if rules.conventional and "title" in rules.conventional_locations:
|
|
42
|
+
match = title_pattern(rules.types).fullmatch(line)
|
|
43
|
+
if match is not None:
|
|
44
|
+
return match.group("description")
|
|
45
|
+
if rules.references:
|
|
46
|
+
stripped = _PREFIX_REF.sub("", line, count=1)
|
|
47
|
+
stripped = _SUFFIX_REF.sub("", stripped)
|
|
48
|
+
return stripped.strip() or line
|
|
49
|
+
return line
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _display_refs(result: CommitResult, config: AnalysisConfig) -> tuple[DisplayRef, ...]:
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
refs: list[DisplayRef] = []
|
|
55
|
+
family = config.profile.identifier_family
|
|
56
|
+
for claim in result.claims_for("refs"):
|
|
57
|
+
key = ref_identity_key(claim.value)
|
|
58
|
+
if key in seen:
|
|
59
|
+
continue
|
|
60
|
+
seen.add(key)
|
|
61
|
+
claim_family = family if matches_family(claim.value, family) else family
|
|
62
|
+
url = resolve_ref_url(claim.value, family, config.profile)
|
|
63
|
+
refs.append(DisplayRef(id=claim.value, family=claim_family, url=url))
|
|
64
|
+
return tuple(refs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def select_display(
|
|
68
|
+
result: CommitResult,
|
|
69
|
+
config: AnalysisConfig,
|
|
70
|
+
*,
|
|
71
|
+
subject: str = "",
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Choose displayed values without inventing claims."""
|
|
74
|
+
reason = config.display
|
|
75
|
+
result.display = DisplaySelection(
|
|
76
|
+
reason=reason,
|
|
77
|
+
type=_pick_single(result, "type", reason),
|
|
78
|
+
scope=_pick_single(result, "scope", reason),
|
|
79
|
+
title=_cleaned_title(subject, config) or None,
|
|
80
|
+
breaking=len(result.claims_for("breaking")) > 0,
|
|
81
|
+
refs=_display_refs(result, config),
|
|
82
|
+
)
|