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.
Files changed (35) hide show
  1. {ghstack-0.14.0 → ghstack-0.15.0}/PKG-INFO +1 -1
  2. {ghstack-0.14.0 → ghstack-0.15.0}/pyproject.toml +1 -1
  3. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/checkout.py +2 -0
  4. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cherry_pick.py +3 -1
  5. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cli.py +113 -12
  6. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_fake.py +18 -0
  7. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/land.py +29 -1
  8. ghstack-0.15.0/src/ghstack/log.py +116 -0
  9. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/submit.py +139 -30
  10. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/test_prelude.py +30 -7
  11. {ghstack-0.14.0 → ghstack-0.15.0}/LICENSE +0 -0
  12. {ghstack-0.14.0 → ghstack-0.15.0}/README.md +0 -0
  13. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/__init__.py +0 -0
  14. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/__main__.py +0 -0
  15. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/action.py +0 -0
  16. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/cache.py +0 -0
  17. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/circleci.py +0 -0
  18. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/circleci_real.py +0 -0
  19. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/config.py +0 -0
  20. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/diff.py +0 -0
  21. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/forensics.py +0 -0
  22. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/git.py +0 -0
  23. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github.py +0 -0
  24. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_real.py +0 -0
  25. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_schema.graphql +0 -0
  26. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/github_utils.py +0 -0
  27. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/gpg_sign.py +0 -0
  28. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/logs.py +0 -0
  29. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/py.typed +0 -0
  30. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/rage.py +0 -0
  31. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/shell.py +0 -0
  32. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/status.py +0 -0
  33. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/trailers.py +0 -0
  34. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/types.py +0 -0
  35. {ghstack-0.14.0 → ghstack-0.15.0}/src/ghstack/unlink.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghstack
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Stack diff support for GitHub
5
5
  Author: Edward Z. Yang
6
6
  Author-email: Edward Z. Yang <ezyang@mit.edu>
@@ -16,7 +16,7 @@ dependencies = [
16
16
  "flake8<8.0.0,>=7.0.0",
17
17
  ]
18
18
  name = "ghstack"
19
- version = "0.14.0"
19
+ version = "0.15.0"
20
20
  description = "Stack diff support for GitHub"
21
21
  readme = "README.md"
22
22
  classifiers = [
@@ -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
- sh.git("fetch", "--prune", remote_name)
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(invoke_without_command=True)
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
- # hidden arguments that we'll pass along to submit if no other command given
56
- @click.option("--message", "-m", default="Update", hidden=True)
57
- @click.option("--update-fields", "-u", is_flag=True, hidden=True)
58
- @click.option("--short", is_flag=True, hidden=True)
59
- @click.option("--force", is_flag=True, hidden=True)
60
- @click.option("--no-skip", is_flag=True, hidden=True)
61
- @click.option("--draft", is_flag=True, hidden=True)
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", "direct_opt", is_flag=True, hidden=True, default=None
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, _ in stack_orig_refs:
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
- pre_branch_state_index[h.commit_id] = PreBranchState(
491
- head_commit_id=GitCommitHash(
492
- self.sh.git(
493
- "rev-parse", f"{self.remote_name}/{ed.head_ref}"
494
- )
495
- ),
496
- base_commit_id=GitCommitHash(
497
- self.sh.git(
498
- "rev-parse", f"{self.remote_name}/{ed.base_ref}"
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
- self.push_updates(diffs_to_submit)
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 as e:
881
+ except RuntimeError:
874
882
  if r["closed"]:
875
- raise RuntimeError(
876
- f"Cannot ghstack a stack with closed PR #{number} whose branch was deleted. "
877
- "If you were just trying to update a later PR in the stack, `git rebase` and try again. "
878
- "Otherwise, you may have been trying to update a PR that was already closed. "
879
- "To disassociate your update from the old PR and open a new PR, "
880
- "run `ghstack unlink`, `git rebase` and then try again."
881
- ) from e
882
- raise
883
- remote_summary = ghstack.git.split_header(rev_list)[0]
884
- m_remote_source_id = RE_GHSTACK_SOURCE_ID.search(remote_summary.commit_msg)
885
- remote_source_id = m_remote_source_id.group(1) if m_remote_source_id else None
886
- m_comment_id = RE_GHSTACK_COMMENT_ID.search(remote_summary.commit_msg)
887
- comment_id = int(m_comment_id.group(1)) if m_comment_id else None
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, diffs_to_submit: List[DiffMeta], *, import_help: bool = True
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(diffs_to_submit, s.number)
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(self, diffs_to_submit: List[DiffMeta], number: int) -> str:
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
- return CTX.github
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: any, callable: Callable[..., any], *args: any, **kwargs: any
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: any, callable: Callable[..., any], expect: str, *args: any, **kwargs: any
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