ghstack 0.9.6__tar.gz → 0.11.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.11.0}/PKG-INFO +7 -6
- {ghstack-0.9.6 → ghstack-0.11.0}/README.md +6 -5
- {ghstack-0.9.6 → ghstack-0.11.0}/pyproject.toml +1 -1
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/diff.py +2 -2
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_real.py +66 -29
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/land.py +21 -14
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/submit.py +14 -23
- ghstack-0.11.0/src/ghstack/trailers.py +191 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/__init__.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/__main__.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/action.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/cache.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/checkout.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/circleci.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/circleci_real.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/cli.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/config.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/forensics.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/git.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_fake.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_schema.graphql +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_utils.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/gpg_sign.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/logs.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/py.typed +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/rage.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/shell.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/status.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/test_prelude.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/types.py +0 -0
- {ghstack-0.9.6 → ghstack-0.11.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.11.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
|
|
@@ -69,13 +69,14 @@ resolve conflicts in *each* PR, not just for the entire stack.)
|
|
|
69
69
|
**How do I start a new feature?** Just checkout master on a new
|
|
70
70
|
branch, and start working on a fresh branch.
|
|
71
71
|
|
|
72
|
-
**
|
|
73
|
-
normal GitHub UI, as their branch bases won't be master.
|
|
74
|
-
`ghstack land $PR_URL` (or alternatively `ghstack land #PR_NUM`) to land
|
|
72
|
+
**How do I merge my changes?** Warning: You will NOT be able to merge these
|
|
73
|
+
commits using the normal GitHub UI, as their branch bases won't be master.
|
|
74
|
+
Use `ghstack land $PR_URL` (or alternatively `ghstack land #PR_NUM`) to land
|
|
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/
|
|
78
|
+
[ghstack_land_example](https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/ghstack_land.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
|
|
@@ -49,13 +49,14 @@ resolve conflicts in *each* PR, not just for the entire stack.)
|
|
|
49
49
|
**How do I start a new feature?** Just checkout master on a new
|
|
50
50
|
branch, and start working on a fresh branch.
|
|
51
51
|
|
|
52
|
-
**
|
|
53
|
-
normal GitHub UI, as their branch bases won't be master.
|
|
54
|
-
`ghstack land $PR_URL` (or alternatively `ghstack land #PR_NUM`) to land
|
|
52
|
+
**How do I merge my changes?** Warning: You will NOT be able to merge these
|
|
53
|
+
commits using the normal GitHub UI, as their branch bases won't be master.
|
|
54
|
+
Use `ghstack land $PR_URL` (or alternatively `ghstack land #PR_NUM`) to land
|
|
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/
|
|
58
|
+
[ghstack_land_example](https://github.com/Chillee/ghstack_land_example/blob/main/.github/workflows/ghstack_land.yml)
|
|
59
|
+
is an end-to-end example of how to do this.
|
|
59
60
|
|
|
60
61
|
## Structure of submitted pull requests
|
|
61
62
|
|
|
@@ -14,7 +14,7 @@ RE_GH_METADATA = re.compile(
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
RAW_PULL_REQUEST_RESOLVED = (
|
|
17
|
-
r"(Pull Request resolved|Pull-Request-resolved): "
|
|
17
|
+
r"(Pull Request resolved|Pull-Request-resolved|Pull-Request): "
|
|
18
18
|
r"https://{github_url}/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pull/(?P<number>[0-9]+)"
|
|
19
19
|
)
|
|
20
20
|
|
|
@@ -93,7 +93,7 @@ class Diff:
|
|
|
93
93
|
# be the same.
|
|
94
94
|
source_id: str
|
|
95
95
|
|
|
96
|
-
# The contents of 'Pull-Request
|
|
96
|
+
# The contents of 'Pull-Request'. This is None for
|
|
97
97
|
# diffs that haven't been submitted by ghstack. For BC reasons,
|
|
98
98
|
# this also accepts gh-metadata.
|
|
99
99
|
pull_request_resolved: Optional[PullRequestResolved]
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import re
|
|
6
|
+
import time
|
|
6
7
|
from typing import Any, Dict, Optional, Sequence, Tuple, Union
|
|
7
8
|
|
|
8
9
|
import requests
|
|
9
10
|
|
|
10
11
|
import ghstack.github
|
|
11
12
|
|
|
13
|
+
MAX_RETRIES = 5
|
|
14
|
+
INITIAL_BACKOFF_SECONDS = 60
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
|
|
14
18
|
"""
|
|
@@ -155,32 +159,63 @@ class RealGitHubEndpoint(ghstack.github.GitHubEndpoint):
|
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
url = self.rest_endpoint.format(github_url=self.github_url) + "/" + path
|
|
158
|
-
logging.debug("# {} {}".format(method, url))
|
|
159
|
-
logging.debug("Request body:\n{}".format(json.dumps(kwargs, indent=1)))
|
|
160
162
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
proxies=self._proxies(),
|
|
166
|
-
verify=self.verify,
|
|
167
|
-
cert=self.cert,
|
|
168
|
-
)
|
|
163
|
+
backoff_seconds = INITIAL_BACKOFF_SECONDS
|
|
164
|
+
for attempt in range(0, MAX_RETRIES):
|
|
165
|
+
logging.debug("# {} {}".format(method, url))
|
|
166
|
+
logging.debug("Request body:\n{}".format(json.dumps(kwargs, indent=1)))
|
|
169
167
|
|
|
170
|
-
|
|
168
|
+
resp: requests.Response = getattr(requests, method)(
|
|
169
|
+
url,
|
|
170
|
+
json=kwargs,
|
|
171
|
+
headers=headers,
|
|
172
|
+
proxies=self._proxies(),
|
|
173
|
+
verify=self.verify,
|
|
174
|
+
cert=self.cert,
|
|
175
|
+
)
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
r = resp.json()
|
|
174
|
-
except ValueError:
|
|
175
|
-
logging.debug("Response body:\n{}".format(r.text))
|
|
176
|
-
raise
|
|
177
|
-
else:
|
|
178
|
-
pretty_json = json.dumps(r, indent=1)
|
|
179
|
-
logging.debug("Response JSON:\n{}".format(pretty_json))
|
|
177
|
+
logging.debug("Response status: {}".format(resp.status_code))
|
|
180
178
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
try:
|
|
180
|
+
r = resp.json()
|
|
181
|
+
except ValueError:
|
|
182
|
+
logging.debug("Response body:\n{}".format(r.text))
|
|
183
|
+
raise
|
|
184
|
+
else:
|
|
185
|
+
pretty_json = json.dumps(r, indent=1)
|
|
186
|
+
logging.debug("Response JSON:\n{}".format(pretty_json))
|
|
187
|
+
|
|
188
|
+
# Per Github rate limiting: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#exceeding-the-rate-limit
|
|
189
|
+
if resp.status_code in (403, 429):
|
|
190
|
+
remaining_count = resp.headers.get("x-ratelimit-remaining")
|
|
191
|
+
reset_time = resp.headers.get("x-ratelimit-reset")
|
|
192
|
+
|
|
193
|
+
if remaining_count == "0" and reset_time:
|
|
194
|
+
sleep_time = int(reset_time) - int(time.time())
|
|
195
|
+
logging.warning(
|
|
196
|
+
f"Rate limit exceeded. Sleeping until reset in {sleep_time} seconds."
|
|
197
|
+
)
|
|
198
|
+
time.sleep(sleep_time)
|
|
199
|
+
continue
|
|
200
|
+
else:
|
|
201
|
+
retry_after_seconds = resp.headers.get("retry-after")
|
|
202
|
+
if retry_after_seconds:
|
|
203
|
+
sleep_time = int(retry_after_seconds)
|
|
204
|
+
logging.warning(
|
|
205
|
+
f"Secondary rate limit hit. Sleeping for {sleep_time} seconds."
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
sleep_time = backoff_seconds
|
|
209
|
+
logging.warning(
|
|
210
|
+
f"Secondary rate limit hit. Sleeping for {sleep_time} seconds (exponential backoff)."
|
|
211
|
+
)
|
|
212
|
+
backoff_seconds *= 2
|
|
213
|
+
time.sleep(sleep_time)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if resp.status_code == 404:
|
|
217
|
+
raise ghstack.github.NotFoundError(
|
|
218
|
+
"""\
|
|
184
219
|
GitHub raised a 404 error on the request for
|
|
185
220
|
{url}.
|
|
186
221
|
Usually, this doesn't actually mean the page doesn't exist; instead, it
|
|
@@ -190,13 +225,15 @@ https://{github_url}/settings/tokens and DOUBLE CHECK that you checked
|
|
|
190
225
|
"public_repo" for permissions, and update ~/.ghstackrc with your new
|
|
191
226
|
value.
|
|
192
227
|
""".format(
|
|
193
|
-
|
|
228
|
+
url=url, github_url=self.github_url
|
|
229
|
+
)
|
|
194
230
|
)
|
|
195
|
-
)
|
|
196
231
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
try:
|
|
233
|
+
resp.raise_for_status()
|
|
234
|
+
except requests.HTTPError:
|
|
235
|
+
raise RuntimeError(pretty_json)
|
|
201
236
|
|
|
202
|
-
|
|
237
|
+
return r
|
|
238
|
+
|
|
239
|
+
raise RuntimeError("Exceeded maximum retries due to GitHub rate limiting")
|
|
@@ -152,11 +152,18 @@ to complain to the ghstack authors."""
|
|
|
152
152
|
sh.git("cherry-pick", "--abort")
|
|
153
153
|
raise
|
|
154
154
|
|
|
155
|
+
# All good! Push!
|
|
156
|
+
maybe_force_arg = []
|
|
157
|
+
if needs_force:
|
|
158
|
+
maybe_force_arg = ["--force-with-lease"]
|
|
159
|
+
sh.git(
|
|
160
|
+
"push", *maybe_force_arg, remote_name, f"HEAD:refs/heads/{default_branch}"
|
|
161
|
+
)
|
|
162
|
+
|
|
155
163
|
# Advance base to head to "close" the PR for all PRs.
|
|
156
|
-
# This
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
# fail
|
|
164
|
+
# This happens after the cherry-pick and push, because the cherry-picks
|
|
165
|
+
# can fail (merge conflict) and the push can also fail (race condition)
|
|
166
|
+
|
|
160
167
|
# TODO: It might be helpful to advance orig to reflect the true
|
|
161
168
|
# state of upstream at the time we are doing the land, and then
|
|
162
169
|
# directly *merge* head into base, so that the PR accurately
|
|
@@ -168,24 +175,24 @@ to complain to the ghstack authors."""
|
|
|
168
175
|
# TODO: regex here so janky
|
|
169
176
|
base_ref = re.sub(r"/orig$", "/base", orig_ref)
|
|
170
177
|
head_ref = re.sub(r"/orig$", "/head", orig_ref)
|
|
171
|
-
sh.git(
|
|
178
|
+
sh.git(
|
|
179
|
+
"push", remote_name, f"{remote_name}/{head_ref}:refs/heads/{base_ref}"
|
|
180
|
+
)
|
|
172
181
|
github.notify_merged(pr_resolved)
|
|
173
182
|
|
|
174
|
-
# All good! Push!
|
|
175
|
-
maybe_force_arg = []
|
|
176
|
-
if needs_force:
|
|
177
|
-
maybe_force_arg = ["--force-with-lease"]
|
|
178
|
-
sh.git(
|
|
179
|
-
"push", *maybe_force_arg, remote_name, f"HEAD:refs/heads/{default_branch}"
|
|
180
|
-
)
|
|
181
|
-
|
|
182
183
|
# Delete the branches
|
|
183
184
|
for orig_ref, _ in stack_orig_refs:
|
|
184
185
|
# TODO: regex here so janky
|
|
185
186
|
base_ref = re.sub(r"/orig$", "/base", orig_ref)
|
|
186
187
|
head_ref = re.sub(r"/orig$", "/head", orig_ref)
|
|
187
188
|
try:
|
|
188
|
-
sh.git("push", remote_name, "--delete", orig_ref, base_ref
|
|
189
|
+
sh.git("push", remote_name, "--delete", orig_ref, base_ref)
|
|
190
|
+
except RuntimeError:
|
|
191
|
+
# Whatever, keep going
|
|
192
|
+
logging.warning("Failed to delete branch, continuing", exc_info=True)
|
|
193
|
+
# Try deleting head_ref separately since often after it's merged it doesn't exist anymore
|
|
194
|
+
try:
|
|
195
|
+
sh.git("push", remote_name, "--delete", head_ref)
|
|
189
196
|
except RuntimeError:
|
|
190
197
|
# Whatever, keep going
|
|
191
198
|
logging.warning("Failed to delete branch, continuing", exc_info=True)
|
|
@@ -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
|
|
@@ -467,12 +468,6 @@ class Submitter:
|
|
|
467
468
|
raise RuntimeError(
|
|
468
469
|
"There appears to be no commits to process, based on the revs you passed me."
|
|
469
470
|
)
|
|
470
|
-
elif commit_count > 8 and not self.force:
|
|
471
|
-
raise RuntimeError(
|
|
472
|
-
"Cowardly refusing to handle a stack with more than eight PRs. "
|
|
473
|
-
"You are likely to get rate limited by GitHub if you try to create or "
|
|
474
|
-
"manipulate this many PRs. You can bypass this throttle using --force"
|
|
475
|
-
)
|
|
476
471
|
|
|
477
472
|
# This is not really accurate if you're doing a fancy pattern;
|
|
478
473
|
# if this is a problem file us a bug.
|
|
@@ -815,7 +810,7 @@ as it supports bidirectional syncing. However, there is no way to
|
|
|
815
810
|
convert a pre-existing PR in the old style to the new format which
|
|
816
811
|
supports bidirectional syncing. If you would like to blow away the old
|
|
817
812
|
PR and start anew, edit the Summary in the Phabricator diff to delete
|
|
818
|
-
the line 'Pull-Request
|
|
813
|
+
the line 'Pull-Request' and then run ghexport again.
|
|
819
814
|
"""
|
|
820
815
|
)
|
|
821
816
|
|
|
@@ -832,7 +827,7 @@ the Phabricator diff page? If so, please continue to use that button
|
|
|
832
827
|
to export your diff.
|
|
833
828
|
|
|
834
829
|
If you think this is in error, edit the Summary in the Phabricator diff
|
|
835
|
-
to delete the line 'Pull-Request
|
|
830
|
+
to delete the line 'Pull-Request' and then run ghexport again.
|
|
836
831
|
"""
|
|
837
832
|
)
|
|
838
833
|
else:
|
|
@@ -963,20 +958,16 @@ to disassociate the commit with the pull request, and then try again.
|
|
|
963
958
|
commit_msg = self._update_source_id(diff.summary, elab_diff)
|
|
964
959
|
else:
|
|
965
960
|
# Need to insert metadata for the first time
|
|
966
|
-
#
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
"
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
),
|
|
977
|
-
"--trailer",
|
|
978
|
-
f"Pull-Request-resolved: {pull_request_resolved.url()}",
|
|
979
|
-
input=strip_mentions(diff.summary.rstrip()),
|
|
961
|
+
# Using our Python implementation of interpret-trailers
|
|
962
|
+
trailers_to_add = [f"ghstack-source-id: {diff.source_id}"]
|
|
963
|
+
|
|
964
|
+
if self.direct:
|
|
965
|
+
trailers_to_add.append(f"ghstack-comment-id: {elab_diff.comment_id}")
|
|
966
|
+
|
|
967
|
+
trailers_to_add.append(f"Pull-Request: {pull_request_resolved.url()}")
|
|
968
|
+
|
|
969
|
+
commit_msg = ghstack.trailers.interpret_trailers(
|
|
970
|
+
strip_mentions(diff.summary.rstrip()), trailers_to_add
|
|
980
971
|
)
|
|
981
972
|
|
|
982
973
|
return DiffMeta(
|
|
@@ -1780,7 +1771,7 @@ is closed (likely due to being merged). Please rebase to upstream and try again
|
|
|
1780
1771
|
commit_body = RE_GHSTACK_SOURCE_ID.sub("", commit_body)
|
|
1781
1772
|
# Comment ID is not necessary; source of truth is orig commit
|
|
1782
1773
|
commit_body = RE_GHSTACK_COMMENT_ID.sub("", commit_body)
|
|
1783
|
-
# Don't store Pull request
|
|
1774
|
+
# Don't store Pull request in the PR body; it's
|
|
1784
1775
|
# unnecessary
|
|
1785
1776
|
commit_body = ghstack.diff.re_pull_request_resolved_w_sp(self.github_url).sub(
|
|
1786
1777
|
"", commit_body
|
|
@@ -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
|