Skip to content

Commit cdfae9b

Browse files
committed
Add commit graph tools
The two tools help us investigate a patchqueue and identify potential fixes/discussions around each patch. * find-fixers-recursive.py takes a list of commit hashes (12 chars) and their titles, then emits a recursive relationship graph of what commits fix each patch, as well as commits that mention the patch's original hash(es) and title in their commit messages. * find-applied-fixes-recursive.py scans the list emitted by find-fixers-recursive.py and finds what has been and has not been applied to our tree. Assisted-by: LLM Signed-off-by: Tu Dinh <ngoc-tu.dinh@vates.tech>
1 parent f7c84a6 commit cdfae9b

7 files changed

Lines changed: 2008 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "commit-graph-tools"
7+
version = "0.1.0"
8+
description = "Utilities for exploring commit relationships"
9+
requires-python = ">=3.11"
10+
license = "BSD-2-Clause"
11+
authors = [
12+
{name = "Tu Dinh", email = "ngoc-tu.dinh@vates.tech"},
13+
]
14+
15+
dependencies = [
16+
]
17+
18+
[dependency-groups]
19+
dev = [
20+
"pyright>=1.1.411",
21+
"pytest>=9.1.1",
22+
"ruff>=0.16.1",
23+
]
24+
25+
[project.urls]
26+
Homepage = "https://github.com/xcp-ng/hypervisor-dev"
27+
Repository = "https://github.com/xcp-ng/hypervisor-dev"
28+
Issues = "https://github.com/xcp-ng/hypervisor-dev/issues"
29+
30+
[project.scripts]
31+
find-applied-fixes-recursive = "commit_graph_tools.find_applied_fixes_recursive:main"
32+
find-fixers-recursive = "commit_graph_tools.find_fixers_recursive:main"
33+
34+
[tool.hatch.build.targets.wheel]
35+
packages = ["src/commit_graph_tools"]
36+
37+
[tool.ruff]
38+
line-length = 100
39+
target-version = "py311"
40+
41+
[tool.ruff.lint]
42+
select = [
43+
"E", # pycodestyle errors
44+
"W", # pycodestyle warnings
45+
"F", # pyflakes
46+
"I", # isort
47+
"B", # flake8-bugbear
48+
"C4", # flake8-comprehensions
49+
"UP", # pyupgrade
50+
]
51+
ignore = [
52+
"B008", # do not perform function calls in argument defaults
53+
"C901", # too complex
54+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Utilities for exploring relationships between Git commits."""
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import argparse
2+
import re
3+
import subprocess
4+
import sys
5+
6+
TREE_RANGE = "v4.19.19..HEAD"
7+
REPOSITORY = "."
8+
9+
10+
def git(*args):
11+
return subprocess.check_output(
12+
["git", "-C", REPOSITORY, *args],
13+
text=True,
14+
errors="replace",
15+
)
16+
17+
18+
def load_tree_subjects():
19+
"""
20+
Return:
21+
subject -> [commit hashes]
22+
23+
Only commits in TREE_RANGE are considered.
24+
"""
25+
data = git(
26+
"log",
27+
TREE_RANGE,
28+
"--format=%H%x1f%s",
29+
)
30+
31+
subjects = {}
32+
33+
for line in data.splitlines():
34+
if "\x1f" not in line:
35+
continue
36+
37+
commit_hash, subject = line.split("\x1f", 1)
38+
subjects.setdefault(subject, []).append(commit_hash)
39+
40+
return subjects
41+
42+
43+
def parse_fixes(lines):
44+
"""
45+
Parse output from the recursive Fixes script while preserving depth.
46+
47+
Input:
48+
49+
original commit
50+
Fixed-by: aaaaaaaaaaaa first fix
51+
Mentioned-by: bbbbbbbbbbbb mention of first fix
52+
Fixed-by: cccccccccccc fix of mention
53+
54+
another commit
55+
Error: no matching Fixes: tag or commit-message mention found
56+
57+
Returns:
58+
[
59+
(
60+
"original commit",
61+
[
62+
(1, "Fixed-by", "aaaaaaaaaaaa", "first fix", None),
63+
(
64+
2,
65+
"Mentioned-by",
66+
"bbbbbbbbbbbb",
67+
"mention of first fix",
68+
"title",
69+
),
70+
(3, "Fixed-by", "cccccccccccc", "fix of mention", None),
71+
],
72+
),
73+
...
74+
]
75+
"""
76+
groups = []
77+
current_source = None
78+
current_fixes = []
79+
80+
ignored_statuses = {
81+
"no Fixes: commits found",
82+
"SOURCE NOT FOUND",
83+
}
84+
85+
for raw in lines:
86+
line = raw.rstrip()
87+
88+
if not line.strip():
89+
continue
90+
91+
if line[0].isspace():
92+
if current_source is None:
93+
continue
94+
95+
stripped = line.strip()
96+
97+
if stripped in ignored_statuses or stripped.startswith("Error:"):
98+
continue
99+
100+
# The first script uses two spaces per recursion level.
101+
leading_spaces = len(line) - len(line.lstrip(" "))
102+
103+
# Be tolerant of odd indentation, but never produce depth 0
104+
# for an indented fix line.
105+
depth = max(1, leading_spaces // 2)
106+
107+
match = re.fullmatch(
108+
r"(Fixed-by|Mentioned-by):\s+"
109+
r"([0-9a-fA-F]{7,40})\s+(.+)",
110+
stripped,
111+
)
112+
113+
if match:
114+
relationship, upstream_hash, subject = match.groups()
115+
matched_as = None
116+
117+
if relationship == "Mentioned-by":
118+
annotation = re.fullmatch(
119+
r"(.+) \(as (title|[0-9a-fA-F]{7,12})\)",
120+
subject,
121+
)
122+
123+
if annotation:
124+
subject, matched_as = annotation.groups()
125+
else:
126+
# Retain compatibility with output from older versions of
127+
# find-fixers-recursive.py, which had no relationship label.
128+
parts = stripped.split(maxsplit=1)
129+
130+
if len(parts) != 2:
131+
print(
132+
f"warning: cannot parse related commit: {line!r}",
133+
file=sys.stderr,
134+
)
135+
continue
136+
137+
upstream_hash, subject = parts
138+
relationship = "Fixed-by"
139+
matched_as = None
140+
141+
if not (
142+
7 <= len(upstream_hash) <= 40
143+
and all(c in "0123456789abcdefABCDEF" for c in upstream_hash)
144+
):
145+
print(
146+
f"warning: ignoring malformed relationship: {line!r}",
147+
file=sys.stderr,
148+
)
149+
continue
150+
151+
current_fixes.append((depth, relationship, upstream_hash, subject, matched_as))
152+
153+
else:
154+
if current_source is not None:
155+
groups.append((current_source, current_fixes))
156+
157+
current_source = line.strip()
158+
current_fixes = []
159+
160+
if current_source is not None:
161+
groups.append((current_source, current_fixes))
162+
163+
return groups
164+
165+
166+
def main():
167+
global REPOSITORY
168+
169+
parser = argparse.ArgumentParser(
170+
description=(
171+
f"Find related commits already present in {TREE_RANGE}, "
172+
"preserving recursive relationship chains."
173+
)
174+
)
175+
176+
parser.add_argument(
177+
"file",
178+
nargs="?",
179+
help="recursive Fixes-list file; omit or use - for stdin",
180+
)
181+
182+
parser.add_argument(
183+
"-C",
184+
"--repo",
185+
default=REPOSITORY,
186+
metavar="PATH",
187+
help="repository to inspect (default: current directory)",
188+
)
189+
190+
parser.add_argument(
191+
"--missing",
192+
action="store_true",
193+
help="show related commits NOT present instead of those already present",
194+
)
195+
196+
parser.add_argument(
197+
"--show-local-hash",
198+
action="store_true",
199+
help="show matching hash from the current tree",
200+
)
201+
202+
args = parser.parse_args()
203+
REPOSITORY = args.repo
204+
205+
repository_check = subprocess.run(
206+
["git", "-C", REPOSITORY, "rev-parse", "--git-dir"],
207+
stdout=subprocess.DEVNULL,
208+
stderr=subprocess.DEVNULL,
209+
check=False,
210+
)
211+
212+
if repository_check.returncode != 0:
213+
parser.error(f"cannot access Git repository: {REPOSITORY}")
214+
215+
if args.file and args.file != "-":
216+
with open(args.file, encoding="utf-8") as f:
217+
groups = parse_fixes(f)
218+
else:
219+
groups = parse_fixes(sys.stdin)
220+
221+
tree_subjects = load_tree_subjects()
222+
223+
total = 0
224+
matched = 0
225+
226+
for source, fixes in groups:
227+
output = []
228+
229+
for depth, relationship, upstream_hash, subject, matched_as in fixes:
230+
total += 1
231+
232+
local_hashes = tree_subjects.get(subject, [])
233+
present = bool(local_hashes)
234+
235+
if present:
236+
matched += 1
237+
238+
want = not args.missing
239+
240+
if present != want:
241+
continue
242+
243+
indent = " " * depth
244+
suffix = f" (as {matched_as})" if matched_as else ""
245+
246+
if args.show_local_hash and present:
247+
local = ", ".join(h[:12] for h in local_hashes)
248+
249+
output.append(
250+
f"{indent}{relationship}: {upstream_hash} {subject}{suffix} [local: {local}]"
251+
)
252+
else:
253+
output.append(f"{indent}{relationship}: {upstream_hash} {subject}{suffix}")
254+
255+
if output:
256+
print(source)
257+
258+
for line in output:
259+
print(line)
260+
261+
if args.missing:
262+
print(
263+
f"\n{total - matched}/{total} related commits are not present in {TREE_RANGE}",
264+
file=sys.stderr,
265+
)
266+
else:
267+
print(
268+
f"\n{matched}/{total} related commits are already present in {TREE_RANGE}",
269+
file=sys.stderr,
270+
)
271+
272+
273+
if __name__ == "__main__":
274+
main()

0 commit comments

Comments
 (0)