ghstack 0.14.0__tar.gz → 0.15.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ghstack-0.14.0 → ghstack-0.15.0}/PKG-INFO +1 -1
- {ghstack-0.14.0 → ghstack-0.15.0}/pyproject.toml +1 -1
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/checkout.py +2 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cherry_pick.py +3 -1
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cli.py +113 -12
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_fake.py +18 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/land.py +29 -1
- ghstack-0.15.0/src/ghstack/log.py +116 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/submit.py +139 -30
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/test_prelude.py +30 -7
- {ghstack-0.14.0 → ghstack-0.15.0}/LICENSE +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/README.md +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/__init__.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/__main__.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/action.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cache.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/circleci.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/circleci_real.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/config.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/diff.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/forensics.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/git.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_real.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_schema.graphql +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_utils.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/gpg_sign.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/logs.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/py.typed +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/rage.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/shell.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/status.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/trailers.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/types.py +0 -0
- {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/unlink.py +0 -0
|
@@ -52,6 +52,8 @@ def main(
|
|
|
52
52
|
|
|
53
53
|
# If --same-base is specified, check what the new merge-base would be
|
|
54
54
|
if same_base:
|
|
55
|
+
assert default_branch_ref is not None
|
|
56
|
+
assert current_base is not None
|
|
55
57
|
target_ref = remote_name + "/" + orig_ref
|
|
56
58
|
new_base = sh.git("merge-base", default_branch_ref, target_ref)
|
|
57
59
|
|
|
@@ -14,6 +14,7 @@ def main(
|
|
|
14
14
|
sh: ghstack.shell.Shell,
|
|
15
15
|
remote_name: str,
|
|
16
16
|
stack: bool = False,
|
|
17
|
+
no_fetch: bool = False,
|
|
17
18
|
) -> None:
|
|
18
19
|
|
|
19
20
|
params = ghstack.github_utils.parse_pull_request(
|
|
@@ -26,7 +27,8 @@ def main(
|
|
|
26
27
|
"The ref {} doesn't look like a ghstack reference".format(head_ref)
|
|
27
28
|
)
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
if not no_fetch:
|
|
31
|
+
sh.git("fetch", "--prune", remote_name)
|
|
30
32
|
|
|
31
33
|
if stack:
|
|
32
34
|
# Cherry-pick the entire stack from merge-base to the commit
|
|
@@ -13,6 +13,7 @@ import ghstack.circleci_real
|
|
|
13
13
|
import ghstack.config
|
|
14
14
|
import ghstack.github_real
|
|
15
15
|
import ghstack.land
|
|
16
|
+
import ghstack.log
|
|
16
17
|
import ghstack.logs
|
|
17
18
|
import ghstack.rage
|
|
18
19
|
import ghstack.status
|
|
@@ -48,22 +49,74 @@ def cli_context(
|
|
|
48
49
|
yield shell, config, github
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
@click.group(
|
|
52
|
+
@click.group(
|
|
53
|
+
invoke_without_command=True,
|
|
54
|
+
epilog="Running ghstack with no subcommand is equivalent to ghstack submit.",
|
|
55
|
+
)
|
|
52
56
|
@click.pass_context
|
|
53
57
|
@click.version_option(ghstack.__version__, "--version", "-V")
|
|
54
58
|
@click.option("--debug", is_flag=True, help="Log debug information to stderr")
|
|
55
|
-
#
|
|
56
|
-
@click.option(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
# These options are forwarded to the submit command when no subcommand is given.
|
|
60
|
+
@click.option(
|
|
61
|
+
"--message",
|
|
62
|
+
"-m",
|
|
63
|
+
default="Update",
|
|
64
|
+
help="Description of change you made",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--update-fields",
|
|
68
|
+
"-u",
|
|
69
|
+
is_flag=True,
|
|
70
|
+
help="Update GitHub pull request summary from the local commit",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--short", is_flag=True, help="Print only the URL of the latest opened PR to stdout"
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--force",
|
|
77
|
+
is_flag=True,
|
|
78
|
+
help="force push the branch even if your local branch is stale",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--no-skip",
|
|
82
|
+
is_flag=True,
|
|
83
|
+
help="Never skip pushing commits, even if the contents didn't change",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--draft",
|
|
87
|
+
is_flag=True,
|
|
88
|
+
help="Create the pull request in draft mode (only if it has not already been created)",
|
|
89
|
+
)
|
|
62
90
|
@click.option(
|
|
63
|
-
"--direct/--no-direct",
|
|
91
|
+
"--direct/--no-direct",
|
|
92
|
+
"direct_opt",
|
|
93
|
+
is_flag=True,
|
|
94
|
+
default=None,
|
|
95
|
+
help="Create stack that directly merges into master",
|
|
96
|
+
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--base",
|
|
99
|
+
"-B",
|
|
100
|
+
default=None,
|
|
101
|
+
help="Branch to base the stack off of",
|
|
102
|
+
)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--stack/--no-stack",
|
|
105
|
+
"-s/-S",
|
|
106
|
+
is_flag=True,
|
|
107
|
+
default=True,
|
|
108
|
+
help="Submit the entire stack of commits reachable from HEAD",
|
|
109
|
+
)
|
|
110
|
+
@click.option(
|
|
111
|
+
"--reviewer",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Comma-separated list of GitHub usernames to add as reviewers",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--label",
|
|
117
|
+
default=None,
|
|
118
|
+
help="Comma-separated list of labels to add to new PRs",
|
|
64
119
|
)
|
|
65
|
-
@click.option("--base", "-B", default=None, hidden=True)
|
|
66
|
-
@click.option("--stack/--no-stack", "-s/-S", is_flag=True, default=True, hidden=True)
|
|
67
120
|
def main(
|
|
68
121
|
ctx: click.Context,
|
|
69
122
|
debug: bool,
|
|
@@ -76,6 +129,8 @@ def main(
|
|
|
76
129
|
draft: bool,
|
|
77
130
|
base: Optional[str],
|
|
78
131
|
stack: bool,
|
|
132
|
+
reviewer: Optional[str],
|
|
133
|
+
label: Optional[str],
|
|
79
134
|
) -> None:
|
|
80
135
|
"""
|
|
81
136
|
Submit stacks of diffs to Github
|
|
@@ -100,9 +155,20 @@ def main(
|
|
|
100
155
|
base=base,
|
|
101
156
|
stack=stack,
|
|
102
157
|
direct_opt=direct_opt,
|
|
158
|
+
reviewer=reviewer,
|
|
159
|
+
label=label,
|
|
103
160
|
)
|
|
104
161
|
|
|
105
162
|
|
|
163
|
+
@main.command("auth")
|
|
164
|
+
def auth() -> None:
|
|
165
|
+
"""
|
|
166
|
+
Set up GitHub authentication if not already configured.
|
|
167
|
+
"""
|
|
168
|
+
with EXIT_STACK:
|
|
169
|
+
ghstack.config.read_config()
|
|
170
|
+
|
|
171
|
+
|
|
106
172
|
@main.command("action")
|
|
107
173
|
@click.option("--close", is_flag=True, help="Close the specified pull request")
|
|
108
174
|
@click.argument("pull_request", metavar="PR")
|
|
@@ -147,8 +213,13 @@ def checkout(same_base: bool, pull_request: str) -> None:
|
|
|
147
213
|
is_flag=True,
|
|
148
214
|
help="Cherry-pick all commits from the commit to the merge-base with main branch",
|
|
149
215
|
)
|
|
216
|
+
@click.option(
|
|
217
|
+
"--no-fetch",
|
|
218
|
+
is_flag=True,
|
|
219
|
+
help="Skip fetching from the remote before cherry-picking",
|
|
220
|
+
)
|
|
150
221
|
@click.argument("pull_request", metavar="PR")
|
|
151
|
-
def cherry_pick(stack: bool, pull_request: str) -> None:
|
|
222
|
+
def cherry_pick(stack: bool, no_fetch: bool, pull_request: str) -> None:
|
|
152
223
|
"""
|
|
153
224
|
Cherry-pick a PR
|
|
154
225
|
"""
|
|
@@ -159,6 +230,7 @@ def cherry_pick(stack: bool, pull_request: str) -> None:
|
|
|
159
230
|
sh=shell,
|
|
160
231
|
remote_name=config.remote_name,
|
|
161
232
|
stack=stack,
|
|
233
|
+
no_fetch=no_fetch,
|
|
162
234
|
)
|
|
163
235
|
|
|
164
236
|
|
|
@@ -180,6 +252,35 @@ def land(force: bool, pull_request: str) -> None:
|
|
|
180
252
|
)
|
|
181
253
|
|
|
182
254
|
|
|
255
|
+
@main.command(
|
|
256
|
+
"log",
|
|
257
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
258
|
+
)
|
|
259
|
+
@click.option(
|
|
260
|
+
"--pr",
|
|
261
|
+
"pull_request",
|
|
262
|
+
default=None,
|
|
263
|
+
help="Explicit PR (URL or number) to log. If omitted, the PR is inferred "
|
|
264
|
+
"from HEAD's Pull-Request trailer, and local pending changes are shown "
|
|
265
|
+
"on top as a synthesized commit.",
|
|
266
|
+
)
|
|
267
|
+
@click.argument("git_log_args", nargs=-1, type=click.UNPROCESSED)
|
|
268
|
+
def log(pull_request: Optional[str], git_log_args: Tuple[str, ...]) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Show git log for a PR, restricted to that PR's commits.
|
|
271
|
+
Extra arguments are forwarded to git log (e.g. -p).
|
|
272
|
+
"""
|
|
273
|
+
with cli_context(request_github_token=False) as (shell, config, github):
|
|
274
|
+
ghstack.log.main(
|
|
275
|
+
github=github,
|
|
276
|
+
sh=shell,
|
|
277
|
+
remote_name=config.remote_name,
|
|
278
|
+
github_url=config.github_url,
|
|
279
|
+
args=list(git_log_args),
|
|
280
|
+
pull_request=pull_request,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
183
284
|
@main.command("rage")
|
|
184
285
|
@click.option(
|
|
185
286
|
"--latest",
|
|
@@ -463,6 +463,24 @@ class FakeGitHubEndpoint(ghstack.github.GitHubEndpoint):
|
|
|
463
463
|
if m:
|
|
464
464
|
# For now, pretend all branches are not protected
|
|
465
465
|
raise ghstack.github.NotFoundError()
|
|
466
|
+
if m := re.match(r"^repos/([^/]+)/([^/]+)/pulls/([^/]+)$", path):
|
|
467
|
+
state = self.state
|
|
468
|
+
repo = state.repository(m.group(1), m.group(2))
|
|
469
|
+
pr = state.pull_request(repo, GitHubNumber(int(m.group(3))))
|
|
470
|
+
return {
|
|
471
|
+
"number": pr.number,
|
|
472
|
+
"state": "closed" if pr.closed else "open",
|
|
473
|
+
"title": pr.title,
|
|
474
|
+
"body": pr.body,
|
|
475
|
+
}
|
|
476
|
+
if m := re.match(r"^repos/([^/]+)/([^/]+)/issues/comments/([^/]+)$", path):
|
|
477
|
+
state = self.state
|
|
478
|
+
repo = state.repository(m.group(1), m.group(2))
|
|
479
|
+
comment = state.issue_comment(repo, int(m.group(3)))
|
|
480
|
+
return {
|
|
481
|
+
"id": comment.fullDatabaseId,
|
|
482
|
+
"body": comment.body,
|
|
483
|
+
}
|
|
466
484
|
|
|
467
485
|
elif method == "post":
|
|
468
486
|
if m := re.match(r"^repos/([^/]+)/([^/]+)/pulls$", path):
|
|
@@ -145,13 +145,41 @@ to complain to the ghstack authors."""
|
|
|
145
145
|
stack_orig_refs.append((ref, pr_resolved))
|
|
146
146
|
|
|
147
147
|
# OK, actually do the land now
|
|
148
|
-
for orig_ref,
|
|
148
|
+
for orig_ref, pr_resolved in stack_orig_refs:
|
|
149
149
|
try:
|
|
150
150
|
sh.git("cherry-pick", f"{remote_name}/{orig_ref}")
|
|
151
151
|
except BaseException:
|
|
152
152
|
sh.git("cherry-pick", "--abort")
|
|
153
153
|
raise
|
|
154
154
|
|
|
155
|
+
# Add PR number to commit message like GitHub does
|
|
156
|
+
commit_msg = sh.git("log", "-1", "--pretty=%B")
|
|
157
|
+
# Get the original author and committer dates to preserve the commit hash
|
|
158
|
+
author_date = sh.git("log", "-1", "--pretty=%aD")
|
|
159
|
+
committer_date = sh.git("log", "-1", "--pretty=%cD")
|
|
160
|
+
lines = commit_msg.split("\n")
|
|
161
|
+
if lines:
|
|
162
|
+
# Add PR number to the subject line (first line)
|
|
163
|
+
subject = lines[0].rstrip()
|
|
164
|
+
# Only add if not already present
|
|
165
|
+
pr_tag = f"(#{pr_resolved.number})"
|
|
166
|
+
if pr_tag not in subject:
|
|
167
|
+
subject = f"{subject} {pr_tag}"
|
|
168
|
+
lines[0] = subject
|
|
169
|
+
new_msg = "\n".join(lines)
|
|
170
|
+
# Preserve dates to keep the commit hash consistent
|
|
171
|
+
sh.git(
|
|
172
|
+
"commit",
|
|
173
|
+
"--amend",
|
|
174
|
+
"-F",
|
|
175
|
+
"-",
|
|
176
|
+
input=new_msg,
|
|
177
|
+
env={
|
|
178
|
+
"GIT_AUTHOR_DATE": author_date,
|
|
179
|
+
"GIT_COMMITTER_DATE": committer_date,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
155
183
|
# All good! Push!
|
|
156
184
|
maybe_force_arg = []
|
|
157
185
|
if needs_force:
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import ghstack.diff
|
|
8
|
+
import ghstack.github
|
|
9
|
+
import ghstack.github_utils
|
|
10
|
+
import ghstack.shell
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_refs(
|
|
14
|
+
*,
|
|
15
|
+
github: ghstack.github.GitHubEndpoint,
|
|
16
|
+
params: ghstack.github_utils.GitHubPullRequestParams,
|
|
17
|
+
) -> Tuple[str, str]:
|
|
18
|
+
pr_result = github.graphql(
|
|
19
|
+
"""
|
|
20
|
+
query ($owner: String!, $name: String!, $number: Int!) {
|
|
21
|
+
repository(name: $name, owner: $owner) {
|
|
22
|
+
pullRequest(number: $number) {
|
|
23
|
+
headRefName
|
|
24
|
+
baseRefName
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
""",
|
|
29
|
+
**params,
|
|
30
|
+
)
|
|
31
|
+
pr = pr_result["data"]["repository"]["pullRequest"]
|
|
32
|
+
return pr["headRefName"], pr["baseRefName"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(
|
|
36
|
+
github: ghstack.github.GitHubEndpoint,
|
|
37
|
+
sh: ghstack.shell.Shell,
|
|
38
|
+
remote_name: str,
|
|
39
|
+
github_url: str,
|
|
40
|
+
args: List[str],
|
|
41
|
+
pull_request: Optional[str] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if pull_request is not None:
|
|
44
|
+
# Explicit PR: fetch from remote and show what's there. HEAD isn't
|
|
45
|
+
# involved; no synthesized pending-changes commit.
|
|
46
|
+
params = ghstack.github_utils.parse_pull_request(
|
|
47
|
+
pull_request, sh=sh, remote_name=remote_name
|
|
48
|
+
)
|
|
49
|
+
sh.git("fetch", "--prune", remote_name)
|
|
50
|
+
show_pending = False
|
|
51
|
+
else:
|
|
52
|
+
# Infer PR from HEAD's Pull-Request trailer; show the log relative
|
|
53
|
+
# to the local understanding of the remote state (no fetch).
|
|
54
|
+
commit_msg = sh.git("log", "-1", "--format=%B", "HEAD")
|
|
55
|
+
pr = ghstack.diff.PullRequestResolved.search(commit_msg, github_url)
|
|
56
|
+
if pr is None:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"HEAD commit is not associated with a ghstack pull request "
|
|
59
|
+
"(no Pull-Request trailer found). Check out the commit for the "
|
|
60
|
+
"PR you want to log, or pass the PR explicitly."
|
|
61
|
+
)
|
|
62
|
+
params = {
|
|
63
|
+
"github_url": pr.github_url,
|
|
64
|
+
"owner": pr.owner,
|
|
65
|
+
"name": pr.repo,
|
|
66
|
+
"number": pr.number,
|
|
67
|
+
}
|
|
68
|
+
show_pending = True
|
|
69
|
+
|
|
70
|
+
head_ref, base_ref = _resolve_refs(github=github, params=params)
|
|
71
|
+
|
|
72
|
+
remote_head = f"{remote_name}/{head_ref}"
|
|
73
|
+
remote_base = f"{remote_name}/{base_ref}"
|
|
74
|
+
|
|
75
|
+
tip = remote_head
|
|
76
|
+
if show_pending:
|
|
77
|
+
# If the local HEAD tree differs from the remote head tree, synthesize
|
|
78
|
+
# a disposable commit on top of the remote head so pending changes
|
|
79
|
+
# show up as the newest commit in `git log`.
|
|
80
|
+
local_tree = sh.git("rev-parse", "HEAD^{tree}")
|
|
81
|
+
remote_tree = sh.git("rev-parse", f"{remote_head}^{{tree}}")
|
|
82
|
+
if local_tree != remote_tree:
|
|
83
|
+
tip = sh.git(
|
|
84
|
+
"commit-tree",
|
|
85
|
+
local_tree,
|
|
86
|
+
"-p",
|
|
87
|
+
remote_head,
|
|
88
|
+
input="Local pending changes (not yet submitted)\n",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ^remote_base restricts the walk to the head chain (each head commit is a
|
|
92
|
+
# merge of the previous head and a base-update commit; excluding everything
|
|
93
|
+
# reachable from the base ref drops the base-update side).
|
|
94
|
+
#
|
|
95
|
+
# --diff-merges=remerge replays the merge and diffs the stored tree
|
|
96
|
+
# against the auto-merge result, which for ghstack isolates just the
|
|
97
|
+
# user's code edits from the base-update changes that got folded in.
|
|
98
|
+
# This only affects merge commits (non-merges still need -p). Requires
|
|
99
|
+
# git 2.35+. Users who prefer a portable alternative can pass
|
|
100
|
+
# --diff-merges=cc to override.
|
|
101
|
+
log_args = ["--diff-merges=remerge", tip]
|
|
102
|
+
if sh.git("rev-parse", "--verify", "--quiet", remote_base, exitcode=True):
|
|
103
|
+
log_args.append(f"^{remote_base}")
|
|
104
|
+
log_args.extend(args)
|
|
105
|
+
|
|
106
|
+
if sys.stdout.isatty():
|
|
107
|
+
# Let git manage its own pager.
|
|
108
|
+
subprocess.run(["git", "log", *log_args], cwd=sh.cwd, check=False)
|
|
109
|
+
else:
|
|
110
|
+
# In test/piped contexts, capture and write to sys.stdout so the
|
|
111
|
+
# caller can intercept it.
|
|
112
|
+
out = sh.git("log", *log_args)
|
|
113
|
+
if out:
|
|
114
|
+
sys.stdout.write(out)
|
|
115
|
+
if not out.endswith("\n"):
|
|
116
|
+
sys.stdout.write("\n")
|
|
@@ -90,6 +90,7 @@ class PreBranchState:
|
|
|
90
90
|
|
|
91
91
|
# Ya, sometimes we get carriage returns. Crazy right?
|
|
92
92
|
RE_STACK = re.compile(r"Stack.*:\r?\n(\* [^\r\n]+\r?\n)+")
|
|
93
|
+
RE_STACK_PR_NUMBER = re.compile(r"#(\d+)")
|
|
93
94
|
|
|
94
95
|
|
|
95
96
|
# NB: This regex is fuzzy because the D1234567 identifier is typically
|
|
@@ -487,18 +488,20 @@ class Submitter:
|
|
|
487
488
|
d = ghstack.git.convert_header(h, self.github_url)
|
|
488
489
|
if d.pull_request_resolved is not None:
|
|
489
490
|
ed = self.elaborate_diff(d)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
491
|
+
# Skip closed PRs (e.g., after landing) where branches have been deleted
|
|
492
|
+
if not ed.closed:
|
|
493
|
+
pre_branch_state_index[h.commit_id] = PreBranchState(
|
|
494
|
+
head_commit_id=GitCommitHash(
|
|
495
|
+
self.sh.git(
|
|
496
|
+
"rev-parse", f"{self.remote_name}/{ed.head_ref}"
|
|
497
|
+
)
|
|
498
|
+
),
|
|
499
|
+
base_commit_id=GitCommitHash(
|
|
500
|
+
self.sh.git(
|
|
501
|
+
"rev-parse", f"{self.remote_name}/{ed.base_ref}"
|
|
502
|
+
)
|
|
503
|
+
),
|
|
504
|
+
)
|
|
502
505
|
|
|
503
506
|
# NB: deduplicates
|
|
504
507
|
commit_index = {
|
|
@@ -516,7 +519,12 @@ class Submitter:
|
|
|
516
519
|
for h in commits_to_submit
|
|
517
520
|
if h.commit_id in diff_meta_index
|
|
518
521
|
]
|
|
519
|
-
|
|
522
|
+
all_diffs_in_topo_order = [
|
|
523
|
+
diff_meta_index[h.commit_id]
|
|
524
|
+
for h in commits_to_rebase
|
|
525
|
+
if h.commit_id in diff_meta_index
|
|
526
|
+
]
|
|
527
|
+
self.push_updates(diffs_to_submit, all_diffs=all_diffs_in_topo_order)
|
|
520
528
|
if new_head := rebase_index.get(
|
|
521
529
|
old_head := GitCommitHash(self.sh.git("rev-parse", "HEAD"))
|
|
522
530
|
):
|
|
@@ -870,21 +878,24 @@ to disassociate the commit with the pull request, and then try again.
|
|
|
870
878
|
"--header",
|
|
871
879
|
self.remote_name + "/" + branch_orig(username, gh_number),
|
|
872
880
|
)
|
|
873
|
-
except RuntimeError
|
|
881
|
+
except RuntimeError:
|
|
874
882
|
if r["closed"]:
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
883
|
+
# If the PR is closed and the branch is deleted (e.g., after landing),
|
|
884
|
+
# we can't get the remote source ID. Return None for it, which will
|
|
885
|
+
# signal to process_commit that this commit has been landed and should
|
|
886
|
+
# be skipped (not updated).
|
|
887
|
+
remote_source_id = None
|
|
888
|
+
comment_id = None
|
|
889
|
+
else:
|
|
890
|
+
raise
|
|
891
|
+
else:
|
|
892
|
+
remote_summary = ghstack.git.split_header(rev_list)[0]
|
|
893
|
+
m_remote_source_id = RE_GHSTACK_SOURCE_ID.search(remote_summary.commit_msg)
|
|
894
|
+
remote_source_id = (
|
|
895
|
+
m_remote_source_id.group(1) if m_remote_source_id else None
|
|
896
|
+
)
|
|
897
|
+
m_comment_id = RE_GHSTACK_COMMENT_ID.search(remote_summary.commit_msg)
|
|
898
|
+
comment_id = int(m_comment_id.group(1)) if m_comment_id else None
|
|
888
899
|
|
|
889
900
|
return DiffWithGitHubMetadata(
|
|
890
901
|
diff=diff,
|
|
@@ -917,6 +928,48 @@ to disassociate the commit with the pull request, and then try again.
|
|
|
917
928
|
if elab_diff is not None and elab_diff.closed:
|
|
918
929
|
if self.direct:
|
|
919
930
|
self._raise_needs_rebase()
|
|
931
|
+
# If we're trying to submit a closed commit, check if it has been modified
|
|
932
|
+
if elab_diff.remote_source_id is None:
|
|
933
|
+
# The branch was deleted (e.g., after landing). Check if the commit has been
|
|
934
|
+
# modified by comparing source_ids. If the commit is reachable from master with
|
|
935
|
+
# the same source_id (tree hash), it means it was landed and we should skip it.
|
|
936
|
+
# Otherwise, it's been modified and we should raise an error.
|
|
937
|
+
try:
|
|
938
|
+
# Check if there's a commit on master with the same tree (source_id)
|
|
939
|
+
master_commits = self.sh.git(
|
|
940
|
+
"log",
|
|
941
|
+
"--format=%H %T",
|
|
942
|
+
f"{self.remote_name}/{self.base}",
|
|
943
|
+
"-n",
|
|
944
|
+
"100", # Check last 100 commits
|
|
945
|
+
)
|
|
946
|
+
for line in master_commits.split("\n"):
|
|
947
|
+
if not line.strip():
|
|
948
|
+
continue
|
|
949
|
+
commit_hash, tree_hash = line.split()
|
|
950
|
+
if tree_hash == diff.source_id:
|
|
951
|
+
# Found a commit on master with the same tree, so this commit
|
|
952
|
+
# was landed (just with a different commit message/hash)
|
|
953
|
+
return None
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
# Didn't find a matching commit on master, so this is a modified closed commit
|
|
957
|
+
raise RuntimeError(
|
|
958
|
+
f"Cannot ghstack a stack with closed PR #{elab_diff.number} whose branch was deleted. "
|
|
959
|
+
"If you were just trying to update a later PR in the stack, `git rebase` and try again. "
|
|
960
|
+
"Otherwise, you may have been trying to update a PR that was already closed. "
|
|
961
|
+
"To disassociate your update from the old PR and open a new PR, "
|
|
962
|
+
"run `ghstack unlink`, `git rebase` and then try again."
|
|
963
|
+
)
|
|
964
|
+
elif diff.source_id != elab_diff.remote_source_id:
|
|
965
|
+
# The commit has been modified locally
|
|
966
|
+
raise RuntimeError(
|
|
967
|
+
f"Cannot ghstack a stack with closed PR #{elab_diff.number} whose branch was deleted. "
|
|
968
|
+
"If you were just trying to update a later PR in the stack, `git rebase` and try again. "
|
|
969
|
+
"Otherwise, you may have been trying to update a PR that was already closed. "
|
|
970
|
+
"To disassociate your update from the old PR and open a new PR, "
|
|
971
|
+
"run `ghstack unlink`, `git rebase` and then try again."
|
|
972
|
+
)
|
|
920
973
|
return None
|
|
921
974
|
|
|
922
975
|
# Edge case: check if the commit is empty; if so skip submitting
|
|
@@ -1491,7 +1544,11 @@ is closed (likely due to being merged). Please rebase to upstream and try again
|
|
|
1491
1544
|
)
|
|
1492
1545
|
|
|
1493
1546
|
def push_updates(
|
|
1494
|
-
self,
|
|
1547
|
+
self,
|
|
1548
|
+
diffs_to_submit: List[DiffMeta],
|
|
1549
|
+
*,
|
|
1550
|
+
all_diffs: Optional[List[DiffMeta]] = None,
|
|
1551
|
+
import_help: bool = True,
|
|
1495
1552
|
) -> None:
|
|
1496
1553
|
# update pull request information, update bases as necessary
|
|
1497
1554
|
# preferably do this in one network call
|
|
@@ -1524,6 +1581,46 @@ is closed (likely due to being merged). Please rebase to upstream and try again
|
|
|
1524
1581
|
if force_push_branches:
|
|
1525
1582
|
self._git_push(force_push_branches, force=True)
|
|
1526
1583
|
|
|
1584
|
+
# Discover orphan PR numbers from the old stack listing.
|
|
1585
|
+
# We search the full local stack for old stack text, then
|
|
1586
|
+
# collect open PRs that aren't being submitted — both above
|
|
1587
|
+
# and below the submitted ones. Closed PRs are filtered out.
|
|
1588
|
+
submitted_numbers = {s.number for s in diffs_to_submit}
|
|
1589
|
+
orphan_above: List[GitHubNumber] = []
|
|
1590
|
+
orphan_below: List[GitHubNumber] = []
|
|
1591
|
+
for s in all_diffs or diffs_to_submit:
|
|
1592
|
+
old_stack_text: Optional[str] = None
|
|
1593
|
+
if self.direct and s.elab_diff.comment_id is not None:
|
|
1594
|
+
r = self.github.get(
|
|
1595
|
+
f"repos/{self.repo_owner}/{self.repo_name}/issues/comments/{s.elab_diff.comment_id}",
|
|
1596
|
+
)
|
|
1597
|
+
old_stack_text = r.get("body")
|
|
1598
|
+
else:
|
|
1599
|
+
m = RE_STACK.search(s.body)
|
|
1600
|
+
if m:
|
|
1601
|
+
old_stack_text = m.group(0)
|
|
1602
|
+
if old_stack_text:
|
|
1603
|
+
old_pr_numbers = [
|
|
1604
|
+
GitHubNumber(int(n))
|
|
1605
|
+
for n in RE_STACK_PR_NUMBER.findall(old_stack_text)
|
|
1606
|
+
]
|
|
1607
|
+
if not old_pr_numbers:
|
|
1608
|
+
continue
|
|
1609
|
+
seen_submitted = False
|
|
1610
|
+
for num in old_pr_numbers:
|
|
1611
|
+
if num in submitted_numbers:
|
|
1612
|
+
seen_submitted = True
|
|
1613
|
+
continue
|
|
1614
|
+
pr_info = self.github.get(
|
|
1615
|
+
f"repos/{self.repo_owner}/{self.repo_name}/pulls/{num}",
|
|
1616
|
+
)
|
|
1617
|
+
if pr_info.get("state") == "open":
|
|
1618
|
+
if seen_submitted:
|
|
1619
|
+
orphan_below.append(num)
|
|
1620
|
+
else:
|
|
1621
|
+
orphan_above.append(num)
|
|
1622
|
+
break
|
|
1623
|
+
|
|
1527
1624
|
for s in reversed(diffs_to_submit):
|
|
1528
1625
|
# NB: GraphQL API does not support modifying PRs
|
|
1529
1626
|
assert not s.closed
|
|
@@ -1541,7 +1638,9 @@ is closed (likely due to being merged). Please rebase to upstream and try again
|
|
|
1541
1638
|
base_kwargs["base"] = s.base
|
|
1542
1639
|
else:
|
|
1543
1640
|
assert s.base == s.elab_diff.base_ref
|
|
1544
|
-
stack_desc = self._format_stack(
|
|
1641
|
+
stack_desc = self._format_stack(
|
|
1642
|
+
diffs_to_submit, s.number, orphan_above, orphan_below
|
|
1643
|
+
)
|
|
1545
1644
|
self.github.patch(
|
|
1546
1645
|
"repos/{owner}/{repo}/pulls/{number}".format(
|
|
1547
1646
|
owner=self.repo_owner, repo=self.repo_name, number=s.number
|
|
@@ -1761,14 +1860,24 @@ is closed (likely due to being merged). Please rebase to upstream and try again
|
|
|
1761
1860
|
# - want "as complete" a tree as possible; this may involve
|
|
1762
1861
|
# poking around the xrefs to find out all the other PRs
|
|
1763
1862
|
# involved in the stack
|
|
1764
|
-
def _format_stack(
|
|
1863
|
+
def _format_stack(
|
|
1864
|
+
self,
|
|
1865
|
+
diffs_to_submit: List[DiffMeta],
|
|
1866
|
+
number: int,
|
|
1867
|
+
orphan_above: Sequence[GitHubNumber] = (),
|
|
1868
|
+
orphan_below: Sequence[GitHubNumber] = (),
|
|
1869
|
+
) -> str:
|
|
1765
1870
|
rows = []
|
|
1871
|
+
for n in orphan_above:
|
|
1872
|
+
rows.append(f"* #{n}")
|
|
1766
1873
|
# NB: top is top of stack, opposite of update order
|
|
1767
1874
|
for s in diffs_to_submit:
|
|
1768
1875
|
if s.number == number:
|
|
1769
1876
|
rows.append(f"* __->__ #{s.number}")
|
|
1770
1877
|
else:
|
|
1771
1878
|
rows.append(f"* #{s.number}")
|
|
1879
|
+
for n in orphan_below:
|
|
1880
|
+
rows.append(f"* #{n}")
|
|
1772
1881
|
return self.stack_header + ":\n" + "\n".join(rows) + "\n"
|
|
1773
1882
|
|
|
1774
1883
|
def _default_title_and_body(
|
|
@@ -8,7 +8,7 @@ import shutil
|
|
|
8
8
|
import stat
|
|
9
9
|
import sys
|
|
10
10
|
import tempfile
|
|
11
|
-
from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple, Union
|
|
11
|
+
from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple, Type, Union
|
|
12
12
|
|
|
13
13
|
from expecttest import assert_expected_inline
|
|
14
14
|
|
|
@@ -19,6 +19,7 @@ import ghstack.github
|
|
|
19
19
|
import ghstack.github_fake
|
|
20
20
|
import ghstack.github_utils
|
|
21
21
|
import ghstack.land
|
|
22
|
+
import ghstack.log
|
|
22
23
|
import ghstack.shell
|
|
23
24
|
import ghstack.submit
|
|
24
25
|
import ghstack.unlink
|
|
@@ -34,6 +35,7 @@ __all__ = [
|
|
|
34
35
|
"gh_unlink",
|
|
35
36
|
"gh_cherry_pick",
|
|
36
37
|
"gh_checkout",
|
|
38
|
+
"gh_log",
|
|
37
39
|
"GitCommitHash",
|
|
38
40
|
"checkout",
|
|
39
41
|
"amend",
|
|
@@ -270,6 +272,18 @@ def gh_checkout(pull_request: str, same_base: bool = False) -> None:
|
|
|
270
272
|
)
|
|
271
273
|
|
|
272
274
|
|
|
275
|
+
def gh_log(pull_request: Optional[str] = None, args: Sequence[str] = ()) -> None:
|
|
276
|
+
self = CTX
|
|
277
|
+
return ghstack.log.main(
|
|
278
|
+
github=self.github,
|
|
279
|
+
sh=self.sh,
|
|
280
|
+
remote_name="origin",
|
|
281
|
+
github_url="github.com",
|
|
282
|
+
args=list(args),
|
|
283
|
+
pull_request=pull_request,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
273
287
|
def write_file_and_add(filename: str, contents: str) -> None:
|
|
274
288
|
self = CTX
|
|
275
289
|
with self.sh.open(filename, "w") as f:
|
|
@@ -392,8 +406,10 @@ def is_direct() -> bool:
|
|
|
392
406
|
return CTX.direct
|
|
393
407
|
|
|
394
408
|
|
|
395
|
-
def get_github() -> ghstack.github_fake.FakeGitHubEndpoint:
|
|
396
|
-
|
|
409
|
+
def get_github() -> "ghstack.github_fake.FakeGitHubEndpoint":
|
|
410
|
+
github = CTX.github
|
|
411
|
+
assert isinstance(github, ghstack.github_fake.FakeGitHubEndpoint)
|
|
412
|
+
return github
|
|
397
413
|
|
|
398
414
|
|
|
399
415
|
def get_pr_reviewers(pr_number: int) -> List[str]:
|
|
@@ -417,8 +433,11 @@ def assert_eq(a: Any, b: Any) -> None:
|
|
|
417
433
|
|
|
418
434
|
|
|
419
435
|
def assert_raises(
|
|
420
|
-
exc_type:
|
|
421
|
-
|
|
436
|
+
exc_type: Type[BaseException],
|
|
437
|
+
callable: Callable[..., Any],
|
|
438
|
+
*args: Any,
|
|
439
|
+
**kwargs: Any,
|
|
440
|
+
) -> None:
|
|
422
441
|
try:
|
|
423
442
|
callable(*args, **kwargs)
|
|
424
443
|
except exc_type:
|
|
@@ -427,8 +446,12 @@ def assert_raises(
|
|
|
427
446
|
|
|
428
447
|
|
|
429
448
|
def assert_expected_raises_inline(
|
|
430
|
-
exc_type:
|
|
431
|
-
|
|
449
|
+
exc_type: Type[BaseException],
|
|
450
|
+
callable: Callable[..., Any],
|
|
451
|
+
expect: str,
|
|
452
|
+
*args: Any,
|
|
453
|
+
**kwargs: Any,
|
|
454
|
+
) -> None:
|
|
432
455
|
try:
|
|
433
456
|
callable(*args, **kwargs)
|
|
434
457
|
except exc_type as e:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|