|
27 | 27 | CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41L9 16.17z"></path></svg>' |
28 | 28 |
|
29 | 29 |
|
30 | | -def get_git_info(file_path: str, add_authors: bool = True, default_author: str | None = None) -> dict[str, Any]: |
31 | | - """Retrieve git information including creation/modified dates and optional authors.""" |
| 30 | +def get_git_info( |
| 31 | + file_path: str, |
| 32 | + add_authors: bool = True, |
| 33 | + default_author: str | None = None, |
| 34 | + git_data: dict[str, dict[str, Any]] | None = None, |
| 35 | + repo_url: str | None = None, |
| 36 | +) -> dict[str, Any]: |
| 37 | + """Retrieve git information (dates + optional authors) from precomputed git data.""" |
32 | 38 | file_path = str(Path(file_path).resolve()) |
33 | 39 | git_info = { |
34 | 40 | "creation_date": DEFAULT_CREATION_DATE, |
35 | 41 | "last_modified_date": DEFAULT_MODIFIED_DATE, |
36 | 42 | } |
37 | 43 |
|
38 | | - try: |
39 | | - subprocess.check_output(["git", "rev-parse", "--is-inside-work-tree"], stderr=subprocess.DEVNULL) |
40 | | - creation_output = subprocess.check_output( |
41 | | - ["git", "log", "--reverse", "--pretty=format:%ai", file_path] |
42 | | - ).decode() |
43 | | - creation_date = creation_output.split("\n")[0] if creation_output else "" |
44 | | - last_modified_date = subprocess.check_output(["git", "log", "-1", "--pretty=format:%ai", file_path]).decode() |
45 | | - git_info.update( |
46 | | - { |
47 | | - "creation_date": creation_date or DEFAULT_CREATION_DATE, |
48 | | - "last_modified_date": last_modified_date or DEFAULT_MODIFIED_DATE, |
49 | | - } |
50 | | - ) |
| 44 | + if not git_data or file_path not in git_data: |
| 45 | + return git_info |
51 | 46 |
|
52 | | - if add_authors: |
53 | | - authors_info = get_github_usernames_from_file(file_path, default_user=default_author) |
54 | | - git_info["authors"] = sorted( |
55 | | - [(author, info["url"], info["changes"], info["avatar"]) for author, info in authors_info.items()], |
56 | | - key=lambda x: x[2], |
57 | | - reverse=True, |
58 | | - ) |
59 | | - except (subprocess.CalledProcessError, FileNotFoundError): |
60 | | - pass |
| 47 | + cached = git_data[file_path] |
| 48 | + git_info.update( |
| 49 | + { |
| 50 | + "creation_date": cached.get("creation_date", DEFAULT_CREATION_DATE), |
| 51 | + "last_modified_date": cached.get("last_modified_date", DEFAULT_MODIFIED_DATE), |
| 52 | + } |
| 53 | + ) |
| 54 | + |
| 55 | + if add_authors and cached.get("emails"): |
| 56 | + git_info["authors"] = sorted( |
| 57 | + [ |
| 58 | + ( |
| 59 | + author, |
| 60 | + info["url"], |
| 61 | + info["changes"], |
| 62 | + info["avatar"], |
| 63 | + ) |
| 64 | + for author, info in get_github_usernames_from_file( |
| 65 | + file_path, default_user=default_author, emails=cached["emails"], repo_url=repo_url |
| 66 | + ).items() |
| 67 | + ], |
| 68 | + key=lambda x: x[2], |
| 69 | + reverse=True, |
| 70 | + ) |
61 | 71 |
|
62 | 72 | return git_info |
63 | 73 |
|
@@ -104,6 +114,90 @@ def insert_content(soup: BeautifulSoup, content_to_insert) -> None: |
104 | 114 | md_typeset.append(content_to_insert) |
105 | 115 |
|
106 | 116 |
|
| 117 | +def build_git_map(file_paths: list[str] | list[Path]) -> tuple[str | None, dict[str, dict[str, Any]]]: |
| 118 | + """Build git metadata for provided files using a single git log pass.""" |
| 119 | + git_data: dict[str, dict[str, Any]] = {} |
| 120 | + repo_url: str | None = None |
| 121 | + |
| 122 | + if not file_paths: |
| 123 | + return repo_url, git_data |
| 124 | + |
| 125 | + try: |
| 126 | + repo_root = Path( |
| 127 | + subprocess.check_output(["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL).decode().strip() |
| 128 | + ) |
| 129 | + except subprocess.CalledProcessError: |
| 130 | + return repo_url, git_data |
| 131 | + |
| 132 | + try: |
| 133 | + github_repo_url = subprocess.check_output( |
| 134 | + ["git", "-C", str(repo_root), "config", "--get", "remote.origin.url"], stderr=subprocess.DEVNULL |
| 135 | + ).decode("utf-8") |
| 136 | + github_repo_url = github_repo_url.strip() |
| 137 | + if github_repo_url.endswith(".git"): |
| 138 | + github_repo_url = github_repo_url[:-4] |
| 139 | + if github_repo_url.startswith("git@"): |
| 140 | + github_repo_url = "https://" + github_repo_url[4:].replace(":", "/") |
| 141 | + repo_url = github_repo_url or None |
| 142 | + except subprocess.CalledProcessError: |
| 143 | + repo_url = None |
| 144 | + |
| 145 | + rel_paths = [] |
| 146 | + for fp in file_paths: |
| 147 | + path = Path(fp) |
| 148 | + if path.exists(): |
| 149 | + try: |
| 150 | + rel_paths.append(path.resolve().relative_to(repo_root)) |
| 151 | + except ValueError: |
| 152 | + continue |
| 153 | + if not rel_paths: |
| 154 | + return repo_url, git_data |
| 155 | + |
| 156 | + cmd = [ |
| 157 | + "git", |
| 158 | + "-C", |
| 159 | + str(repo_root), |
| 160 | + "log", |
| 161 | + "--name-only", |
| 162 | + "--pretty=format:%ad\t%ae", |
| 163 | + "--date=format:%Y-%m-%d %H:%M:%S %z", |
| 164 | + "--", |
| 165 | + *[str(p) for p in rel_paths], |
| 166 | + ] |
| 167 | + |
| 168 | + try: |
| 169 | + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode().splitlines() |
| 170 | + except subprocess.CalledProcessError: |
| 171 | + return repo_url, git_data |
| 172 | + |
| 173 | + current_date = None |
| 174 | + current_email = None |
| 175 | + for line in output: |
| 176 | + if not line.strip(): |
| 177 | + continue |
| 178 | + parts = line.split("\t") |
| 179 | + if len(parts) == 2: |
| 180 | + current_date, current_email = parts |
| 181 | + continue |
| 182 | + |
| 183 | + if current_date and current_email: |
| 184 | + abs_path = (repo_root / line.strip()).resolve() |
| 185 | + key = str(abs_path) |
| 186 | + entry = git_data.setdefault( |
| 187 | + key, |
| 188 | + { |
| 189 | + "creation_date": current_date, |
| 190 | + "last_modified_date": current_date, |
| 191 | + "emails": {}, |
| 192 | + }, |
| 193 | + ) |
| 194 | + entry.setdefault("last_modified_date", current_date) |
| 195 | + entry["creation_date"] = current_date |
| 196 | + entry["emails"][current_email] = entry["emails"].get(current_email, 0) + 1 |
| 197 | + |
| 198 | + return repo_url, git_data |
| 199 | + |
| 200 | + |
107 | 201 | def get_css() -> str: |
108 | 202 | """CSS for git info, share buttons, and copy button.""" |
109 | 203 | return """ |
@@ -212,6 +306,8 @@ def process_html( |
212 | 306 | page_url: str, |
213 | 307 | title: str, |
214 | 308 | src_path: str | None = None, |
| 309 | + git_data: dict[str, dict[str, Any]] | None = None, |
| 310 | + repo_url: str | None = None, |
215 | 311 | default_image: str | None = None, |
216 | 312 | default_author: str | None = None, |
217 | 313 | keywords: str | None = None, |
@@ -389,15 +485,17 @@ def process_html( |
389 | 485 | """ |
390 | 486 | soup.body.append(script) |
391 | 487 |
|
392 | | - # Initialize git info with defaults |
| 488 | + # Initialize git info with defaults and only call git when needed (authors or JSON-LD) |
393 | 489 | git_info = { |
394 | 490 | "creation_date": DEFAULT_CREATION_DATE, |
395 | 491 | "last_modified_date": DEFAULT_MODIFIED_DATE, |
396 | 492 | } |
| 493 | + needs_git = (add_authors or add_json_ld) and src_path |
397 | 494 |
|
398 | | - # Add git information if source path available |
399 | | - if src_path: |
400 | | - git_info = get_git_info(src_path, add_authors=add_authors, default_author=default_author) |
| 495 | + if needs_git: |
| 496 | + git_info = get_git_info( |
| 497 | + src_path, add_authors=add_authors, default_author=default_author, git_data=git_data, repo_url=repo_url |
| 498 | + ) |
401 | 499 |
|
402 | 500 | # Only render git footer if we have real git history (not placeholder defaults) |
403 | 501 | has_real_git_data = ( |
|
0 commit comments