Skip to content

Commit b2c8320

Browse files
authored
Fix ancestry checks
We were relying on the local git repo to determine ancestry; however, if a commit was pushed directly to a PR (for instance, a merge commit generated by GitHub), it is not necessarily available locally. Instead, pull the commit history from GitHub itself.
1 parent 3d3ab46 commit b2c8320

5 files changed

Lines changed: 83 additions & 81 deletions

File tree

git_sync/git.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,6 @@ async def get_remote_branches(remote: bytes) -> list[bytes]:
106106
return raw_bytes.splitlines()
107107

108108

109-
async def is_ancestor(commit1: _ExecArg, commit2: _ExecArg) -> bool:
110-
"""Return true if commit1 is an ancestor of commit2."""
111-
try:
112-
await git("merge-base", "--is-ancestor", commit1, commit2)
113-
return True
114-
except GitError as e:
115-
if e.returncode == 1:
116-
return False
117-
raise
118-
119-
120109
async def fetch_and_fast_forward_to_upstream(branches: Iterable[Branch]) -> None:
121110
if any(b.is_current for b in branches):
122111
await git("pull", "--all")
@@ -211,17 +200,11 @@ async def update_merged_prs(
211200
if (
212201
merged_hash
213202
and branch_name in branch_hashes
214-
and merged_hash != branch_hashes[branch_name]
203+
and branch_hashes[branch_name] in pr.hashes
215204
and push_remote_url in pr.repo_urls
216205
):
217-
try:
218-
branch_is_ancestor = await is_ancestor(branch_name, pr.branch_hash)
219-
except GitError:
220-
pass # Probably no longer have the commit hash
221-
else:
222-
if branch_is_ancestor:
223-
await update_merged_pr_branch(
224-
branch_name=branch_name,
225-
merged_hash=merged_hash,
226-
allow_delete=allow_delete,
227-
)
206+
await update_merged_pr_branch(
207+
branch_name=branch_name,
208+
merged_hash=merged_hash,
209+
allow_delete=allow_delete,
210+
)

git_sync/github.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -49,37 +49,58 @@ def repos_by_domain(urls: Iterable[str]) -> dict[str, list[Repository]]:
4949
@dataclass(frozen=True)
5050
class PullRequest:
5151
branch_name: str
52+
"""Name of the branch that backed the PR."""
5253
repo_urls: frozenset[str]
53-
branch_hash: str
54+
"""Git and SSH URLs of the repository where the PR is located."""
55+
hashes: tuple[str, ...]
56+
"""All commits pushed to the PR, newest first."""
5457
merged_hash: str | None
58+
"""The commit hash of the PR merge commit, if it exists."""
5559

5660

57-
def gql_query(owner: str, name: str) -> str:
61+
def pr_initial_query(owner: str, name: str) -> str:
5862
return f"""
5963
repository(owner: "{owner}", name: "{name}" ) {{
6064
pullRequests(orderBy: {{ field: UPDATED_AT, direction: ASC }}, last: 50) {{
6165
nodes {{
62-
headRefName
63-
headRepository {{
64-
sshUrl
65-
url
66-
}}
66+
id
6767
commits (last: 1) {{
68-
nodes {{
69-
commit {{
70-
oid
71-
}}
72-
}}
68+
totalCount
7369
}}
74-
mergeCommit {{
75-
oid
70+
}}
71+
}}
72+
}}
73+
"""
74+
75+
76+
def pr_details_query(pr_node_id: str, commit_count: int) -> str:
77+
return f"""
78+
node(id: "{pr_node_id}") {{
79+
... on PullRequest {{
80+
headRefName
81+
headRepository {{
82+
sshUrl
83+
url
84+
}}
85+
commits (last: {commit_count}) {{
86+
nodes {{
87+
commit {{
88+
oid
89+
}}
7690
}}
7791
}}
92+
mergeCommit {{
93+
oid
94+
}}
7895
}}
7996
}}
8097
"""
8198

8299

100+
def join_queries(queries: Iterable[str]) -> str:
101+
return "{" + "\n".join(f"q{i}: {query}" for i, query in enumerate(queries)) + "}"
102+
103+
83104
async def fetch_pull_requests_from_domain(
84105
token: str, domain: str, repos: list[Repository]
85106
) -> AsyncIterator[PullRequest]:
@@ -91,24 +112,38 @@ async def fetch_pull_requests_from_domain(
91112
client = GraphQLClient(
92113
endpoint=endpoint, headers={"Authorization": f"Bearer {token}"}
93114
)
94-
queries = [
95-
f"repo{i}: {gql_query(repo.owner, repo.name)}"
96-
for i, repo in enumerate(repos, 1)
115+
116+
# Query for PRs and commit counts
117+
initial_queries = [
118+
pr_initial_query(repo.owner, repo.name) for i, repo in enumerate(repos, 1)
97119
]
98-
query = "{" + "\n".join(queries) + "}"
99-
response = await client.query(query)
100-
assert not response.errors
101-
for repo_data in response.data.values():
102-
for pr_data in repo_data["pullRequests"]["nodes"]:
103-
head_repo = pr_data.get("headRepository") or {}
104-
repo_urls = [head_repo.get("sshUrl"), head_repo.get("url")]
105-
if pr_data["commits"]["nodes"]:
106-
yield PullRequest(
107-
branch_name=pr_data["headRefName"],
108-
repo_urls=frozenset(url for url in repo_urls if url is not None),
109-
branch_hash=pr_data["commits"]["nodes"][0]["commit"]["oid"],
110-
merged_hash=(pr_data.get("mergeCommit") or {}).get("oid"),
111-
)
120+
initial_response = await client.query(join_queries(initial_queries))
121+
assert not initial_response.errors
122+
123+
# Determine what follow-up queries to make
124+
details_queries = [
125+
pr_details_query(pr_data["id"], pr_data["commits"]["totalCount"])
126+
for repo_data in initial_response.data.values()
127+
for pr_data in repo_data["pullRequests"]["nodes"]
128+
]
129+
130+
# Query for detailed PR information
131+
details_response = await client.query(join_queries(details_queries))
132+
assert not details_response.errors
133+
134+
# Yield response data as PullRequest objects
135+
for pr_data in details_response.data.values():
136+
head_repo = pr_data.get("headRepository") or {}
137+
repo_urls = [head_repo.get("sshUrl"), head_repo.get("url")]
138+
hashes = tuple(
139+
commit["commit"]["oid"] for commit in reversed(pr_data["commits"]["nodes"])
140+
)
141+
yield PullRequest(
142+
branch_name=pr_data["headRefName"],
143+
repo_urls=frozenset(url for url in repo_urls if url is not None),
144+
hashes=hashes,
145+
merged_hash=(pr_data.get("mergeCommit") or {}).get("oid"),
146+
)
112147

113148

114149
async def fetch_pull_requests(

pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "git-sync"
3-
version = "0.4"
3+
version = "0.4.1"
44
description = "Synchronize local git repo with remotes"
55
authors = [{ name = "Alice Purcell", email = "[email protected]" }]
66
requires-python = ">= 3.12"
@@ -22,9 +22,6 @@ target-version = "py310"
2222

2323
[tool.ruff.lint]
2424
select = ["ANN", "B", "C4", "E", "F", "I", "PGH", "PLR", "PYI", "RUF", "SIM", "UP", "W"]
25-
ignore = [
26-
"ANN101", # Deprecated, will be removed
27-
]
2825
isort.split-on-trailing-comma = false
2926

3027
[tool.setuptools.dynamic]

tests/integration/test_fast_forward_merged_prs.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def test_delete_merged_inactive_pr_branch() -> None:
3030
pr = PullRequest(
3131
branch_name="my_pr",
3232
repo_urls=frozenset([REPO_URL]),
33-
branch_hash=commit_b,
33+
hashes=(commit_b,),
3434
merged_hash=commit_c,
3535
)
3636

@@ -57,7 +57,7 @@ async def test_force_inactive_upstream_branch_to_merged_commit() -> None:
5757
pr = PullRequest(
5858
branch_name="my_pr",
5959
repo_urls=frozenset([REPO_URL]),
60-
branch_hash=commit_b,
60+
hashes=(commit_b,),
6161
merged_hash=commit_c,
6262
)
6363

@@ -85,7 +85,7 @@ async def test_merged_inactive_pr_branch_with_deletion_disabled() -> None:
8585
pr = PullRequest(
8686
branch_name="my_pr",
8787
repo_urls=frozenset([REPO_URL]),
88-
branch_hash=commit_b,
88+
hashes=(commit_b,),
8989
merged_hash=commit_c,
9090
)
9191

@@ -113,7 +113,7 @@ async def test_delete_merged_active_pr_branch() -> None:
113113
pr = PullRequest(
114114
branch_name="my_pr",
115115
repo_urls=frozenset([REPO_URL]),
116-
branch_hash=commit_b,
116+
hashes=(commit_b,),
117117
merged_hash=commit_c,
118118
)
119119

@@ -142,7 +142,7 @@ async def test_force_active_upstream_branch_to_merged_commit() -> None:
142142
pr = PullRequest(
143143
branch_name="my_pr",
144144
repo_urls=frozenset([REPO_URL]),
145-
branch_hash=commit_b,
145+
hashes=(commit_b,),
146146
merged_hash=commit_c,
147147
)
148148

@@ -170,7 +170,7 @@ async def test_merged_active_upstream_branch_with_deletion_disabled() -> None:
170170
pr = PullRequest(
171171
branch_name="my_pr",
172172
repo_urls=frozenset([REPO_URL]),
173-
branch_hash=commit_b,
173+
hashes=(commit_b,),
174174
merged_hash=commit_c,
175175
)
176176

@@ -195,7 +195,7 @@ async def test_staged_changes_not_lost() -> None:
195195
pr = PullRequest(
196196
branch_name="my_pr",
197197
repo_urls=frozenset([REPO_URL]),
198-
branch_hash=commit_b,
198+
hashes=(commit_b,),
199199
merged_hash=commit_c,
200200
)
201201

@@ -221,7 +221,7 @@ async def test_unstaged_changes_to_committed_files_not_lost() -> None:
221221
pr = PullRequest(
222222
branch_name="my_pr",
223223
repo_urls=frozenset([REPO_URL]),
224-
branch_hash=commit_b,
224+
hashes=(commit_b,),
225225
merged_hash=commit_c,
226226
)
227227

@@ -251,7 +251,7 @@ async def test_fastforward_when_pr_had_additional_commits() -> None:
251251
pr = PullRequest(
252252
branch_name="my_pr",
253253
repo_urls=frozenset([REPO_URL]),
254-
branch_hash=commit_c,
254+
hashes=(commit_c, commit_b),
255255
merged_hash=commit_d,
256256
)
257257

@@ -276,7 +276,7 @@ async def test_no_fastforward_when_branch_has_additional_commits() -> None:
276276
pr = PullRequest(
277277
branch_name="my_pr",
278278
repo_urls=frozenset([REPO_URL]),
279-
branch_hash=commit_b,
279+
hashes=(commit_b,),
280280
merged_hash=commit_d,
281281
)
282282

tests/integration/test_is_ancestor.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)