Skip to content

Commit 00d9ebb

Browse files
Move DIRECTORY.md to its own section in pr_file_map.py (#15353)
* Move DIRECTORY.md to its own section in pr_file_map.py DIRECTORY.md is auto-generated and touched by nearly every open PR, so it dominated the 'possible merge conflicts' list and distracted maintainers. Pull it out into a dedicated section at the very bottom of the report that separates PRs whose only overlap is DIRECTORY.md (safe to 'accept both' in the GitHub UI) from PRs that also collide on real source files. Also render the Script path relative to the git root instead of an absolute machine-specific path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 624d207 commit 00d9ebb

1 file changed

Lines changed: 143 additions & 24 deletions

File tree

scripts/pr_file_map.py

Lines changed: 143 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,24 @@
55
Lists all open pull requests in the current directory's git repo (via `gh`)
66
and, for each file touched by any open PR, which PR number(s) touch it.
77
8-
Output is GitHub-flavored Markdown that includes this script's path, the
9-
current UTC datetime, and summary counts for open PRs, file touches, distinct
10-
files, and existing/missing files. It highlights files touched by more than one
11-
open PR first (the likely merge-conflict hot spots when landing PRs), then
12-
renders a sorted list of files that currently exist in the working directory,
13-
each with its modifying PR numbers, followed by a separate section for files
14-
referenced by open PRs but that do not exist in the working directory (e.g.
8+
Output is GitHub-flavored Markdown that includes this script's path (relative to
9+
the git root), the current UTC datetime, and summary counts for open PRs, file
10+
touches, distinct files, and existing/missing files. It highlights files touched
11+
by more than one open PR first (the likely merge-conflict hot spots when landing
12+
PRs), then renders a sorted list of files that currently exist in the working
13+
directory, each with its modifying PR numbers, followed by a separate section for
14+
files referenced by open PRs but that do not exist in the working directory (e.g.
1515
deleted, renamed, or on a branch not checked out locally).
1616
17+
`DIRECTORY.md` is treated specially and reported in its own section at the very
18+
bottom. It is auto-generated, so nearly every PR touches it and it would
19+
otherwise dominate the "possible merge conflicts" list and distract busy
20+
maintainers. A merge conflict caused only by `DIRECTORY.md` is trivial to clear:
21+
choose __accept both__ in the GitHub UI. The bottom section therefore separates
22+
the PRs whose only overlap with other open PRs is `DIRECTORY.md` (safe to accept
23+
both) from those that also overlap on real source files (which need a genuine
24+
review or rebase).
25+
1726
Two file totals are reported because they answer different questions:
1827
- "file touches" counts every (PR, file) pair, so a file edited by three open
1928
PRs contributes three touches; and
@@ -40,6 +49,10 @@
4049
from datetime import UTC, datetime
4150
from pathlib import Path
4251

52+
# Auto-generated index of the repo. Almost every PR touches it, so a merge
53+
# conflict here is expected and is resolved with "accept both" in the GitHub UI.
54+
DIRECTORY_FILE = "DIRECTORY.md"
55+
4356

4457
def run_gh(args: list[str]) -> str:
4558
try:
@@ -68,6 +81,34 @@ def check_gh_auth() -> None:
6881
sys.exit("Error: gh is not authenticated. Run 'gh auth login' first.")
6982

7083

84+
def git_root() -> Path | None:
85+
"""Return the repository root, or None if not inside a git work tree."""
86+
try:
87+
result = subprocess.run(
88+
["git", "rev-parse", "--show-toplevel"], # noqa: S607
89+
capture_output=True,
90+
text=True,
91+
check=True,
92+
)
93+
except FileNotFoundError:
94+
return None
95+
except subprocess.CalledProcessError:
96+
return None
97+
root = result.stdout.strip()
98+
return Path(root) if root else None
99+
100+
101+
def script_display_path() -> Path:
102+
"""This script's path relative to the git root (falls back to absolute)."""
103+
script_path = Path(__file__).resolve()
104+
if (root := git_root()) is not None:
105+
try:
106+
return script_path.relative_to(root.resolve())
107+
except ValueError:
108+
pass
109+
return script_path
110+
111+
71112
def get_open_prs() -> list[dict]:
72113
raw = run_gh(
73114
["pr", "list", "--state", "open", "--limit", "1000", "--json", "number,title"]
@@ -81,6 +122,76 @@ def get_pr_files(pr_number: int) -> list[str]:
81122
return [f["path"] for f in data.get("files", [])]
82123

83124

125+
def split_directory_conflicts(
126+
directory_prs: list[int],
127+
pr_to_files: dict[int, list[str]],
128+
contested: dict[str, list[int]],
129+
) -> tuple[list[int], list[tuple[int, list[str]]]]:
130+
"""Split PRs touching DIRECTORY.md by whether it is their only overlap.
131+
132+
Returns (directory_only, directory_plus_other) where directory_only lists
133+
PRs whose sole collision with other open PRs is DIRECTORY.md (safe to
134+
"accept both"), and directory_plus_other pairs each remaining PR with the
135+
other contested files it touches (a real review/rebase is needed).
136+
"""
137+
directory_only: list[int] = []
138+
directory_plus_other: list[tuple[int, list[str]]] = []
139+
for pr_number in directory_prs:
140+
other_contested = sorted(
141+
path
142+
for path in pr_to_files.get(pr_number, [])
143+
if path != DIRECTORY_FILE and path in contested
144+
)
145+
if other_contested:
146+
directory_plus_other.append((pr_number, other_contested))
147+
else:
148+
directory_only.append(pr_number)
149+
return directory_only, directory_plus_other
150+
151+
152+
def render_file_section(title: str, files: dict[str, list[int]]) -> None:
153+
"""Render a Markdown section listing files and the PR numbers touching them."""
154+
print(f"\n## `{len(files)}` {title}\n")
155+
if not files:
156+
print("_None._")
157+
return
158+
for path in sorted(files):
159+
pr_list = " ".join(f"#{n}" for n in files[path])
160+
print(f"- `{path}`: {pr_list}")
161+
162+
163+
def render_directory_section(
164+
directory_prs: list[int],
165+
directory_only: list[int],
166+
directory_plus_other: list[tuple[int, list[str]]],
167+
) -> None:
168+
"""Render the bottom DIRECTORY.md section (kept last on purpose)."""
169+
print(f"\n## `{len(directory_prs)}` open PRs touch `{DIRECTORY_FILE}`\n")
170+
if not directory_prs:
171+
print(f"_None -- no open PR modifies `{DIRECTORY_FILE}`._")
172+
return
173+
print(
174+
f"`{DIRECTORY_FILE}` is auto-generated, so nearly every PR touches it. "
175+
"A merge conflict caused only by this file is cleared by choosing "
176+
"__accept both__ in the GitHub UI -- no rebase needed.\n"
177+
)
178+
print(
179+
f"### `{len(directory_only)}` PRs whose only overlap is "
180+
f"`{DIRECTORY_FILE}` (safe to accept both)\n"
181+
)
182+
print(" ".join(f"#{n}" for n in directory_only) if directory_only else "_None._")
183+
print(
184+
f"\n### `{len(directory_plus_other)}` PRs that also overlap on other "
185+
"files (need a review or rebase)\n"
186+
)
187+
if not directory_plus_other:
188+
print("_None._")
189+
return
190+
for pr_number, files in directory_plus_other:
191+
file_list = ", ".join(f"`{path}`" for path in files)
192+
print(f"- #{pr_number}: also touches {file_list}")
193+
194+
84195
def main() -> None:
85196
if shutil.which("gh") is None:
86197
sys.exit("Error: 'gh' (GitHub CLI) is not installed or not in PATH.")
@@ -92,11 +203,13 @@ def main() -> None:
92203
print(f"PR count from get_open_prs(): {pr_count}", file=sys.stderr)
93204

94205
file_to_prs: dict[str, list[int]] = defaultdict(list)
206+
pr_to_files: dict[int, list[str]] = {}
95207
touch_count = 0 # every (PR, file) pair; a file may be touched by many PRs
96208

97209
for pr in prs:
98210
pr_number = pr["number"]
99211
pr_files = get_pr_files(pr_number)
212+
pr_to_files[pr_number] = pr_files
100213
touch_count += len(pr_files)
101214
for path in pr_files:
102215
file_to_prs[path].append(pr_number)
@@ -107,6 +220,10 @@ def main() -> None:
107220
file=sys.stderr,
108221
)
109222

223+
# Pull DIRECTORY.md out so it does not dominate the contested/existing lists;
224+
# it gets its own section at the very bottom.
225+
directory_prs = sorted(set(file_to_prs.pop(DIRECTORY_FILE, [])))
226+
110227
existing: dict[str, list[int]] = {}
111228
missing: dict[str, list[int]] = {}
112229
contested: dict[str, list[int]] = {}
@@ -121,18 +238,30 @@ def main() -> None:
121238
missing_count = len(missing)
122239
print(
123240
f"Existing files: {existing_count}, Missing files: {missing_count}, "
124-
f"Contested files: {len(contested)}",
241+
f"Contested files: {len(contested)} (excluding {DIRECTORY_FILE}), "
242+
f"PRs touching {DIRECTORY_FILE}: {len(directory_prs)}",
125243
file=sys.stderr,
126244
)
127245

246+
# Of the PRs that touch DIRECTORY.md, separate those whose only overlap with
247+
# other open PRs is DIRECTORY.md itself (safe "accept both") from those that
248+
# also collide on real source files (need a genuine review or rebase).
249+
directory_only, directory_plus_other = split_directory_conflicts(
250+
directory_prs, pr_to_files, contested
251+
)
252+
128253
# --- Render GitHub-flavored Markdown ---
129254
print("# Open Pull Request File Map\n")
130-
print(f"- Script: `{Path(__file__).resolve()}`")
255+
print(f"- Script: `{script_display_path()}`")
131256
print(f"- Generated (UTC): `{datetime.now(UTC).isoformat()}`")
132257
print(f"- Number of PRs: `{pr_count}`")
133258
print(f"- File touches (PR x file): `{touch_count}`")
134259
print(f"- Distinct files touched: `{distinct_count}`")
135-
print(f"- Files touched by more than one PR: `{len(contested)}`")
260+
print(
261+
f"- Files touched by more than one PR: `{len(contested)}` "
262+
f"(excluding `{DIRECTORY_FILE}`)"
263+
)
264+
print(f"- Open PRs touching `{DIRECTORY_FILE}`: `{len(directory_prs)}`")
136265
if pr_count == 0:
137266
print("\nNo open pull requests found.")
138267
return
@@ -150,21 +279,11 @@ def main() -> None:
150279
else:
151280
print("_None -- no open PRs overlap on the same file._")
152281

153-
print(f"\n## `{existing_count}` existing files\n")
154-
if existing:
155-
for path in sorted(existing):
156-
pr_list = " ".join(f"#{n}" for n in existing[path])
157-
print(f"- `{path}`: {pr_list}")
158-
else:
159-
print("_None._")
282+
render_file_section("existing files", existing)
283+
render_file_section("files not present in the working directory", missing)
160284

161-
print(f"\n## `{missing_count}` files not present in the working directory\n")
162-
if missing:
163-
for path in sorted(missing):
164-
pr_list = " ".join(f"#{n}" for n in missing[path])
165-
print(f"- `{path}`: {pr_list}")
166-
else:
167-
print("_None._")
285+
# DIRECTORY.md section, kept at the very bottom on purpose.
286+
render_directory_section(directory_prs, directory_only, directory_plus_other)
168287

169288

170289
if __name__ == "__main__":

0 commit comments

Comments
 (0)