ghstack 0.9.6__tar.gz → 0.10.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.9.6 → ghstack-0.10.0}/PKG-INFO +4 -3
- {ghstack-0.9.6 → ghstack-0.10.0}/README.md +3 -2
- {ghstack-0.9.6 → ghstack-0.10.0}/pyproject.toml +1 -1
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/land.py +3 -1
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/submit.py +13 -14
- ghstack-0.10.0/src/ghstack/trailers.py +191 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/__init__.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/__main__.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/action.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/cache.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/checkout.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/circleci.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/circleci_real.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/cli.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/config.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/diff.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/forensics.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/git.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_fake.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_real.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_schema.graphql +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_utils.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/gpg_sign.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/logs.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/py.typed +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/rage.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/shell.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/status.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/test_prelude.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/types.py +0 -0
- {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/unlink.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ghstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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>
|
|
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
Conveniently submit stacks of diffs to GitHub as separate pull requests.
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
|
|
26
|
+
uv tool install ghstack
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
ghstack is tested with
|
|
@@ -75,7 +75,8 @@ normal GitHub UI, as their branch bases won't be master. Use
|
|
|
75
75
|
a ghstack'ed pull request.
|
|
76
76
|
|
|
77
77
|
You can also setup a GitHub action to allow a bot land.
|
|
78
|
-
https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/issue_comment.yml
|
|
78
|
+
[ghstack_land_example](https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/issue_comment.yml)
|
|
79
|
+
is an end-to-end example of how to do this.
|
|
79
80
|
|
|
80
81
|
## Structure of submitted pull requests
|
|
81
82
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Conveniently submit stacks of diffs to GitHub as separate pull requests.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
|
|
6
|
+
uv tool install ghstack
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
ghstack is tested with
|
|
@@ -55,7 +55,8 @@ normal GitHub UI, as their branch bases won't be master. Use
|
|
|
55
55
|
a ghstack'ed pull request.
|
|
56
56
|
|
|
57
57
|
You can also setup a GitHub action to allow a bot land.
|
|
58
|
-
https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/issue_comment.yml
|
|
58
|
+
[ghstack_land_example](https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/issue_comment.yml)
|
|
59
|
+
is an end-to-end example of how to do this.
|
|
59
60
|
|
|
60
61
|
## Structure of submitted pull requests
|
|
61
62
|
|
|
@@ -168,7 +168,9 @@ to complain to the ghstack authors."""
|
|
|
168
168
|
# TODO: regex here so janky
|
|
169
169
|
base_ref = re.sub(r"/orig$", "/base", orig_ref)
|
|
170
170
|
head_ref = re.sub(r"/orig$", "/head", orig_ref)
|
|
171
|
-
sh.git(
|
|
171
|
+
sh.git(
|
|
172
|
+
"push", remote_name, f"{remote_name}/{head_ref}:refs/heads/{base_ref}"
|
|
173
|
+
)
|
|
172
174
|
github.notify_merged(pr_resolved)
|
|
173
175
|
|
|
174
176
|
# All good! Push!
|
|
@@ -15,6 +15,7 @@ import ghstack.github_utils
|
|
|
15
15
|
import ghstack.gpg_sign
|
|
16
16
|
import ghstack.logs
|
|
17
17
|
import ghstack.shell
|
|
18
|
+
import ghstack.trailers
|
|
18
19
|
from ghstack.types import GhNumber, GitCommitHash, GitHubNumber, GitHubRepositoryId
|
|
19
20
|
|
|
20
21
|
# Either "base", "head" or "orig"; which of the ghstack generated
|
|
@@ -963,20 +964,18 @@ to disassociate the commit with the pull request, and then try again.
|
|
|
963
964
|
commit_msg = self._update_source_id(diff.summary, elab_diff)
|
|
964
965
|
else:
|
|
965
966
|
# Need to insert metadata for the first time
|
|
966
|
-
#
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
"
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
f"Pull-Request-resolved: {pull_request_resolved.url()}",
|
|
979
|
-
input=strip_mentions(diff.summary.rstrip()),
|
|
967
|
+
# Using our Python implementation of interpret-trailers
|
|
968
|
+
trailers_to_add = [f"ghstack-source-id: {diff.source_id}"]
|
|
969
|
+
|
|
970
|
+
if self.direct:
|
|
971
|
+
trailers_to_add.append(f"ghstack-comment-id: {elab_diff.comment_id}")
|
|
972
|
+
|
|
973
|
+
trailers_to_add.append(
|
|
974
|
+
f"Pull-Request-resolved: {pull_request_resolved.url()}"
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
commit_msg = ghstack.trailers.interpret_trailers(
|
|
978
|
+
strip_mentions(diff.summary.rstrip()), trailers_to_add
|
|
980
979
|
)
|
|
981
980
|
|
|
982
981
|
return DiffMeta(
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
# Compile regexes once at module level for better performance
|
|
7
|
+
TRAILER_RE = re.compile(r"^([A-Za-z0-9_-]+)(\s*:\s*)(.*)$")
|
|
8
|
+
CONTINUATION_RE = re.compile(r"^\s+\S.*$")
|
|
9
|
+
|
|
10
|
+
# Git-generated trailer prefixes
|
|
11
|
+
GIT_GENERATED_PREFIXES = ["Signed-off-by: ", "(cherry picked from commit "]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_message(message: str) -> Tuple[str, str, str]:
|
|
15
|
+
"""
|
|
16
|
+
Parse a Git commit message into subject, body, and trailers.
|
|
17
|
+
|
|
18
|
+
According to the Git documentation, trailers are:
|
|
19
|
+
- A group of one or more lines that is all trailers, or contains at least one
|
|
20
|
+
Git-generated or user-configured trailer and consists of at least 25% trailers.
|
|
21
|
+
- The group must be preceded by one or more empty (or whitespace-only) lines.
|
|
22
|
+
- The group must either be at the end of the message or be the last non-whitespace
|
|
23
|
+
lines before a line that starts with "---" (the "divider").
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: The commit message to parse.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A tuple containing:
|
|
30
|
+
- subject: The first line of the message
|
|
31
|
+
- body: The body of the message (may be empty)
|
|
32
|
+
- trailers: The trailer block as a raw string (may be empty)
|
|
33
|
+
"""
|
|
34
|
+
if not message:
|
|
35
|
+
return "", "", ""
|
|
36
|
+
|
|
37
|
+
# Split into lines and get the subject (first line)
|
|
38
|
+
lines = message.splitlines()
|
|
39
|
+
subject = lines[0] if lines else ""
|
|
40
|
+
|
|
41
|
+
if len(lines) <= 1:
|
|
42
|
+
return subject, "", ""
|
|
43
|
+
|
|
44
|
+
# Remove subject
|
|
45
|
+
message_lines = lines[1:]
|
|
46
|
+
|
|
47
|
+
if not message_lines:
|
|
48
|
+
return subject, "", ""
|
|
49
|
+
|
|
50
|
+
# Find where the trailer block starts
|
|
51
|
+
trailer_start = find_trailer_block_start(message_lines)
|
|
52
|
+
|
|
53
|
+
if trailer_start == -1:
|
|
54
|
+
# No trailer block found, everything after subject is body
|
|
55
|
+
body = "\n".join(message_lines).strip()
|
|
56
|
+
return subject, body, ""
|
|
57
|
+
|
|
58
|
+
# Body is everything between subject and trailers (with empty lines trimmed)
|
|
59
|
+
body = "\n".join(message_lines[:trailer_start]).strip()
|
|
60
|
+
|
|
61
|
+
# Keep trailers as a raw string
|
|
62
|
+
trailers = "\n".join(message_lines[trailer_start:]).strip()
|
|
63
|
+
|
|
64
|
+
return subject, body, trailers
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_trailer_block_start(lines: List[str]) -> int:
|
|
68
|
+
"""
|
|
69
|
+
Find the start index of the trailer block in a list of lines.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
lines: List of message lines (without subject and divider).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Index of the first line of the trailer block, or -1 if no trailer block is found.
|
|
76
|
+
"""
|
|
77
|
+
# Remove trailing empty lines
|
|
78
|
+
trimmed_lines = list(reversed([line for line in reversed(lines) if line.strip()]))
|
|
79
|
+
|
|
80
|
+
if not trimmed_lines:
|
|
81
|
+
return -1
|
|
82
|
+
|
|
83
|
+
# Find the last non-empty block
|
|
84
|
+
block_indices = [-1] + [i for i, line in enumerate(lines) if not line.strip()]
|
|
85
|
+
|
|
86
|
+
# Try blocks from last to first
|
|
87
|
+
for i in range(len(block_indices) - 1, -1, -1):
|
|
88
|
+
start_idx = block_indices[i] + 1
|
|
89
|
+
# If we're at the beginning or checking the whole message
|
|
90
|
+
if i == 0 or start_idx == 0:
|
|
91
|
+
# Check if the whole remaining content is a trailer block
|
|
92
|
+
if is_trailer_block(lines[start_idx:]):
|
|
93
|
+
return start_idx
|
|
94
|
+
# No more blocks to check
|
|
95
|
+
return -1
|
|
96
|
+
|
|
97
|
+
# Check if the block after this blank line is a trailer block
|
|
98
|
+
end_idx = block_indices[i + 1] if i + 1 < len(block_indices) else len(lines)
|
|
99
|
+
if is_trailer_block(lines[start_idx:end_idx]):
|
|
100
|
+
return start_idx
|
|
101
|
+
|
|
102
|
+
return -1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_trailer_block(lines: List[str]) -> bool:
|
|
106
|
+
"""
|
|
107
|
+
Determine if the given lines form a trailer block.
|
|
108
|
+
|
|
109
|
+
A block is a trailer block if:
|
|
110
|
+
1. All lines are trailers, or
|
|
111
|
+
2. At least one Git-generated trailer exists and at least 25% of lines are trailers
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
lines: List of lines to check.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if the lines form a trailer block, False otherwise.
|
|
118
|
+
"""
|
|
119
|
+
# Filter out empty lines
|
|
120
|
+
content_lines = [line for line in lines if line.strip()]
|
|
121
|
+
|
|
122
|
+
if not content_lines:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
trailer_lines = 0
|
|
126
|
+
non_trailer_lines = 0
|
|
127
|
+
has_git_generated_trailer = False
|
|
128
|
+
|
|
129
|
+
i = 0
|
|
130
|
+
while i < len(content_lines):
|
|
131
|
+
line = content_lines[i]
|
|
132
|
+
|
|
133
|
+
# Skip continuation lines (they belong to the previous trailer)
|
|
134
|
+
if CONTINUATION_RE.match(line):
|
|
135
|
+
i += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Check if it's a git-generated trailer
|
|
139
|
+
if any(line.startswith(prefix) for prefix in GIT_GENERATED_PREFIXES):
|
|
140
|
+
has_git_generated_trailer = True
|
|
141
|
+
trailer_lines += 1
|
|
142
|
+
elif TRAILER_RE.match(line):
|
|
143
|
+
# Regular trailer
|
|
144
|
+
trailer_lines += 1
|
|
145
|
+
else:
|
|
146
|
+
# Not a trailer line
|
|
147
|
+
non_trailer_lines += 1
|
|
148
|
+
|
|
149
|
+
i += 1
|
|
150
|
+
|
|
151
|
+
# A block is a trailer block if all lines are trailers OR
|
|
152
|
+
# it has at least one git-generated trailer and >= 25% of lines are trailers
|
|
153
|
+
return (trailer_lines > 0 and non_trailer_lines == 0) or (
|
|
154
|
+
has_git_generated_trailer and trailer_lines * 3 >= non_trailer_lines
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def interpret_trailers(message: str, trailers_to_add: List[str]) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Add trailers to a commit message, mimicking git interpret-trailers.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
message: The commit message to add trailers to
|
|
164
|
+
trailers_to_add: List of trailers to add in the format "Key: Value"
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The commit message with trailers added
|
|
168
|
+
"""
|
|
169
|
+
subject, body, existing_trailers = parse_message(message)
|
|
170
|
+
|
|
171
|
+
# Create a new list with all trailers (existing + new)
|
|
172
|
+
all_trailers = []
|
|
173
|
+
if existing_trailers:
|
|
174
|
+
all_trailers.append(existing_trailers)
|
|
175
|
+
|
|
176
|
+
all_trailers.extend(trailers_to_add)
|
|
177
|
+
|
|
178
|
+
# Build the new message
|
|
179
|
+
new_message = subject
|
|
180
|
+
|
|
181
|
+
if body:
|
|
182
|
+
new_message += "\n\n" + body
|
|
183
|
+
|
|
184
|
+
if all_trailers:
|
|
185
|
+
if body or (not body and existing_trailers):
|
|
186
|
+
new_message += "\n"
|
|
187
|
+
if not existing_trailers:
|
|
188
|
+
new_message += "\n"
|
|
189
|
+
new_message += "\n" + "\n".join(all_trailers)
|
|
190
|
+
|
|
191
|
+
return new_message
|
|
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
|
|
File without changes
|