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.
Files changed (32) hide show
  1. {ghstack-0.9.6 → ghstack-0.10.0}/PKG-INFO +4 -3
  2. {ghstack-0.9.6 → ghstack-0.10.0}/README.md +3 -2
  3. {ghstack-0.9.6 → ghstack-0.10.0}/pyproject.toml +1 -1
  4. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/land.py +3 -1
  5. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/submit.py +13 -14
  6. ghstack-0.10.0/src/ghstack/trailers.py +191 -0
  7. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/__init__.py +0 -0
  8. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/__main__.py +0 -0
  9. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/action.py +0 -0
  10. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/cache.py +0 -0
  11. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/checkout.py +0 -0
  12. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/circleci.py +0 -0
  13. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/circleci_real.py +0 -0
  14. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/cli.py +0 -0
  15. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/config.py +0 -0
  16. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/diff.py +0 -0
  17. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/forensics.py +0 -0
  18. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/git.py +0 -0
  19. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github.py +0 -0
  20. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_fake.py +0 -0
  21. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_real.py +0 -0
  22. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_schema.graphql +0 -0
  23. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/github_utils.py +0 -0
  24. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/gpg_sign.py +0 -0
  25. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/logs.py +0 -0
  26. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/py.typed +0 -0
  27. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/rage.py +0 -0
  28. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/shell.py +0 -0
  29. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/status.py +0 -0
  30. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/test_prelude.py +0 -0
  31. {ghstack-0.9.6 → ghstack-0.10.0}/src/ghstack/types.py +0 -0
  32. {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.9.6
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
- pip3 install ghstack
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 is an end-to-end example of how to do this.
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
- pip3 install ghstack
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 is an end-to-end example of how to do this.
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
 
@@ -13,7 +13,7 @@ dependencies = [
13
13
  "flake8<8.0.0,>=7.0.0",
14
14
  ]
15
15
  name = "ghstack"
16
- version = "0.9.6"
16
+ version = "0.10.0"
17
17
  description = "Stack diff support for GitHub"
18
18
  readme = "README.md"
19
19
  classifiers = [
@@ -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("push", remote_name, f"{remote_name}/{head_ref}:{base_ref}")
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
- # TODO: Probably quicker if we reimplement reinterpret-trailers
967
- # in Python
968
- commit_msg = self.sh.git(
969
- "interpret-trailers",
970
- "--trailer",
971
- f"ghstack-source-id: {diff.source_id}",
972
- *(
973
- ["--trailer", f"ghstack-comment-id: {elab_diff.comment_id}"]
974
- if self.direct
975
- else []
976
- ),
977
- "--trailer",
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