Skip to content

Commit 9c5695f

Browse files
jgmelberclaude
andcommitted
Address Copilot review: fail (not skip) on non-fast-forward url changes
Two issues Copilot flagged on the earlier aie_api-only version of this check still applied to the generalized version: - A submodule whose .gitmodules url changed was unconditionally skipped, meaning a bad merge that regresses both the pin and the url together (reverting back to an old fork, say) would silently pass. Now the ancestry check runs against the *new* url instead of being skipped; it only passes if the new pin is provably a descendant of the old one there. A genuine non-fast-forward remote switch still fails and must be landed explicitly (e.g. an admin merge), rather than passing silently just because "the url changed". - git fetch failures (e.g. a pin that's unreachable from any ref on the remote) surfaced as an unhandled CalledProcessError traceback instead of a clear ::error:: message. Fetches now go through a non-raising helper and a failed fetch is reported as "not reachable at all". Also pass --filter=blob:none --no-tags on the ancestry-check fetches, since they only need commit/tree objects. Verified against three new synthetic sandbox cases: url switch to a provably unrelated remote now fails instead of silently passing, and a genuine fast-forward across a url switch (the #3262 pattern) still passes with a note. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 0010548 commit 9c5695f

1 file changed

Lines changed: 68 additions & 32 deletions

File tree

utils/check_submodule_regressions.py

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,25 @@
1919
descendant of origin/main's pin -- which is exactly what a silent revert
2020
from a stale-branch merge is not.
2121
22-
A submodule whose .gitmodules url changed in this diff is skipped: a
23-
deliberate remote switch (see #3262) can legitimately be a non-fast-forward
24-
change in commit history, and the url edit itself is a visible, reviewed
25-
part of the diff.
22+
A submodule whose .gitmodules url also changed in this diff is still held
23+
to the same standard: the ancestry check runs against the *new* url instead
24+
of being skipped, since a bad merge could just as easily regress the url
25+
back to an old fork alongside the pin (that combination must not silently
26+
pass just because "the url changed"). A deliberate remote switch (see
27+
#3262) can legitimately be a non-fast-forward change -- when it is, this
28+
script fails and says so, rather than silently waving it through; landing
29+
one is a manual, reviewed action (e.g. an admin merge), not something this
30+
check should rubber-stamp.
2631
"""
2732

2833
import subprocess
2934
import sys
3035

3136
BASE_REF = "origin/main"
3237

38+
# Ancestry checks only need commit/tree objects, not blobs or tags.
39+
FETCH_FLAGS = ["--quiet", "--force", "--filter=blob:none", "--no-tags"]
40+
3341

3442
def run(*args):
3543
return subprocess.run(
@@ -44,17 +52,23 @@ def try_run(*args):
4452

4553
def submodule_paths_and_urls(rev):
4654
ok, paths_out = try_run(
47-
"git", "config", f"--blob={rev}:.gitmodules",
48-
"--get-regexp", r"^submodule\..*\.path$",
55+
"git",
56+
"config",
57+
f"--blob={rev}:.gitmodules",
58+
"--get-regexp",
59+
r"^submodule\..*\.path$",
4960
)
5061
if not ok:
5162
return {}
5263

5364
urls = dict(
5465
line.split(" ", 1)
5566
for line in run(
56-
"git", "config", f"--blob={rev}:.gitmodules",
57-
"--get-regexp", r"^submodule\..*\.url$",
67+
"git",
68+
"config",
69+
f"--blob={rev}:.gitmodules",
70+
"--get-regexp",
71+
r"^submodule\..*\.url$",
5872
).splitlines()
5973
)
6074

@@ -74,18 +88,32 @@ def pinned_commit(rev, path):
7488
return out.split()[2]
7589

7690

77-
def check_submodule(path, url, old_pin, new_pin):
91+
def fetch_into(url, commit, ref):
92+
return try_run("git", "fetch", *FETCH_FLAGS, url, f"{commit}:{ref}")[0]
93+
94+
95+
def is_forward_move(path, url, old_pin, new_pin):
96+
"""Returns True if new_pin is reachable from old_pin at url, False if
97+
it provably isn't, or None if that couldn't be determined (e.g. old_pin
98+
no longer exists on any ref reachable from url)."""
7899
old_ref = f"refs/tmp/submodule-check-old-{abs(hash(path))}"
79100
new_ref = f"refs/tmp/submodule-check-new-{abs(hash(path))}"
80-
run("git", "fetch", "--quiet", "--force", url, f"{old_pin}:{old_ref}")
81-
run("git", "fetch", "--quiet", "--force", url, f"{new_pin}:{new_ref}")
82-
return subprocess.run(
83-
["git", "merge-base", "--is-ancestor", old_ref, new_ref]
84-
).returncode == 0
101+
102+
if not fetch_into(url, old_pin, old_ref) or not fetch_into(url, new_pin, new_ref):
103+
return None
104+
105+
return (
106+
subprocess.run(
107+
["git", "merge-base", "--is-ancestor", old_ref, new_ref]
108+
).returncode
109+
== 0
110+
)
85111

86112

87113
def main():
88-
run("git", "fetch", "--quiet", "origin", "main")
114+
if not try_run("git", "fetch", "--quiet", "origin", "main")[0]:
115+
print(f"::error::Could not fetch {BASE_REF} from origin to compare against.")
116+
sys.exit(1)
89117

90118
head_submodules = submodule_paths_and_urls("HEAD")
91119
base_submodules = submodule_paths_and_urls(BASE_REF)
@@ -103,29 +131,37 @@ def main():
103131
print(f"OK: {path} unchanged ({new_pin}).")
104132
continue
105133

106-
if base_submodules.get(path) != url:
107-
print(
108-
f"NOTE: {path} changed its .gitmodules url in this diff; "
109-
"skipping the ancestry check (the remote switch is itself "
110-
"a visible, reviewed part of the diff)."
111-
)
112-
continue
134+
url_changed = base_submodules.get(path) != url
135+
result = is_forward_move(path, url, old_pin, new_pin)
113136

114-
if check_submodule(path, url, old_pin, new_pin):
115-
print(f"OK: {path} moved forward {old_pin} -> {new_pin}")
137+
if result is True:
138+
note = " (url also changed)" if url_changed else ""
139+
print(f"OK: {path} moved forward {old_pin} -> {new_pin}{note}")
116140
else:
117-
failures.append((path, old_pin, new_pin))
141+
failures.append((path, old_pin, new_pin, url, url_changed, result))
118142

119143
if failures:
120-
for path, old_pin, new_pin in failures:
144+
for path, old_pin, new_pin, url, url_changed, result in failures:
145+
reason = (
146+
f"{old_pin} is not reachable from {url} at all"
147+
if result is None
148+
else f"{new_pin} is not a descendant of it at {url}"
149+
)
150+
url_note = (
151+
" Its .gitmodules url also changed in this diff -- a "
152+
"deliberate remote switch can legitimately be non-fast-forward, "
153+
"but that requires a human to say so explicitly (e.g. an admin "
154+
"merge), not a silently passing check."
155+
if url_changed
156+
else ""
157+
)
121158
print(
122159
f"::error::{path} moved from {old_pin} (on {BASE_REF}) to "
123-
f"{new_pin}, which is not a descendant of it. This is what a "
124-
"silent regression from a stale-branch merge looks like (see "
125-
"#3292, #3330). If this is an intentional downgrade, rebase "
126-
"onto the latest main before merging so the pin doesn't "
127-
"silently clobber a bump that landed after this branch was "
128-
"created."
160+
f"{new_pin}: {reason}. This is what a silent regression from a "
161+
"stale-branch merge looks like (see #3292, #3330)."
162+
f"{url_note} If this is an intentional downgrade, rebase onto "
163+
"the latest main before merging so the pin doesn't silently "
164+
"clobber a bump that landed after this branch was created."
129165
)
130166
sys.exit(1)
131167

0 commit comments

Comments
 (0)