Skip to content

Commit de70142

Browse files
authored
Implement ghstack pull (#354)
1 parent 02e2120 commit de70142

6 files changed

Lines changed: 385 additions & 0 deletions

File tree

src/ghstack/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import ghstack.land
1616
import ghstack.log
1717
import ghstack.logs
18+
import ghstack.pull
1819
import ghstack.rage
1920
import ghstack.status
2021
import ghstack.submit
@@ -282,6 +283,34 @@ def checkout(same_base: bool, pull_request: str) -> None:
282283
)
283284

284285

286+
@main.command("pull")
287+
@click.option(
288+
"--continue",
289+
"continue_",
290+
is_flag=True,
291+
help="Finish a ghstack pull after resolving conflicts",
292+
)
293+
@click.argument("pull_request", metavar="PR", required=False)
294+
def pull(continue_: bool, pull_request: Optional[str]) -> None:
295+
"""
296+
Pull remote updates for a ghstack PR
297+
"""
298+
with cli_context(request_github_token=False) as (shell, config, github):
299+
run_async(
300+
run_with_github(
301+
github,
302+
ghstack.pull.main(
303+
pull_request=pull_request,
304+
github=github,
305+
sh=shell,
306+
remote_name=config.remote_name,
307+
github_url=config.github_url,
308+
continue_=continue_,
309+
),
310+
)
311+
)
312+
313+
285314
@main.command("cherry-pick")
286315
@click.option(
287316
"--stack",

src/ghstack/pull.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
import json
5+
import os
6+
import re
7+
from typing import Any, Dict, List, Optional, Tuple
8+
9+
import ghstack.checkout
10+
import ghstack.diff
11+
import ghstack.github
12+
import ghstack.github_utils
13+
import ghstack.shell
14+
import ghstack.submit
15+
16+
17+
async def _run_git_for_status(
18+
sh: ghstack.shell.Shell, args: List[str]
19+
) -> Tuple[int, str]:
20+
ghstack.shell.log_command(["git", *args])
21+
proc = await asyncio.create_subprocess_exec(
22+
"git",
23+
*args,
24+
cwd=sh.cwd,
25+
stdout=asyncio.subprocess.PIPE,
26+
stderr=asyncio.subprocess.STDOUT,
27+
)
28+
out, _ = await proc.communicate()
29+
assert proc.returncode is not None
30+
return proc.returncode, out.decode(errors="backslashreplace")
31+
32+
33+
async def _resolve_params(
34+
*,
35+
pull_request: Optional[str],
36+
github_url: str,
37+
sh: ghstack.shell.Shell,
38+
remote_name: str,
39+
) -> ghstack.github_utils.GitHubPullRequestParams:
40+
if pull_request is not None:
41+
return await ghstack.github_utils.parse_pull_request(
42+
pull_request, sh=sh, remote_name=remote_name
43+
)
44+
45+
commit_msg = await sh.agit("log", "-1", "--format=%B", "HEAD")
46+
pr = ghstack.diff.PullRequestResolved.search(commit_msg, github_url)
47+
if pr is None:
48+
raise RuntimeError(
49+
"HEAD commit is not associated with a ghstack pull request "
50+
"(no Pull-Request trailer found). Check out the commit for the "
51+
"PR you want to pull, or pass the PR explicitly."
52+
)
53+
return {
54+
"github_url": pr.github_url,
55+
"owner": pr.owner,
56+
"name": pr.repo,
57+
"number": pr.number,
58+
}
59+
60+
61+
def _replace_source_id(commit_msg: str, source_id: str) -> str:
62+
line = f"ghstack-source-id: {source_id}\n"
63+
if ghstack.submit.RE_GHSTACK_SOURCE_ID.search(commit_msg) is None:
64+
return commit_msg.rstrip() + "\n" + line
65+
return ghstack.submit.RE_GHSTACK_SOURCE_ID.sub(line, commit_msg)
66+
67+
68+
async def _state_path(sh: ghstack.shell.Shell) -> str:
69+
path = await sh.agit("rev-parse", "--git-path", "GHSTACK_PULL")
70+
return path if os.path.isabs(path) else sh.abspath(path)
71+
72+
73+
async def _read_state(sh: ghstack.shell.Shell) -> Dict[str, Any]:
74+
path = await _state_path(sh)
75+
if not os.path.exists(path):
76+
raise RuntimeError("No ghstack pull conflict in progress.")
77+
with open(path, encoding="utf-8") as f:
78+
result: Dict[str, Any] = json.load(f)
79+
return result
80+
81+
82+
async def _write_state(sh: ghstack.shell.Shell, state: Dict[str, Any]) -> None:
83+
path = await _state_path(sh)
84+
with open(path, "w", encoding="utf-8") as f:
85+
json.dump(state, f)
86+
f.write("\n")
87+
88+
89+
async def _clear_state(sh: ghstack.shell.Shell) -> None:
90+
path = await _state_path(sh)
91+
try:
92+
os.unlink(path)
93+
except FileNotFoundError:
94+
pass
95+
96+
97+
async def _find_head_with_tree(
98+
sh: ghstack.shell.Shell, *, remote_head: str, tree: str
99+
) -> str:
100+
log = await sh.agit("log", "--first-parent", "--format=%H %T", remote_head)
101+
for line in log.splitlines():
102+
commit, commit_tree = line.split()
103+
if commit_tree == tree:
104+
return commit
105+
raise RuntimeError(
106+
"Could not find the previously checked out ghstack head commit. "
107+
"The local ghstack-source-id does not appear in the remote head history."
108+
)
109+
110+
111+
async def _is_worktree_clean(sh: ghstack.shell.Shell) -> bool:
112+
return bool(
113+
await sh.agit("diff", "--quiet", exitcode=True)
114+
and await sh.agit("diff", "--cached", "--quiet", exitcode=True)
115+
)
116+
117+
118+
async def _finish_pull(sh: ghstack.shell.Shell, state: Dict[str, Any]) -> None:
119+
unmerged = await sh.agit("ls-files", "-u")
120+
if unmerged:
121+
raise RuntimeError(
122+
"There are still unresolved merge conflicts. Resolve them and run "
123+
"`ghstack pull --continue` again."
124+
)
125+
if not await sh.agit("diff", "--quiet", exitcode=True):
126+
raise RuntimeError(
127+
"There are unstaged changes. Stage the resolved files with `git add`, "
128+
"then run `ghstack pull --continue` again."
129+
)
130+
131+
merged_tree = await sh.agit("write-tree")
132+
pulled_commit_msg = _replace_source_id(
133+
state["commit_msg"], state["remote_source_id"]
134+
)
135+
pulled_orig = await sh.agit(
136+
"commit-tree",
137+
"-p",
138+
state["parent"],
139+
merged_tree,
140+
input=pulled_commit_msg,
141+
env={
142+
"GIT_AUTHOR_NAME": state["author_name"],
143+
"GIT_AUTHOR_EMAIL": state["author_email"],
144+
},
145+
)
146+
await sh.agit("checkout", pulled_orig)
147+
await _clear_state(sh)
148+
149+
150+
async def main(
151+
github: ghstack.github.GitHubEndpoint,
152+
sh: ghstack.shell.Shell,
153+
remote_name: str,
154+
github_url: str,
155+
pull_request: Optional[str] = None,
156+
continue_: bool = False,
157+
) -> None:
158+
if continue_:
159+
await _finish_pull(sh, await _read_state(sh))
160+
return
161+
162+
params = await _resolve_params(
163+
pull_request=pull_request,
164+
github_url=github_url,
165+
sh=sh,
166+
remote_name=remote_name,
167+
)
168+
head_ref = await github.get_head_ref(**params)
169+
orig_ref = re.sub(r"/head$", "/orig", head_ref)
170+
if orig_ref == head_ref:
171+
raise RuntimeError(f"The ref {head_ref} doesn't look like a ghstack reference")
172+
173+
await ghstack.checkout._fetch_refs(
174+
sh, remote_name=remote_name, refs=[head_ref, orig_ref]
175+
)
176+
remote_head = f"{remote_name}/{head_ref}"
177+
remote_orig = f"{remote_name}/{orig_ref}"
178+
179+
if await sh.agit("merge-base", "--is-ancestor", "HEAD", remote_orig, exitcode=True):
180+
await sh.agit("checkout", remote_orig)
181+
await _clear_state(sh)
182+
return
183+
184+
state_path = await _state_path(sh)
185+
if os.path.exists(state_path):
186+
raise RuntimeError(
187+
"A ghstack pull conflict is already in progress. Resolve it and run "
188+
"`ghstack pull --continue`."
189+
)
190+
191+
if not await _is_worktree_clean(sh):
192+
raise RuntimeError(
193+
"Working tree has uncommitted changes; commit or stash them first."
194+
)
195+
196+
local_commit_msg = await sh.agit("log", "-1", "--format=%B", "HEAD")
197+
m_local_source_id = ghstack.submit.RE_GHSTACK_SOURCE_ID.search(local_commit_msg)
198+
if m_local_source_id is None:
199+
raise RuntimeError(
200+
"HEAD has no ghstack-source-id trailer, so ghstack cannot determine "
201+
"which remote head version your local changes are based on."
202+
)
203+
local_source_id = m_local_source_id.group(1)
204+
205+
old_head = await _find_head_with_tree(
206+
sh, remote_head=remote_head, tree=local_source_id
207+
)
208+
local_tree = await sh.agit("rev-parse", "HEAD^{tree}")
209+
local_imputed_head = await sh.agit(
210+
"commit-tree",
211+
"-p",
212+
old_head,
213+
local_tree,
214+
input="Local changes for ghstack pull\n\n[ghstack-poisoned]\n",
215+
)
216+
217+
returncode, merge_tree_output = await _run_git_for_status(
218+
sh,
219+
["merge-tree", "--write-tree", "--messages", remote_head, local_imputed_head],
220+
)
221+
merged_tree = merge_tree_output.splitlines()[0] if returncode == 0 else None
222+
223+
remote_orig_commit_msg = await sh.agit("log", "-1", "--format=%B", remote_orig)
224+
m_remote_source_id = ghstack.submit.RE_GHSTACK_SOURCE_ID.search(
225+
remote_orig_commit_msg
226+
)
227+
remote_source_id = (
228+
m_remote_source_id.group(1)
229+
if m_remote_source_id is not None
230+
else await sh.agit("rev-parse", f"{remote_orig}^{{tree}}")
231+
)
232+
remote_orig_parent = await sh.agit("rev-parse", f"{remote_orig}^")
233+
234+
author_name = await sh.agit("log", "-1", "--format=%an", "HEAD")
235+
author_email = await sh.agit("log", "-1", "--format=%ae", "HEAD")
236+
state = {
237+
"parent": remote_orig_parent,
238+
"remote_source_id": remote_source_id,
239+
"commit_msg": local_commit_msg,
240+
"author_name": author_name,
241+
"author_email": author_email,
242+
}
243+
244+
if returncode != 0:
245+
await _write_state(sh, state)
246+
recursive_returncode, recursive_output = await _run_git_for_status(
247+
sh, ["merge-recursive", old_head, "--", local_imputed_head, remote_head]
248+
)
249+
if recursive_returncode == 0:
250+
await _finish_pull(sh, state)
251+
return
252+
raise RuntimeError(
253+
"Automatic ghstack pull merge failed. Resolve the conflicts, then run "
254+
"`ghstack pull --continue`.\n" + recursive_output
255+
)
256+
257+
pulled_commit_msg = _replace_source_id(local_commit_msg, remote_source_id)
258+
assert merged_tree is not None
259+
pulled_orig = await sh.agit(
260+
"commit-tree",
261+
"-p",
262+
remote_orig_parent,
263+
merged_tree,
264+
input=pulled_commit_msg,
265+
env={
266+
"GIT_AUTHOR_NAME": author_name,
267+
"GIT_AUTHOR_EMAIL": author_email,
268+
},
269+
)
270+
await sh.agit("checkout", pulled_orig)

src/ghstack/test_prelude.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import ghstack.github_utils
3333
import ghstack.land
3434
import ghstack.log
35+
import ghstack.pull
3536
import ghstack.shell
3637
import ghstack.submit
3738
import ghstack.sync
@@ -49,6 +50,7 @@
4950
"gh_cherry_pick",
5051
"gh_checkout",
5152
"gh_log",
53+
"gh_pull",
5254
"gh_sync",
5355
"GitCommitHash",
5456
"checkout",
@@ -307,6 +309,18 @@ async def gh_log(pull_request: Optional[str] = None, args: Sequence[str] = ()) -
307309
)
308310

309311

312+
async def gh_pull(pull_request: Optional[str] = None, continue_: bool = False) -> None:
313+
self = CTX
314+
return await ghstack.pull.main(
315+
github=self.github,
316+
sh=self.sh,
317+
remote_name="origin",
318+
github_url="github.com",
319+
pull_request=pull_request,
320+
continue_=continue_,
321+
)
322+
323+
310324
async def gh_sync() -> GitCommitHash:
311325
self = CTX
312326
return await ghstack.sync.main(

test/pull/basic.py.test

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from ghstack.test_prelude import *
2+
3+
await init_test()
4+
5+
await commit("A")
6+
(A,) = await gh_submit("Initial")
7+
old_orig = A.orig
8+
9+
await write_file_and_add("remote.txt", "remote change")
10+
await git("commit", "--amend", "--no-edit")
11+
await gh_submit("Remote update")
12+
13+
await checkout(old_orig)
14+
await write_file_and_add("local.txt", "local change")
15+
await git("commit", "--amend", "--no-edit")
16+
17+
await gh_pull()
18+
19+
assert_eq(await git("show", "HEAD:remote.txt"), "remote change")
20+
assert_eq(await git("show", "HEAD:local.txt"), "local change")
21+
22+
# The pulled commit records that it is based on the latest remote orig, so a
23+
# normal submit should not need --force.
24+
await gh_submit("Local update")
25+
26+
ok()

0 commit comments

Comments
 (0)