55Lists all open pull requests in the current directory's git repo (via `gh`)
66and, 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.
1515deleted, 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+
1726Two 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
4049from datetime import UTC , datetime
4150from 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
4457def 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+
71112def 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+
84195def 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 ("\n No 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
170289if __name__ == "__main__" :
0 commit comments