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.
Files changed (32) hide show
  1. {ghstack-0.9.6 → ghstack-0.11.0}/PKG-INFO +7 -6
  2. {ghstack-0.9.6 → ghstack-0.11.0}/README.md +6 -5
  3. {ghstack-0.9.6 → ghstack-0.11.0}/pyproject.toml +1 -1
  4. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/diff.py +2 -2
  5. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_real.py +66 -29
  6. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/land.py +21 -14
  7. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/submit.py +14 -23
  8. ghstack-0.11.0/src/ghstack/trailers.py +191 -0
  9. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/__init__.py +0 -0
  10. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/__main__.py +0 -0
  11. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/action.py +0 -0
  12. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/cache.py +0 -0
  13. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/checkout.py +0 -0
  14. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/circleci.py +0 -0
  15. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/circleci_real.py +0 -0
  16. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/cli.py +0 -0
  17. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/config.py +0 -0
  18. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/forensics.py +0 -0
  19. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/git.py +0 -0
  20. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github.py +0 -0
  21. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_fake.py +0 -0
  22. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_schema.graphql +0 -0
  23. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/github_utils.py +0 -0
  24. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/gpg_sign.py +0 -0
  25. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/logs.py +0 -0
  26. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/py.typed +0 -0
  27. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/rage.py +0 -0
  28. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/shell.py +0 -0
  29. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/status.py +0 -0
  30. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/test_prelude.py +0 -0
  31. {ghstack-0.9.6 → ghstack-0.11.0}/src/ghstack/types.py +0 -0
  32. {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.9.6
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
- pip3 install ghstack
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
- **WARNING.** You will NOT be able to merge these commits using the
73
- normal GitHub UI, as their branch bases won't be master. Use
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/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/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
- pip3 install ghstack
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
- **WARNING.** You will NOT be able to merge these commits using the
53
- normal GitHub UI, as their branch bases won't be master. Use
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/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/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
 
@@ -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.11.0"
17
17
  description = "Stack diff support for GitHub"
18
18
  readme = "README.md"
19
19
  classifiers = [
@@ -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-resolved'. This is None for
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
- resp: requests.Response = getattr(requests, method)(
162
- url,
163
- json=kwargs,
164
- headers=headers,
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
- logging.debug("Response status: {}".format(resp.status_code))
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
- try:
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
- if resp.status_code == 404:
182
- raise ghstack.github.NotFoundError(
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
- url=url, github_url=self.github_url
228
+ url=url, github_url=self.github_url
229
+ )
194
230
  )
195
- )
196
231
 
197
- try:
198
- resp.raise_for_status()
199
- except requests.HTTPError:
200
- raise RuntimeError(pretty_json)
232
+ try:
233
+ resp.raise_for_status()
234
+ except requests.HTTPError:
235
+ raise RuntimeError(pretty_json)
201
236
 
202
- return r
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 has to happen before the push because the push
157
- # will trigger a closure, but we want a *merge*. This should
158
- # happen after the cherry-pick, because the cherry-picks can
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("push", remote_name, f"{remote_name}/{head_ref}:{base_ref}")
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, head_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-resolved' and then run ghexport again.
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-resolved' and then run ghexport again.
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
- # 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()),
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 resolved in the PR body; it's
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