| ... |
... |
@@ -6,6 +6,7 @@ Useful tools for working on tor-browser repository. |
|
6
|
6
|
|
|
7
|
7
|
import argparse
|
|
8
|
8
|
import atexit
|
|
|
9
|
+import functools
|
|
9
|
10
|
import json
|
|
10
|
11
|
import os
|
|
11
|
12
|
import re
|
| ... |
... |
@@ -14,8 +15,15 @@ import sys |
|
14
|
15
|
import tempfile
|
|
15
|
16
|
import termios
|
|
16
|
17
|
import urllib.request
|
|
|
18
|
+from collections.abc import Callable, Iterable, Iterator
|
|
|
19
|
+from types import ModuleType
|
|
|
20
|
+from typing import Any, NotRequired, TypedDict, TypeVar
|
|
17
|
21
|
|
|
18
|
|
-import argcomplete
|
|
|
22
|
+argcomplete: None | ModuleType = None
|
|
|
23
|
+try:
|
|
|
24
|
+ import argcomplete
|
|
|
25
|
+except ImportError:
|
|
|
26
|
+ pass
|
|
19
|
27
|
|
|
20
|
28
|
GIT_PATH = "/usr/bin/git"
|
|
21
|
29
|
UPSTREAM_URLS = {
|
| ... |
... |
@@ -36,9 +44,14 @@ class TbDevException(Exception): |
|
36
|
44
|
pass
|
|
37
|
45
|
|
|
38
|
46
|
|
|
39
|
|
-def git_run(args, check=True, env=None):
|
|
|
47
|
+def git_run(
|
|
|
48
|
+ args: list[str], check: bool = True, env: None | dict[str, str] = None
|
|
|
49
|
+) -> None:
|
|
40
|
50
|
"""
|
|
41
|
51
|
Run a git command with output sent to stdout.
|
|
|
52
|
+ :param args: The arguments to pass to git.
|
|
|
53
|
+ :param check: Whether to check for success.
|
|
|
54
|
+ :param env: Optional environment to set.
|
|
42
|
55
|
"""
|
|
43
|
56
|
if env is not None:
|
|
44
|
57
|
tmp_env = dict(os.environ)
|
| ... |
... |
@@ -51,46 +64,122 @@ def git_run(args, check=True, env=None): |
|
51
|
64
|
raise TbDevException(str(err)) from err
|
|
52
|
65
|
|
|
53
|
66
|
|
|
54
|
|
-def git_get(args):
|
|
|
67
|
+def git_run_pager(
|
|
|
68
|
+ args: list[str] | None = None,
|
|
|
69
|
+ arg_sequence: Iterable[list[str]] | None = None,
|
|
|
70
|
+ pager_prefix: None | str = None,
|
|
|
71
|
+) -> None:
|
|
55
|
72
|
"""
|
|
56
|
|
- Run a git command with each non-empty line returned in a list.
|
|
|
73
|
+ Run a sequence of git commands with the output concatenated and sent to the
|
|
|
74
|
+ git pager.
|
|
|
75
|
+ :param args: The arguments to pass to git, or `None` if a sequence is desired.
|
|
|
76
|
+ :param arg_sequence: A sequence representing several git commands.
|
|
|
77
|
+ :param pager_prefix: An optional text to send to the pager first.
|
|
|
78
|
+ """
|
|
|
79
|
+ if arg_sequence is None:
|
|
|
80
|
+ if args is not None:
|
|
|
81
|
+ arg_sequence = (args,)
|
|
|
82
|
+ else:
|
|
|
83
|
+ raise ValueError("Missing `arg_sequence` or `args`")
|
|
|
84
|
+ elif args is not None:
|
|
|
85
|
+ raise ValueError("Unexpected both args and arg_sequence")
|
|
|
86
|
+
|
|
|
87
|
+ pager = git_get(["var", "GIT_PAGER"])
|
|
|
88
|
+ if not pager:
|
|
|
89
|
+ raise TbDevException("Missing a GIT_PAGER")
|
|
|
90
|
+ command = [pager]
|
|
|
91
|
+ if os.path.basename(pager) == "less":
|
|
|
92
|
+ # Show colours.
|
|
|
93
|
+ command.append("-R")
|
|
|
94
|
+
|
|
|
95
|
+ pager_process = subprocess.Popen(command, stdin=subprocess.PIPE, text=True)
|
|
|
96
|
+ assert pager_process.stdin is not None
|
|
|
97
|
+
|
|
|
98
|
+ if pager_prefix is not None:
|
|
|
99
|
+ pager_process.stdin.write(pager_prefix)
|
|
|
100
|
+ pager_process.stdin.flush()
|
|
|
101
|
+
|
|
|
102
|
+ for git_args in arg_sequence:
|
|
|
103
|
+ subprocess.run(
|
|
|
104
|
+ [GIT_PATH, "--no-pager", *git_args], check=False, stdout=pager_process.stdin
|
|
|
105
|
+ )
|
|
|
106
|
+
|
|
|
107
|
+ pager_process.stdin.close()
|
|
|
108
|
+
|
|
|
109
|
+ status = pager_process.wait()
|
|
|
110
|
+ if status != 0:
|
|
|
111
|
+ raise TbDevException(f"git pager {pager} exited with status {status}")
|
|
|
112
|
+
|
|
|
113
|
+
|
|
|
114
|
+def git_get(args: list[str], strip: bool = True, check: bool = True) -> str:
|
|
|
115
|
+ """
|
|
|
116
|
+ Return the output from a git command.
|
|
|
117
|
+ :param args: The arguments to send to git.
|
|
|
118
|
+ :param strip: Whether to strip the whitespace from the output.
|
|
|
119
|
+ :param check: Whether to check for success.
|
|
|
120
|
+ :returns: The stdout.
|
|
57
|
121
|
"""
|
|
58
|
122
|
try:
|
|
59
|
123
|
git_process = subprocess.run(
|
|
60
|
|
- [GIT_PATH, *args], text=True, stdout=subprocess.PIPE, check=True
|
|
|
124
|
+ [GIT_PATH, *args], text=True, stdout=subprocess.PIPE, check=check
|
|
61
|
125
|
)
|
|
62
|
126
|
except subprocess.CalledProcessError as err:
|
|
63
|
127
|
raise TbDevException(str(err)) from err
|
|
64
|
|
- return [line for line in git_process.stdout.split("\n") if line]
|
|
|
128
|
+ ret = git_process.stdout
|
|
|
129
|
+ if strip:
|
|
|
130
|
+ ret = ret.strip()
|
|
|
131
|
+ return ret
|
|
65
|
132
|
|
|
66
|
133
|
|
|
67
|
|
-local_root = None
|
|
|
134
|
+def git_lines(args: list[str]) -> Iterator[str]:
|
|
|
135
|
+ """
|
|
|
136
|
+ Yields the non-empty lines returned by the git command.
|
|
|
137
|
+ :param args: The arguments to send to git.
|
|
|
138
|
+ :yield: The lines.
|
|
|
139
|
+ """
|
|
|
140
|
+ for line in git_get(args, strip=False).split("\n"):
|
|
|
141
|
+ if not line:
|
|
|
142
|
+ continue
|
|
|
143
|
+ yield line
|
|
|
144
|
+
|
|
|
145
|
+
|
|
|
146
|
+def git_path_args(path_iter: Iterable[str]) -> Iterator[str]:
|
|
|
147
|
+ """
|
|
|
148
|
+ Generate the trailing arguments to specify paths in git commands, includes
|
|
|
149
|
+ the "--" separator just before the paths.
|
|
|
150
|
+ :param path_iter: The paths that should be passed in.
|
|
|
151
|
+ :yields: The git arguments.
|
|
|
152
|
+ """
|
|
|
153
|
+ yield "--"
|
|
|
154
|
+ for path in path_iter:
|
|
|
155
|
+ yield f":(literal){path}"
|
|
68
|
156
|
|
|
69
|
157
|
|
|
70
|
|
-def get_local_root():
|
|
|
158
|
+@functools.cache
|
|
|
159
|
+def get_local_root() -> str:
|
|
71
|
160
|
"""
|
|
72
|
161
|
Get the path for the tor-browser root directory.
|
|
|
162
|
+ :returns: The local root.
|
|
73
|
163
|
"""
|
|
74
|
|
- global local_root
|
|
75
|
|
- if local_root is None:
|
|
76
|
|
- try:
|
|
77
|
|
- # Make sure we have a matching remote in this git repository.
|
|
78
|
|
- if get_upstream_details()["is-browser-repo"]:
|
|
79
|
|
- local_root = git_get(["rev-parse", "--show-toplevel"])[0]
|
|
80
|
|
- else:
|
|
81
|
|
- local_root = ""
|
|
82
|
|
- except TbDevException:
|
|
83
|
|
- local_root = ""
|
|
84
|
|
- return local_root
|
|
|
164
|
+ try:
|
|
|
165
|
+ # Make sure we have a matching remote in this git repository.
|
|
|
166
|
+ if get_upstream_details()["is-browser-repo"] == "True":
|
|
|
167
|
+ return git_get(["rev-parse", "--show-toplevel"])
|
|
|
168
|
+ else:
|
|
|
169
|
+ return ""
|
|
|
170
|
+ except TbDevException:
|
|
|
171
|
+ return ""
|
|
85
|
172
|
|
|
86
|
173
|
|
|
87
|
|
-def determine_upstream_details():
|
|
|
174
|
+@functools.cache
|
|
|
175
|
+def get_upstream_details() -> dict[str, str]:
|
|
88
|
176
|
"""
|
|
89
|
|
- Determine details about the upstream.
|
|
|
177
|
+ Get details about the upstream repository.
|
|
|
178
|
+ :returns: The details.
|
|
90
|
179
|
"""
|
|
91
|
180
|
remote_urls = {
|
|
92
|
|
- remote: git_get(["remote", "get-url", remote])[0]
|
|
93
|
|
- for remote in git_get(["remote"])
|
|
|
181
|
+ remote: git_get(["remote", "get-url", remote])
|
|
|
182
|
+ for remote in git_lines(["remote"])
|
|
94
|
183
|
}
|
|
95
|
184
|
|
|
96
|
185
|
matches = {
|
| ... |
... |
@@ -102,7 +191,7 @@ def determine_upstream_details(): |
|
102
|
191
|
}
|
|
103
|
192
|
|
|
104
|
193
|
is_browser_repo = len(matches) > 0
|
|
105
|
|
- details = {"is-browser-repo": is_browser_repo}
|
|
|
194
|
+ details = {"is-browser-repo": str(is_browser_repo)}
|
|
106
|
195
|
|
|
107
|
196
|
origin_remote_repo = matches.get("origin", None)
|
|
108
|
197
|
upstream_remote_repo = matches.get("upstream", None)
|
| ... |
... |
@@ -125,31 +214,30 @@ def determine_upstream_details(): |
|
125
|
214
|
return details
|
|
126
|
215
|
|
|
127
|
216
|
|
|
128
|
|
-cached_upstream_details = None
|
|
129
|
|
-
|
|
130
|
|
-
|
|
131
|
|
-def get_upstream_details():
|
|
132
|
|
- """
|
|
133
|
|
- Get details about the upstream repository.
|
|
134
|
|
- """
|
|
135
|
|
- global cached_upstream_details
|
|
136
|
|
- if cached_upstream_details is None:
|
|
137
|
|
- cached_upstream_details = determine_upstream_details()
|
|
138
|
|
- return cached_upstream_details
|
|
139
|
|
-
|
|
140
|
|
-
|
|
141
|
217
|
class Reference:
|
|
142
|
218
|
"""Represents a git reference to a commit."""
|
|
143
|
219
|
|
|
144
|
|
- def __init__(self, name, commit):
|
|
145
|
|
- self.name = name
|
|
|
220
|
+ _REFS_REGEX = re.compile(r"refs/[a-z]+/")
|
|
|
221
|
+
|
|
|
222
|
+ def __init__(self, full_name: str, commit: str) -> None:
|
|
|
223
|
+ """
|
|
|
224
|
+ :param full_name: The full reference name. E.g. "refs/tags/MyTag".
|
|
|
225
|
+ :param commit: The commit hash for the commit this reference points to.
|
|
|
226
|
+ """
|
|
|
227
|
+ match = self.__class__._REFS_REGEX.match(full_name)
|
|
|
228
|
+ if not match:
|
|
|
229
|
+ raise ValueError(f"Invalid reference name {full_name}")
|
|
|
230
|
+ self.full_name = full_name
|
|
|
231
|
+ self.name = full_name[match.end() :]
|
|
146
|
232
|
self.commit = commit
|
|
147
|
233
|
|
|
148
|
234
|
|
|
149
|
|
-def get_refs(ref_type, name_start):
|
|
|
235
|
+def get_refs(ref_type: str, name_start: str) -> Iterator[Reference]:
|
|
150
|
236
|
"""
|
|
151
|
|
- Get a list of references that match the given 'ref_type' ("tag" or "remote"
|
|
152
|
|
- or "head") that starts with the given 'name_start'.
|
|
|
237
|
+ Get a list of references that match the given conditions.
|
|
|
238
|
+ :param ref_type: The ref type to search for ("tag" or "remote" or "head").
|
|
|
239
|
+ :param name_start: The ref name start to match against.
|
|
|
240
|
+ :yield: The matching references.
|
|
153
|
241
|
"""
|
|
154
|
242
|
if ref_type == "tag":
|
|
155
|
243
|
ref_start = "refs/tags/"
|
| ... |
... |
@@ -163,56 +251,83 @@ def get_refs(ref_type, name_start): |
|
163
|
251
|
fstring = "%(*objectname),%(objectname),%(refname)"
|
|
164
|
252
|
pattern = f"{ref_start}{name_start}**"
|
|
165
|
253
|
|
|
166
|
|
- def line_to_ref(line):
|
|
|
254
|
+ def line_to_ref(line: str) -> Reference:
|
|
167
|
255
|
[objectname_reference, objectname, ref_name] = line.split(",", 2)
|
|
168
|
256
|
# For annotated tags, the objectname_reference is non-empty and points
|
|
169
|
257
|
# to an actual commit.
|
|
170
|
258
|
# For remotes, heads and lightweight tags, the objectname_reference will
|
|
171
|
259
|
# be empty and objectname will point directly to the commit.
|
|
172
|
|
- return Reference(
|
|
173
|
|
- ref_name.replace(ref_start, "", 1), objectname_reference or objectname
|
|
174
|
|
- )
|
|
|
260
|
+ return Reference(ref_name, objectname_reference or objectname)
|
|
175
|
261
|
|
|
176
|
|
- return [
|
|
|
262
|
+ return (
|
|
177
|
263
|
line_to_ref(line)
|
|
178
|
|
- for line in git_get(["for-each-ref", f"--format={fstring}", pattern])
|
|
179
|
|
- ]
|
|
|
264
|
+ for line in git_lines(["for-each-ref", f"--format={fstring}", pattern])
|
|
|
265
|
+ )
|
|
180
|
266
|
|
|
181
|
267
|
|
|
182
|
|
-def get_nearest_ref(ref_type, name_start, search_from):
|
|
|
268
|
+def get_firefox_ref(search_from: str) -> Reference:
|
|
183
|
269
|
"""
|
|
184
|
|
- Search backwards from the 'search_from' commit to find the first commit
|
|
185
|
|
- that matches the given 'ref_type' that starts with the given 'name_start'.
|
|
|
270
|
+ Search for the commit that comes from firefox.
|
|
|
271
|
+ :param search_from: The commit to search backwards from.
|
|
|
272
|
+ :returns: The firefox reference.
|
|
186
|
273
|
"""
|
|
187
|
|
- ref_list = get_refs(ref_type, name_start)
|
|
|
274
|
+ # Only search a limited history that should include the FIREFOX_ tag.
|
|
|
275
|
+ search_commits = [c for c in git_lines(["rev-list", "-1000", search_from])]
|
|
|
276
|
+
|
|
|
277
|
+ firefox_tag_prefix = "FIREFOX_"
|
|
188
|
278
|
|
|
189
|
|
- for commit in git_get(["rev-list", "-1000", search_from]):
|
|
190
|
|
- for ref in ref_list:
|
|
|
279
|
+ existing_tags = list(get_refs("tag", firefox_tag_prefix))
|
|
|
280
|
+ for commit in search_commits:
|
|
|
281
|
+ for ref in existing_tags:
|
|
191
|
282
|
if commit == ref.commit:
|
|
192
|
283
|
return ref
|
|
193
|
284
|
|
|
194
|
|
- raise TbDevException(f"No {name_start} commit found in the last 1000 commits")
|
|
195
|
|
-
|
|
196
|
|
-
|
|
197
|
|
-def get_firefox_ref(search_from):
|
|
|
285
|
+ # Might just need to fetch tags from the remote.
|
|
|
286
|
+ upstream = get_upstream_details().get("remote", None)
|
|
|
287
|
+ if upstream:
|
|
|
288
|
+ remote_ref: None | Reference = None
|
|
|
289
|
+ search_index = len(search_commits)
|
|
|
290
|
+ # Search the remote for a tag that is in our history.
|
|
|
291
|
+ # We want to avoid triggering a long fetch, so we just want to grab the
|
|
|
292
|
+ # tag that already points to a commit in our history.
|
|
|
293
|
+ for line in git_lines(
|
|
|
294
|
+ ["ls-remote", upstream, f"refs/tags/{firefox_tag_prefix}*"]
|
|
|
295
|
+ ):
|
|
|
296
|
+ objectname, name = line.split("\t", 1)
|
|
|
297
|
+ for index in range(search_index):
|
|
|
298
|
+ if search_commits[index] == objectname:
|
|
|
299
|
+ # Remove trailing "^{}" for commits pointed to by
|
|
|
300
|
+ # annotated tags.
|
|
|
301
|
+ remote_ref = Reference(re.sub(r"\^\{\}$", "", name), objectname)
|
|
|
302
|
+ # Only continue to search for references that are even
|
|
|
303
|
+ # closer to `search_from`.
|
|
|
304
|
+ search_index = index
|
|
|
305
|
+ break
|
|
|
306
|
+ if remote_ref is not None:
|
|
|
307
|
+ # Get a local copy of just this tag.
|
|
|
308
|
+ git_run(["fetch", "--no-tags", upstream, "tag", remote_ref.name])
|
|
|
309
|
+ return ref
|
|
|
310
|
+
|
|
|
311
|
+ raise TbDevException("Unable to find FIREFOX_ tag")
|
|
|
312
|
+
|
|
|
313
|
+
|
|
|
314
|
+def get_upstream_tracking_branch(search_from: str) -> str:
|
|
198
|
315
|
"""
|
|
199
|
|
- Search backwards from the 'search_from' commit to find the commit that comes
|
|
200
|
|
- from firefox.
|
|
|
316
|
+ :param search_from: The commit reference.
|
|
|
317
|
+ :returns: The upstream branch reference name.
|
|
201
|
318
|
"""
|
|
202
|
|
- return get_nearest_ref("tag", "FIREFOX_", search_from)
|
|
203
|
|
-
|
|
204
|
|
-
|
|
205
|
|
-def get_upstream_tracking_branch(search_from):
|
|
206
|
|
- return git_get(["rev-parse", "--abbrev-ref", f"{search_from}@{{upstream}}"])[0]
|
|
|
319
|
+ return git_get(["rev-parse", "--abbrev-ref", f"{search_from}@{{upstream}}"])
|
|
207
|
320
|
|
|
208
|
321
|
|
|
209
|
|
-def get_upstream_basis_commit(search_from):
|
|
|
322
|
+def get_upstream_basis_commit(search_from: str) -> str:
|
|
210
|
323
|
"""
|
|
211
|
324
|
Get the first common ancestor of search_from that is also in its upstream
|
|
212
|
325
|
branch.
|
|
|
326
|
+ :param search_from: The commit reference.
|
|
|
327
|
+ :returns: The upstream commit hash.
|
|
213
|
328
|
"""
|
|
214
|
329
|
upstream_branch = get_upstream_tracking_branch(search_from)
|
|
215
|
|
- commit = git_get(["merge-base", search_from, upstream_branch])[0]
|
|
|
330
|
+ commit = git_get(["merge-base", search_from, upstream_branch])
|
|
216
|
331
|
# Verify that the upstream commit shares the same firefox basis. Otherwise,
|
|
217
|
332
|
# this would indicate that the upstream is on an early or later FIREFOX
|
|
218
|
333
|
# base.
|
| ... |
... |
@@ -226,26 +341,82 @@ def get_upstream_basis_commit(search_from): |
|
226
|
341
|
return commit
|
|
227
|
342
|
|
|
228
|
343
|
|
|
229
|
|
-def get_changed_files(from_commit, staged=False):
|
|
|
344
|
+class FileChange:
|
|
|
345
|
+ """Represents a git change to a commit."""
|
|
|
346
|
+
|
|
|
347
|
+ def __init__(self, status: str, path: str, new_path: str) -> None:
|
|
|
348
|
+ """
|
|
|
349
|
+ :param status: The file change status used within git diff. E.g. "M" for
|
|
|
350
|
+ modified, or "D" for deleted.
|
|
|
351
|
+ :param path: The source file path.
|
|
|
352
|
+ :param new_path: The file path after the change.
|
|
|
353
|
+ """
|
|
|
354
|
+ self.status = status
|
|
|
355
|
+ self.path = path
|
|
|
356
|
+ self.new_path = new_path
|
|
|
357
|
+
|
|
|
358
|
+
|
|
|
359
|
+RAW_DIFF_PATH_PATTERN = r"(?P<path>[^\0]*)\0"
|
|
|
360
|
+RAW_DIFF_LINE_REGEX = re.compile(
|
|
|
361
|
+ r":[0-7]+ [0-7]+ [0-9a-f]+ [0-9a-f]+ (?P<status>[ADMTUXRC])[0-9]*\0"
|
|
|
362
|
+ + RAW_DIFF_PATH_PATTERN
|
|
|
363
|
+)
|
|
|
364
|
+RAW_DIFF_PATH_REGEX = re.compile(RAW_DIFF_PATH_PATTERN)
|
|
|
365
|
+
|
|
|
366
|
+
|
|
|
367
|
+def parse_raw_diff_line(raw_output: str) -> tuple[FileChange, int]:
|
|
230
|
368
|
"""
|
|
231
|
|
- Get a list of filenames relative to the current working directory that have
|
|
|
369
|
+ Parse the --raw diff output from git.
|
|
|
370
|
+ :param raw_output: The raw output.
|
|
|
371
|
+ :returns: The change for this line, and the offset for the end of the raw
|
|
|
372
|
+ diff line.
|
|
|
373
|
+ """
|
|
|
374
|
+ match = RAW_DIFF_LINE_REGEX.match(raw_output)
|
|
|
375
|
+ if not match:
|
|
|
376
|
+ raise ValueError(f"Invalid raw output: {raw_output[:50]}...")
|
|
|
377
|
+ path = os.path.relpath(os.path.join(get_local_root(), match.group("path")))
|
|
|
378
|
+ status = match.group("status")
|
|
|
379
|
+ if status in ("R", "C"):
|
|
|
380
|
+ match = RAW_DIFF_PATH_REGEX.match(raw_output, pos=match.end())
|
|
|
381
|
+ if not match:
|
|
|
382
|
+ raise ValueError(f"Invalid raw output for rename: {raw_output[:50]}...")
|
|
|
383
|
+ new_path = os.path.relpath(os.path.join(get_local_root(), match.group("path")))
|
|
|
384
|
+ else:
|
|
|
385
|
+ new_path = path
|
|
|
386
|
+
|
|
|
387
|
+ return FileChange(status, path, new_path), match.end()
|
|
|
388
|
+
|
|
|
389
|
+
|
|
|
390
|
+def get_changed_files(
|
|
|
391
|
+ from_commit: None | str = None, staged: bool = False
|
|
|
392
|
+) -> Iterator[FileChange]:
|
|
|
393
|
+ """
|
|
|
394
|
+ Get a list of file changes relative to the current working directory that have
|
|
232
|
395
|
been changed since 'from_commit' (non-inclusive).
|
|
|
396
|
+ :param from_commit: The commit to compare against, otherwise use the git
|
|
|
397
|
+ diff default.
|
|
|
398
|
+ :param staged: Whether to limit the diff to staged changes.
|
|
|
399
|
+ :yield: The file changes.
|
|
233
|
400
|
"""
|
|
234
|
|
- args = ["diff"]
|
|
|
401
|
+ args = ["diff", "-z", "--raw"]
|
|
235
|
402
|
if staged:
|
|
236
|
403
|
args.append("--staged")
|
|
237
|
|
- args.append("--name-only")
|
|
238
|
|
- args.append(from_commit)
|
|
239
|
|
- return [
|
|
240
|
|
- os.path.relpath(os.path.join(get_local_root(), filename))
|
|
241
|
|
- for filename in git_get(args)
|
|
242
|
|
- ]
|
|
|
404
|
+ if from_commit:
|
|
|
405
|
+ args.append(from_commit)
|
|
|
406
|
+ raw_output = git_get(args, strip=False)
|
|
|
407
|
+ while raw_output:
|
|
|
408
|
+ file_change, end = parse_raw_diff_line(raw_output)
|
|
|
409
|
+ yield file_change
|
|
|
410
|
+ raw_output = raw_output[end:]
|
|
243
|
411
|
|
|
244
|
412
|
|
|
245
|
|
-def file_contains(filename, regex):
|
|
|
413
|
+def file_contains(filename: str, regex: re.Pattern[str]) -> bool:
|
|
246
|
414
|
"""
|
|
247
|
415
|
Return whether the file is a utf-8 text file containing the regular
|
|
248
|
416
|
expression given by 'regex'.
|
|
|
417
|
+ :param filename: The file path.
|
|
|
418
|
+ :param regex: The pattern to search for.
|
|
|
419
|
+ :returns: Whether the pattern was matched.
|
|
249
|
420
|
"""
|
|
250
|
421
|
with open(filename, encoding="utf-8") as file:
|
|
251
|
422
|
try:
|
| ... |
... |
@@ -258,9 +429,10 @@ def file_contains(filename, regex): |
|
258
|
429
|
return False
|
|
259
|
430
|
|
|
260
|
431
|
|
|
261
|
|
-def get_gitlab_default():
|
|
|
432
|
+def get_gitlab_default() -> str:
|
|
262
|
433
|
"""
|
|
263
|
434
|
Get the name of the default branch on gitlab.
|
|
|
435
|
+ :returns: The branch name.
|
|
264
|
436
|
"""
|
|
265
|
437
|
repo_name = get_upstream_details().get("repo-name", None)
|
|
266
|
438
|
if repo_name is None:
|
| ... |
... |
@@ -283,12 +455,14 @@ def get_gitlab_default(): |
|
283
|
455
|
)
|
|
284
|
456
|
|
|
285
|
457
|
with urllib.request.urlopen(gitlab_request, timeout=20) as response:
|
|
286
|
|
- return json.load(response)["data"]["project"]["repository"]["rootRef"]
|
|
|
458
|
+ default = json.load(response)["data"]["project"]["repository"]["rootRef"]
|
|
|
459
|
+ assert isinstance(default, str)
|
|
|
460
|
+ return default
|
|
287
|
461
|
|
|
288
|
462
|
|
|
289
|
|
-def within_browser_root():
|
|
|
463
|
+def within_browser_root() -> bool:
|
|
290
|
464
|
"""
|
|
291
|
|
- Whether we are with the tor browser root.
|
|
|
465
|
+ :returns: Whether we are with the tor browser root.
|
|
292
|
466
|
"""
|
|
293
|
467
|
root = get_local_root()
|
|
294
|
468
|
if not root:
|
| ... |
... |
@@ -301,24 +475,24 @@ def within_browser_root(): |
|
301
|
475
|
# * -------------------- *
|
|
302
|
476
|
|
|
303
|
477
|
|
|
304
|
|
-def show_firefox_commit(_args):
|
|
|
478
|
+def show_firefox_commit(_args: argparse.Namespace) -> None:
|
|
305
|
479
|
"""
|
|
306
|
480
|
Print the tag name and commit for the last firefox commit below the current
|
|
307
|
481
|
HEAD.
|
|
308
|
482
|
"""
|
|
309
|
483
|
ref = get_firefox_ref("HEAD")
|
|
310
|
|
- print(ref.name)
|
|
|
484
|
+ print(ref.full_name)
|
|
311
|
485
|
print(ref.commit)
|
|
312
|
486
|
|
|
313
|
487
|
|
|
314
|
|
-def show_upstream_basis_commit(_args):
|
|
|
488
|
+def show_upstream_basis_commit(_args: argparse.Namespace) -> None:
|
|
315
|
489
|
"""
|
|
316
|
490
|
Print the last upstream commit for the current HEAD.
|
|
317
|
491
|
"""
|
|
318
|
492
|
print(get_upstream_basis_commit("HEAD"))
|
|
319
|
493
|
|
|
320
|
494
|
|
|
321
|
|
-def show_log(args):
|
|
|
495
|
+def show_log(args: argparse.Namespace) -> None:
|
|
322
|
496
|
"""
|
|
323
|
497
|
Show the git log between the current HEAD and the last firefox commit.
|
|
324
|
498
|
"""
|
| ... |
... |
@@ -326,7 +500,7 @@ def show_log(args): |
|
326
|
500
|
git_run(["log", f"{commit}..HEAD", *args.gitargs], check=False)
|
|
327
|
501
|
|
|
328
|
502
|
|
|
329
|
|
-def show_files_containing(args):
|
|
|
503
|
+def show_files_containing(args: argparse.Namespace) -> None:
|
|
330
|
504
|
"""
|
|
331
|
505
|
List all the files that that have been modified for tor browser, that also
|
|
332
|
506
|
contain a regular expression.
|
| ... |
... |
@@ -336,33 +510,32 @@ def show_files_containing(args): |
|
336
|
510
|
except re.error as err:
|
|
337
|
511
|
raise TbDevException(f"{args.regex} is not a valid python regex") from err
|
|
338
|
512
|
|
|
339
|
|
- file_list = get_changed_files(get_firefox_ref("HEAD").commit)
|
|
340
|
|
-
|
|
341
|
|
- for filename in file_list:
|
|
342
|
|
- if not os.path.isfile(filename):
|
|
|
513
|
+ for file_change in get_changed_files(get_firefox_ref("HEAD").commit):
|
|
|
514
|
+ path = file_change.new_path
|
|
|
515
|
+ if not os.path.isfile(path):
|
|
343
|
516
|
# deleted ofile
|
|
344
|
517
|
continue
|
|
345
|
|
- if file_contains(filename, regex):
|
|
346
|
|
- print(filename)
|
|
|
518
|
+ if file_contains(path, regex):
|
|
|
519
|
+ print(path)
|
|
347
|
520
|
|
|
348
|
521
|
|
|
349
|
|
-def show_changed_files(_args):
|
|
|
522
|
+def show_changed_files(_args: argparse.Namespace) -> None:
|
|
350
|
523
|
"""
|
|
351
|
524
|
List all the files that have been modified relative to upstream.
|
|
352
|
525
|
"""
|
|
353
|
|
- for filename in get_changed_files(get_upstream_basis_commit("HEAD")):
|
|
354
|
|
- print(filename)
|
|
|
526
|
+ for file_change in get_changed_files(get_upstream_basis_commit("HEAD")):
|
|
|
527
|
+ print(file_change.new_path)
|
|
355
|
528
|
|
|
356
|
529
|
|
|
357
|
|
-def lint_changed_files(args):
|
|
|
530
|
+def lint_changed_files(args: argparse.Namespace) -> None:
|
|
358
|
531
|
"""
|
|
359
|
532
|
Lint all the files that have been modified relative to upstream.
|
|
360
|
533
|
"""
|
|
361
|
534
|
os.chdir(get_local_root())
|
|
362
|
535
|
file_list = [
|
|
363
|
|
- f
|
|
|
536
|
+ f.new_path
|
|
364
|
537
|
for f in get_changed_files(get_upstream_basis_commit("HEAD"))
|
|
365
|
|
- if os.path.isfile(f) # Not deleted
|
|
|
538
|
+ if os.path.isfile(f.new_path) # Not deleted
|
|
366
|
539
|
]
|
|
367
|
540
|
# We add --warnings since clang only reports whitespace issues as warnings.
|
|
368
|
541
|
subprocess.run(
|
| ... |
... |
@@ -371,10 +544,18 @@ def lint_changed_files(args): |
|
371
|
544
|
)
|
|
372
|
545
|
|
|
373
|
546
|
|
|
374
|
|
-def prompt_user(prompt, convert):
|
|
|
547
|
+# TODO: replace with "prompt_user[T](..., T]) -> T" after python 3.12 is the
|
|
|
548
|
+# minimum mach version.
|
|
|
549
|
+T = TypeVar("T")
|
|
|
550
|
+
|
|
|
551
|
+
|
|
|
552
|
+def prompt_user(prompt: str, convert: Callable[[str], T]) -> T:
|
|
375
|
553
|
"""
|
|
376
|
|
- Ask the user for some input until the given converter returns without
|
|
377
|
|
- throwing a ValueError.
|
|
|
554
|
+ Ask the user for some input.
|
|
|
555
|
+ :param prompt: The prompt to show the user.
|
|
|
556
|
+ :param convert: A method to convert the response into a type. Should
|
|
|
557
|
+ throw `ValueError` if the user should be re-prompted for a valid input.
|
|
|
558
|
+ :returns: The first valid user response.
|
|
378
|
559
|
"""
|
|
379
|
560
|
while True:
|
|
380
|
561
|
# Flush out stdin.
|
| ... |
... |
@@ -388,8 +569,12 @@ def prompt_user(prompt, convert): |
|
388
|
569
|
pass
|
|
389
|
570
|
|
|
390
|
571
|
|
|
391
|
|
-def binary_reply_default_no(value):
|
|
392
|
|
- """Process a 'y' or 'n' reply, defaulting to 'n' if empty."""
|
|
|
572
|
+def binary_reply_default_no(value: str) -> bool:
|
|
|
573
|
+ """
|
|
|
574
|
+ Process a 'y' or 'n' reply, defaulting to 'n' if empty.
|
|
|
575
|
+ :param value: The user input.
|
|
|
576
|
+ :returns: Whether the answer is yes.
|
|
|
577
|
+ """
|
|
393
|
578
|
if value == "":
|
|
394
|
579
|
return False
|
|
395
|
580
|
if value.lower() == "y":
|
| ... |
... |
@@ -399,121 +584,737 @@ def binary_reply_default_no(value): |
|
399
|
584
|
raise ValueError()
|
|
400
|
585
|
|
|
401
|
586
|
|
|
402
|
|
-def get_fixup_for_file(filename, firefox_commit):
|
|
403
|
|
- """Find the commit the given file should fix up."""
|
|
|
587
|
+class FixupTarget:
|
|
|
588
|
+ """Represents a commit that can be targeted by a fixup."""
|
|
|
589
|
+
|
|
|
590
|
+ def __init__(self, commit: str, short_ref: str, title: str) -> None:
|
|
|
591
|
+ """
|
|
|
592
|
+ :param commit: The commit hash for the commit.
|
|
|
593
|
+ :param short_ref: The shortened commit hash for display.
|
|
|
594
|
+ :param title: The first line of the commit message.
|
|
|
595
|
+ """
|
|
|
596
|
+ self.commit = commit
|
|
|
597
|
+ self.short_ref = short_ref
|
|
|
598
|
+ self.title = title
|
|
|
599
|
+ self.changes: list[FileChange] = []
|
|
|
600
|
+ self.fixups: list[FixupTarget] = []
|
|
|
601
|
+ self.target: None | FixupTarget = None
|
|
|
602
|
+
|
|
|
603
|
+ _FIXUP_REGEX = re.compile(r"^fixup! +")
|
|
|
604
|
+
|
|
|
605
|
+ def trim_fixup(self) -> tuple[str, int]:
|
|
|
606
|
+ """
|
|
|
607
|
+ Trim the "fixup!" prefixes.
|
|
|
608
|
+ :returns: The stripped commit title and the fixup depth (how many fixups
|
|
|
609
|
+ prefixes there were).
|
|
|
610
|
+ """
|
|
|
611
|
+ title = self.title
|
|
|
612
|
+ depth = 0
|
|
|
613
|
+ while True:
|
|
|
614
|
+ match = self.__class__._FIXUP_REGEX.match(title)
|
|
|
615
|
+ if not match:
|
|
|
616
|
+ return title, depth
|
|
|
617
|
+ title = title[match.end() :]
|
|
|
618
|
+ depth += 1
|
|
|
619
|
+
|
|
|
620
|
+ def touches_path(
|
|
|
621
|
+ self, path: str, filter_status: None | str = None, check_dir: bool = False
|
|
|
622
|
+ ) -> bool:
|
|
|
623
|
+ """
|
|
|
624
|
+ Whether this target, or one of its fixups or target, touches the given
|
|
|
625
|
+ path.
|
|
|
626
|
+ :param path: The path to check.
|
|
|
627
|
+ :param filter_status: Limit the detected changes to the given status(es).
|
|
|
628
|
+ :param check_dir: Whether we should treat `path` as a directory and check for
|
|
|
629
|
+ files within it.
|
|
|
630
|
+ :returns: Whether this target matches.
|
|
|
631
|
+ """
|
|
|
632
|
+ # NOTE: In the case of renames, we generally assume that renames occur
|
|
|
633
|
+ # in the fixup targets. E.g. "Commit 1" creates the file "file.txt", and
|
|
|
634
|
+ # "fixup! Commit 1" renames it to "new.txt". In this case, if the
|
|
|
635
|
+ # FixupTarget for "Commit 1" is passed in "file.txt" it will match. And
|
|
|
636
|
+ # if it is passed in "new.txt" it will also match via the self.fixups
|
|
|
637
|
+ # field, which will include the "fixup! Commit 1" rename.
|
|
|
638
|
+ # But the "fixup ! Commit 1" FixupTargets will only match with
|
|
|
639
|
+ # "file.txt" if they occurred before the rename fixup, and will only
|
|
|
640
|
+ # match with "new.txt" if they occur after the rename fixup. With the
|
|
|
641
|
+ # exception of the rename fixup itself, which will match both.
|
|
|
642
|
+ #
|
|
|
643
|
+ # In principle, we could identify a file across renames (have a mapping
|
|
|
644
|
+ # from each commit to what the file is called at that stage) and match
|
|
|
645
|
+ # using this file identifier. Similar to the "--follow" git diff
|
|
|
646
|
+ # argument. This would then cover cases where a rename occurs between
|
|
|
647
|
+ # the commit and its fixups, and allow fixups before the rename to also
|
|
|
648
|
+ # match. However, the former case is unexpected and the latter case
|
|
|
649
|
+ # would not be that useful.
|
|
|
650
|
+ if self._touches_path_basis(path, filter_status, check_dir):
|
|
|
651
|
+ return True
|
|
|
652
|
+ # Mark this as a valid target for the path if one of our fixups changes
|
|
|
653
|
+ # this path.
|
|
|
654
|
+ # NOTE: We use _touch_path_basis to prevent recursion. This means we
|
|
|
655
|
+ # will only check one layer up or down, but we only expect fixups of
|
|
|
656
|
+ # up to depth 1.
|
|
|
657
|
+ for fixup_target in self.fixups:
|
|
|
658
|
+ if fixup_target._touches_path_basis(path, filter_status, check_dir):
|
|
|
659
|
+ return True
|
|
|
660
|
+ # Mark this as a valid target if our target changes this path.
|
|
|
661
|
+ if self.target is not None and self.target._touches_path_basis(
|
|
|
662
|
+ path, filter_status, check_dir
|
|
|
663
|
+ ):
|
|
|
664
|
+ return True
|
|
|
665
|
+ return False
|
|
|
666
|
+
|
|
|
667
|
+ def _touches_path_basis(
|
|
|
668
|
+ self, path: str, filter_status: None | str, check_dir: bool
|
|
|
669
|
+ ) -> bool:
|
|
|
670
|
+ """
|
|
|
671
|
+ Whether this target touches the given path.
|
|
|
672
|
+ :param path: The path to check.
|
|
|
673
|
+ :param filter_status: Limit the detected changes to the given status.
|
|
|
674
|
+ :param check_dir: Whether we should treat `path` as a directory and check for
|
|
|
675
|
+ files within it.
|
|
|
676
|
+ :returns: Whether this target matches.
|
|
|
677
|
+ """
|
|
|
678
|
+ for file_change in self.changes:
|
|
|
679
|
+ if filter_status is not None and file_change.status not in filter_status:
|
|
|
680
|
+ continue
|
|
|
681
|
+ for test_path in (file_change.path, file_change.new_path):
|
|
|
682
|
+ if check_dir:
|
|
|
683
|
+ if os.path.commonpath((os.path.dirname(test_path), path)) == path:
|
|
|
684
|
+ # test_path's directory matches the path or is within it.
|
|
|
685
|
+ return True
|
|
|
686
|
+ elif test_path == path:
|
|
|
687
|
+ return True
|
|
|
688
|
+ return False
|
|
|
689
|
+
|
|
|
690
|
+
|
|
|
691
|
+def get_fixup_targets(
|
|
|
692
|
+ target_list: list[FixupTarget],
|
|
|
693
|
+ from_commit: str,
|
|
|
694
|
+ to_commit: str,
|
|
|
695
|
+ fixup_depth: int = 0,
|
|
|
696
|
+) -> None:
|
|
|
697
|
+ """
|
|
|
698
|
+ Find all the commits that can be targeted by a fixup between the given
|
|
|
699
|
+ commits.
|
|
|
700
|
+ :param target_list: The list to fill with targets. Appended in the order of
|
|
|
701
|
+ `from_commit` to `to_commit`.
|
|
|
702
|
+ :param from_commit: The commit to start from (non-inclusive).
|
|
|
703
|
+ :param to_commit: The commit to end on (inclusive).
|
|
|
704
|
+ :param fixup_depth: The maximum "depth" of fixups. I.e. how many "fixup!"
|
|
|
705
|
+ prefixes to allow.
|
|
|
706
|
+ """
|
|
|
707
|
+ raw_output = git_get(
|
|
|
708
|
+ [
|
|
|
709
|
+ "log",
|
|
|
710
|
+ "--pretty=format:%H,%h,%s",
|
|
|
711
|
+ "--reverse",
|
|
|
712
|
+ "--raw",
|
|
|
713
|
+ "-z",
|
|
|
714
|
+ f"{from_commit}..{to_commit}",
|
|
|
715
|
+ ],
|
|
|
716
|
+ strip=False,
|
|
|
717
|
+ )
|
|
|
718
|
+ pretty_regex = re.compile(
|
|
|
719
|
+ r"(?P<commit>[0-9a-f]+),(?P<short_ref>[0-9a-f]+),(?P<title>[^\n\0]*)\n"
|
|
|
720
|
+ )
|
|
|
721
|
+ excluded_regex_list = [
|
|
|
722
|
+ re.compile(r"^Bug [0-9]+.*r="), # Backported Mozilla bug.
|
|
|
723
|
+ re.compile(r"^dropme! "),
|
|
|
724
|
+ ]
|
|
|
725
|
+
|
|
|
726
|
+ while raw_output:
|
|
|
727
|
+ match = pretty_regex.match(raw_output)
|
|
|
728
|
+ if not match:
|
|
|
729
|
+ raise ValueError(f"Invalid pretty format: {raw_output[:100]}...")
|
|
|
730
|
+ fixup_target = FixupTarget(
|
|
|
731
|
+ match.group("commit"), match.group("short_ref"), match.group("title")
|
|
|
732
|
+ )
|
|
|
733
|
+ raw_output = raw_output[match.end() :]
|
|
|
734
|
+ while raw_output and raw_output[0] != "\0":
|
|
|
735
|
+ file_change, end = parse_raw_diff_line(raw_output)
|
|
|
736
|
+ fixup_target.changes.append(file_change)
|
|
|
737
|
+ raw_output = raw_output[end:]
|
|
|
738
|
+ if raw_output:
|
|
|
739
|
+ # Skip over the "\0".
|
|
|
740
|
+ raw_output = raw_output[1:]
|
|
|
741
|
+
|
|
|
742
|
+ for regex in excluded_regex_list:
|
|
|
743
|
+ if regex.match(fixup_target.title):
|
|
|
744
|
+ # Exclude from the list.
|
|
|
745
|
+ continue
|
|
|
746
|
+
|
|
|
747
|
+ trimmed_title, depth = fixup_target.trim_fixup()
|
|
|
748
|
+ if depth:
|
|
|
749
|
+ original_target = None
|
|
|
750
|
+ for target in target_list:
|
|
|
751
|
+ if target.title == trimmed_title:
|
|
|
752
|
+ original_target = target
|
|
|
753
|
+ break
|
|
|
754
|
+
|
|
|
755
|
+ if original_target:
|
|
|
756
|
+ original_target.fixups.append(fixup_target)
|
|
|
757
|
+ fixup_target.target = original_target
|
|
|
758
|
+ if depth > fixup_depth:
|
|
|
759
|
+ # Exclude from the list.
|
|
|
760
|
+ continue
|
|
|
761
|
+
|
|
|
762
|
+ target_list.append(fixup_target)
|
|
|
763
|
+
|
|
|
764
|
+
|
|
|
765
|
+class NewCommitBasis:
|
|
|
766
|
+ def __init__(self) -> None:
|
|
|
767
|
+ self.staged_paths: set[str] = set()
|
|
|
768
|
+ self.adding_paths: set[str] = set()
|
|
|
769
|
+
|
|
|
770
|
+ def add(self, paths: Iterable[str], staged: bool) -> None:
|
|
|
771
|
+ """
|
|
|
772
|
+ Add a path to include in this commit.
|
|
|
773
|
+ :param paths: The paths to add.
|
|
|
774
|
+ :param staged: Whether we are adding already staged changes.
|
|
|
775
|
+ """
|
|
|
776
|
+ if staged:
|
|
|
777
|
+ self.staged_paths.update(paths)
|
|
|
778
|
+ return
|
|
|
779
|
+
|
|
|
780
|
+ self.adding_paths.update(paths)
|
|
|
781
|
+
|
|
|
782
|
+
|
|
|
783
|
+class NewCommit(NewCommitBasis):
|
|
|
784
|
+ """Represents a new commit that we want to create."""
|
|
404
|
785
|
|
|
405
|
|
- def parse_log_line(line):
|
|
406
|
|
- [commit, short_ref, title] = line.split(",", 2)
|
|
407
|
|
- return {"commit": commit, "short-ref": short_ref, "title": title}
|
|
|
786
|
+ def __init__(self, alias: str) -> None:
|
|
|
787
|
+ """
|
|
|
788
|
+ :param alias: The alias name for the commit.
|
|
|
789
|
+ """
|
|
|
790
|
+ super().__init__()
|
|
|
791
|
+ self.alias = alias
|
|
408
|
792
|
|
|
409
|
|
- options = [
|
|
410
|
|
- parse_log_line(line)
|
|
411
|
|
- for line in git_get(
|
|
412
|
|
- [
|
|
413
|
|
- "log",
|
|
414
|
|
- "--pretty=format:%H,%h,%s",
|
|
415
|
|
- f"{firefox_commit}..HEAD",
|
|
416
|
|
- "--",
|
|
417
|
|
- filename,
|
|
418
|
|
- ]
|
|
|
793
|
+
|
|
|
794
|
+class NewFixup(NewCommitBasis):
|
|
|
795
|
+ """Represents a new fixup commit that we want to create."""
|
|
|
796
|
+
|
|
|
797
|
+ def __init__(self, target: FixupTarget) -> None:
|
|
|
798
|
+ """
|
|
|
799
|
+ :param target: The commit to target with the fixup.
|
|
|
800
|
+ """
|
|
|
801
|
+ super().__init__()
|
|
|
802
|
+ self.target = target
|
|
|
803
|
+
|
|
|
804
|
+
|
|
|
805
|
+def get_suggested_fixup_targets_for_change(
|
|
|
806
|
+ file_change: FileChange,
|
|
|
807
|
+ fixup_target_list: list[FixupTarget],
|
|
|
808
|
+ firefox_directories_lazy: Callable[[], set[str]],
|
|
|
809
|
+) -> Iterator[FixupTarget]:
|
|
|
810
|
+ """
|
|
|
811
|
+ Find the suggested fixup targets for the given file change.
|
|
|
812
|
+ :param file_change: The file change to get a suggestion for.
|
|
|
813
|
+ :param fixup_target_list: The list to choose from.
|
|
|
814
|
+ :param firefox_directories_lazy: Lazy method to return the firefox
|
|
|
815
|
+ directories.
|
|
|
816
|
+ :yield: The suggested fixup targets.
|
|
|
817
|
+ """
|
|
|
818
|
+
|
|
|
819
|
+ def filter_list(
|
|
|
820
|
+ path: str, filter_status: None | str = None, check_dir: bool = False
|
|
|
821
|
+ ) -> Iterator[FixupTarget]:
|
|
|
822
|
+ return (
|
|
|
823
|
+ t
|
|
|
824
|
+ for t in fixup_target_list
|
|
|
825
|
+ if t.touches_path(path, filter_status=filter_status, check_dir=check_dir)
|
|
419
|
826
|
)
|
|
|
827
|
+
|
|
|
828
|
+ if file_change.status == "D":
|
|
|
829
|
+ # Deleted.
|
|
|
830
|
+ # Find the commit that introduced this file or previously deleted it.
|
|
|
831
|
+ # I.e. added the file ("A"), renamed it ("R"), or deleted it ("D").
|
|
|
832
|
+ yield from filter_list(file_change.path, filter_status="ARD")
|
|
|
833
|
+ return
|
|
|
834
|
+
|
|
|
835
|
+ if file_change.status == "A":
|
|
|
836
|
+ # First check to see if this file name was actually touched before.
|
|
|
837
|
+ yielded_target = False
|
|
|
838
|
+ for target in filter_list(file_change.path):
|
|
|
839
|
+ yielded_target = True
|
|
|
840
|
+ yield target
|
|
|
841
|
+ if yielded_target:
|
|
|
842
|
+ return
|
|
|
843
|
+ # Else, find commits that introduced files in the same directory, or
|
|
|
844
|
+ # deleted in them, if they are not firefox directories.
|
|
|
845
|
+ dir_path = file_change.path
|
|
|
846
|
+ while True:
|
|
|
847
|
+ dir_path = os.path.dirname(dir_path)
|
|
|
848
|
+ if not dir_path or dir_path in firefox_directories_lazy():
|
|
|
849
|
+ return
|
|
|
850
|
+
|
|
|
851
|
+ yielded_target = False
|
|
|
852
|
+ for target in filter_list(dir_path, filter_status="ARD", check_dir=True):
|
|
|
853
|
+ yielded_target = True
|
|
|
854
|
+ yield target
|
|
|
855
|
+
|
|
|
856
|
+ if yielded_target:
|
|
|
857
|
+ return
|
|
|
858
|
+ # Else, search one directory higher.
|
|
|
859
|
+
|
|
|
860
|
+ if file_change.status == "R":
|
|
|
861
|
+ # Renamed.
|
|
|
862
|
+ # Find the commit that introduced the original name for this file.
|
|
|
863
|
+ yield from filter_list(file_change.path, filter_status="AR")
|
|
|
864
|
+ return
|
|
|
865
|
+
|
|
|
866
|
+ # Modified.
|
|
|
867
|
+ yield from filter_list(file_change.path)
|
|
|
868
|
+
|
|
|
869
|
+
|
|
|
870
|
+def ask_for_target(
|
|
|
871
|
+ file_change_list: list[FileChange],
|
|
|
872
|
+ new_commits_list: list[NewCommit | NewFixup],
|
|
|
873
|
+ suggested_fixup_target_list: list[FixupTarget],
|
|
|
874
|
+ full_fixup_target_list: list[FixupTarget],
|
|
|
875
|
+ staged: bool = False,
|
|
|
876
|
+) -> bool:
|
|
|
877
|
+ """
|
|
|
878
|
+ Ask the user to choose a target.
|
|
|
879
|
+ :param file_change_list: The file changes to ask for.
|
|
|
880
|
+ :param new_commits_list: The list of pending new commits, may be added to.
|
|
|
881
|
+ :param suggested_fixup_target_list: The list of suggested target fixups
|
|
|
882
|
+ to choose from.
|
|
|
883
|
+ :param staged: Whether this is for staged changes.
|
|
|
884
|
+ :returns: `True` if the operation should be aborted.
|
|
|
885
|
+ """
|
|
|
886
|
+
|
|
|
887
|
+ new_paths = [c.new_path for c in file_change_list]
|
|
|
888
|
+ all_paths = set(new_paths).union(c.path for c in file_change_list)
|
|
|
889
|
+ non_fixup_commits: list[NewCommit] = [
|
|
|
890
|
+ n for n in new_commits_list if isinstance(n, NewCommit)
|
|
420
|
891
|
]
|
|
421
|
|
- if not options:
|
|
422
|
|
- print(f"No commit found for {filename}")
|
|
423
|
|
- return None
|
|
424
|
892
|
|
|
425
|
|
- def valid_index(val):
|
|
|
893
|
+ shown_list: list[NewCommit | FixupTarget] = (
|
|
|
894
|
+ non_fixup_commits + suggested_fixup_target_list
|
|
|
895
|
+ )
|
|
|
896
|
+
|
|
|
897
|
+ can_skip = not staged
|
|
|
898
|
+ shown_full = False
|
|
|
899
|
+
|
|
|
900
|
+ index_offset = 2
|
|
|
901
|
+
|
|
|
902
|
+ def valid_response(val: str) -> tuple[str, None | NewCommit | FixupTarget]:
|
|
|
903
|
+ val = val.strip()
|
|
|
904
|
+
|
|
|
905
|
+ if val == "h":
|
|
|
906
|
+ return "help", None
|
|
|
907
|
+
|
|
|
908
|
+ if val == "a":
|
|
|
909
|
+ return "abort", None
|
|
|
910
|
+
|
|
426
|
911
|
if val == "d":
|
|
427
|
|
- return val
|
|
|
912
|
+ return "diff", None
|
|
|
913
|
+
|
|
|
914
|
+ if val == "f":
|
|
|
915
|
+ if shown_full:
|
|
|
916
|
+ # Already done once.
|
|
|
917
|
+ raise ValueError()
|
|
|
918
|
+ return "full-list", None
|
|
428
|
919
|
|
|
|
920
|
+ is_patch_full = val.startswith("P")
|
|
429
|
921
|
is_patch = val.startswith("p")
|
|
430
|
|
- if is_patch:
|
|
431
|
|
- val = val[1:]
|
|
|
922
|
+ if is_patch or is_patch_full:
|
|
|
923
|
+ index = int(val[1:], base=10) # Raises ValueError if not integer.
|
|
|
924
|
+ else:
|
|
|
925
|
+ index = int(val, base=10) # Raises ValueError if not integer.
|
|
|
926
|
+ if index == 0:
|
|
|
927
|
+ if not can_skip:
|
|
|
928
|
+ raise ValueError()
|
|
|
929
|
+ return "skip", None
|
|
|
930
|
+
|
|
|
931
|
+ if index == 1:
|
|
|
932
|
+ return "new", None
|
|
432
|
933
|
|
|
433
|
|
- # May raise a ValueError.
|
|
434
|
|
- as_index = int(val)
|
|
435
|
|
- if as_index < 0 or as_index > len(options):
|
|
|
934
|
+ index -= index_offset
|
|
|
935
|
+
|
|
|
936
|
+ if index < 0 or index >= len(shown_list):
|
|
436
|
937
|
raise ValueError()
|
|
437
|
938
|
|
|
438
|
|
- if as_index == 0:
|
|
439
|
|
- if is_patch:
|
|
|
939
|
+ selected = shown_list[index]
|
|
|
940
|
+
|
|
|
941
|
+ if is_patch_full:
|
|
|
942
|
+ return "patch-full", selected
|
|
|
943
|
+ if is_patch:
|
|
|
944
|
+ return "patch", selected
|
|
|
945
|
+ return "target", selected
|
|
|
946
|
+
|
|
|
947
|
+ def alias_response(val: str) -> str:
|
|
|
948
|
+ # Choose a default alias name if none is given.
|
|
|
949
|
+ val = val.strip() or f"New commit {len(non_fixup_commits)}"
|
|
|
950
|
+ for new_commit in non_fixup_commits:
|
|
|
951
|
+ if new_commit.alias == val:
|
|
|
952
|
+ # Already in use.
|
|
440
|
953
|
raise ValueError()
|
|
441
|
|
- return None
|
|
|
954
|
+ return val
|
|
|
955
|
+
|
|
|
956
|
+ def print_index_option(index: int, description: str) -> None:
|
|
|
957
|
+ print(f" \x1b[1m{index}\x1b[0m: {description}")
|
|
442
|
958
|
|
|
443
|
|
- return (is_patch, options[as_index - 1]["commit"])
|
|
|
959
|
+ def in_pink(text: str) -> str:
|
|
|
960
|
+ return f"\x1b[1;38;5;212m{text}\x1b[0m"
|
|
444
|
961
|
|
|
|
962
|
+ prefix_str = "For " + (in_pink("staged") if staged else "unstaged") + " changes to"
|
|
|
963
|
+ if len(new_paths) == 1:
|
|
|
964
|
+ print(f"{prefix_str} {in_pink(new_paths[0])}:")
|
|
|
965
|
+ else:
|
|
|
966
|
+ print(f"{prefix_str}:")
|
|
|
967
|
+ for path in new_paths:
|
|
|
968
|
+ print(f" {in_pink(path)}")
|
|
|
969
|
+ print("")
|
|
|
970
|
+
|
|
|
971
|
+ show_help = True
|
|
|
972
|
+ reshow_list = True
|
|
445
|
973
|
while True:
|
|
446
|
|
- print(f"For {filename}:\n")
|
|
447
|
|
- print(" \x1b[1m0\x1b[0m: None")
|
|
448
|
|
- for index, opt in enumerate(options):
|
|
449
|
|
- print(
|
|
450
|
|
- f" \x1b[1m{index + 1}\x1b[0m: "
|
|
451
|
|
- + f"\x1b[1;38;5;212m{opt['short-ref']}\x1b[0m "
|
|
452
|
|
- + opt["title"]
|
|
453
|
|
- )
|
|
|
974
|
+ if reshow_list:
|
|
|
975
|
+ if can_skip:
|
|
|
976
|
+ print_index_option(0, "Skip")
|
|
|
977
|
+ print_index_option(1, "New commit")
|
|
|
978
|
+ for index, target in enumerate(shown_list, start=index_offset):
|
|
|
979
|
+ if isinstance(target, NewCommit):
|
|
|
980
|
+ print_index_option(index, f"Add to new commit: {target.alias}")
|
|
|
981
|
+ else:
|
|
|
982
|
+ print_index_option(
|
|
|
983
|
+ index, f"Fixup: {in_pink(target.short_ref)} {target.title}"
|
|
|
984
|
+ )
|
|
|
985
|
+ reshow_list = False
|
|
454
|
986
|
print("")
|
|
455
|
|
- response = prompt_user(
|
|
456
|
|
- "Choose an <index> to fixup, or '0' to skip this file, "
|
|
457
|
|
- "or 'd' to view the pending diff, "
|
|
458
|
|
- "or 'p<index>' to view the patch for the index: ",
|
|
459
|
|
- valid_index,
|
|
|
987
|
+
|
|
|
988
|
+ response, selected = prompt_user(
|
|
|
989
|
+ (
|
|
|
990
|
+ "Choose an <index> to target. Type 'h' for additional options: "
|
|
|
991
|
+ if show_help
|
|
|
992
|
+ else "Choose an <index> to target or an option: "
|
|
|
993
|
+ ),
|
|
|
994
|
+ valid_response,
|
|
460
|
995
|
)
|
|
461
|
|
- if response is None:
|
|
462
|
|
- # Skip this file.
|
|
463
|
|
- return None
|
|
464
|
996
|
|
|
465
|
|
- if response == "d":
|
|
466
|
|
- git_run(["diff", "--", filename])
|
|
|
997
|
+ if response == "help":
|
|
|
998
|
+ print("Options:")
|
|
|
999
|
+ for option, desc in (
|
|
|
1000
|
+ ("h", "show the available options."),
|
|
|
1001
|
+ ("a", "abort this commit operation and all pending commits."),
|
|
|
1002
|
+ (
|
|
|
1003
|
+ ("", "")
|
|
|
1004
|
+ if shown_full
|
|
|
1005
|
+ else (
|
|
|
1006
|
+ "f",
|
|
|
1007
|
+ "show the full list of fixup targets, rather than just the suggested ones.",
|
|
|
1008
|
+ )
|
|
|
1009
|
+ ),
|
|
|
1010
|
+ ("d", "view the diff for the pending file changes."),
|
|
|
1011
|
+ (
|
|
|
1012
|
+ "P<index>",
|
|
|
1013
|
+ "view the patch for the index (including its relevant fixups).",
|
|
|
1014
|
+ ),
|
|
|
1015
|
+ (
|
|
|
1016
|
+ "p<index>",
|
|
|
1017
|
+ "view the patch for the index (including its relevant fixups), "
|
|
|
1018
|
+ "limited to the current files.",
|
|
|
1019
|
+ ),
|
|
|
1020
|
+ ):
|
|
|
1021
|
+ if not option:
|
|
|
1022
|
+ # Skip this option.
|
|
|
1023
|
+ continue
|
|
|
1024
|
+ print(f" \x1b[1m{option[0]}\x1b[0m{option[1:].ljust(7)}: {desc}")
|
|
|
1025
|
+ # Do not show the help option again.
|
|
|
1026
|
+ show_help = False
|
|
|
1027
|
+ continue
|
|
|
1028
|
+
|
|
|
1029
|
+ if response == "abort":
|
|
|
1030
|
+ return True
|
|
|
1031
|
+
|
|
|
1032
|
+ if response == "skip":
|
|
|
1033
|
+ return False
|
|
|
1034
|
+
|
|
|
1035
|
+ if response == "new":
|
|
|
1036
|
+ new_alias = prompt_user(
|
|
|
1037
|
+ "Enter an optional temporary alias for this new commit: ",
|
|
|
1038
|
+ alias_response,
|
|
|
1039
|
+ )
|
|
|
1040
|
+ new_commit = NewCommit(new_alias)
|
|
|
1041
|
+ new_commit.add(all_paths, staged)
|
|
|
1042
|
+ new_commits_list.append(new_commit)
|
|
|
1043
|
+ return False
|
|
|
1044
|
+
|
|
|
1045
|
+ if response == "target":
|
|
|
1046
|
+ assert selected is not None
|
|
|
1047
|
+
|
|
|
1048
|
+ if isinstance(selected, NewCommit):
|
|
|
1049
|
+ # Adding to a new commit.
|
|
|
1050
|
+ selected.add(all_paths, staged)
|
|
|
1051
|
+ return False
|
|
|
1052
|
+
|
|
|
1053
|
+ for new_fixup in new_commits_list:
|
|
|
1054
|
+ if not isinstance(new_fixup, NewFixup):
|
|
|
1055
|
+ continue
|
|
|
1056
|
+ if new_fixup.target == selected:
|
|
|
1057
|
+ # We already have a pending fixup commit that targets this
|
|
|
1058
|
+ # selected target. Add this path to the same commit.
|
|
|
1059
|
+ new_fixup.add(all_paths, staged)
|
|
|
1060
|
+ return False
|
|
|
1061
|
+
|
|
|
1062
|
+ new_fixup = NewFixup(selected)
|
|
|
1063
|
+ new_fixup.add(all_paths, staged)
|
|
|
1064
|
+ new_commits_list.append(new_fixup)
|
|
|
1065
|
+ return False
|
|
|
1066
|
+
|
|
|
1067
|
+ if response == "full-list":
|
|
|
1068
|
+ shown_list = non_fixup_commits + full_fixup_target_list
|
|
|
1069
|
+ shown_full = True
|
|
|
1070
|
+ reshow_list = True
|
|
467
|
1071
|
continue
|
|
468
|
1072
|
|
|
469
|
|
- view_patch, commit = response
|
|
470
|
|
- if view_patch:
|
|
471
|
|
- git_run(["log", "-p", "-1", commit, "--", filename])
|
|
|
1073
|
+ if response == "diff":
|
|
|
1074
|
+ git_args = ["diff", "--color"]
|
|
|
1075
|
+ if staged:
|
|
|
1076
|
+ git_args.append("--staged")
|
|
|
1077
|
+ git_args.extend(git_path_args(all_paths))
|
|
|
1078
|
+ git_run_pager(git_args)
|
|
472
|
1079
|
continue
|
|
473
|
1080
|
|
|
474
|
|
- return commit
|
|
|
1081
|
+ if response in ("patch", "patch-full"):
|
|
|
1082
|
+ assert selected is not None
|
|
|
1083
|
+
|
|
|
1084
|
+ filter_paths = response == "patch"
|
|
|
1085
|
+
|
|
|
1086
|
+ if isinstance(selected, NewCommit):
|
|
|
1087
|
+ git_sequence = [
|
|
|
1088
|
+ ["diff", "--color", "--staged", *git_path_args((path,))]
|
|
|
1089
|
+ for path in selected.staged_paths
|
|
|
1090
|
+ if not filter_paths or path in all_paths
|
|
|
1091
|
+ ]
|
|
|
1092
|
+ git_sequence.extend(
|
|
|
1093
|
+ ["diff", "--color", *git_path_args((path,))]
|
|
|
1094
|
+ for path in selected.adding_paths
|
|
|
1095
|
+ if not filter_paths or path in all_paths
|
|
|
1096
|
+ )
|
|
|
1097
|
+
|
|
|
1098
|
+ # Show what the expected patch will be for the new commit.
|
|
|
1099
|
+ git_run_pager(
|
|
|
1100
|
+ arg_sequence=git_sequence, pager_prefix=f"{selected.alias}\n\n"
|
|
|
1101
|
+ )
|
|
|
1102
|
+ else:
|
|
|
1103
|
+ # Show the log entry for the FixupTarget and each of its fixups.
|
|
|
1104
|
+ # Order with the commmit closest to HEAD first. We expect
|
|
|
1105
|
+ # selected.fixups to match this order.
|
|
|
1106
|
+ git_sequence = []
|
|
|
1107
|
+ # If `filter_paths` is set, we want to limit the log to the
|
|
|
1108
|
+ # paths, and try to track any renames in the commit history.
|
|
|
1109
|
+ prev_log_paths: None | set[str] = None
|
|
|
1110
|
+ # For the first commit in the sequence, we use the old path
|
|
|
1111
|
+ # names (rather than `c.new_path`) since we expect the commit
|
|
|
1112
|
+ # which is closest to us to use the older names.
|
|
|
1113
|
+ log_paths: None | set[str] = (
|
|
|
1114
|
+ {c.path for c in file_change_list} if filter_paths else None
|
|
|
1115
|
+ )
|
|
|
1116
|
+ for target in (*selected.fixups, selected):
|
|
|
1117
|
+ git_args = [
|
|
|
1118
|
+ "log",
|
|
|
1119
|
+ "--color",
|
|
|
1120
|
+ "-p",
|
|
|
1121
|
+ f"{target.commit}~1..{target.commit}",
|
|
|
1122
|
+ ]
|
|
|
1123
|
+ if filter_paths:
|
|
|
1124
|
+ assert log_paths is not None
|
|
|
1125
|
+ # Track the renamed paths.
|
|
|
1126
|
+ prev_log_paths = log_paths.copy()
|
|
|
1127
|
+ for file_change in target.changes:
|
|
|
1128
|
+ if (
|
|
|
1129
|
+ file_change.status == "R"
|
|
|
1130
|
+ and file_change.new_path in log_paths
|
|
|
1131
|
+ ):
|
|
|
1132
|
+ # file was renamed in this change.
|
|
|
1133
|
+ # Update log_paths to the new name.
|
|
|
1134
|
+ # NOTE: This should have a similar effect to the
|
|
|
1135
|
+ # --follow option for git log for a single file
|
|
|
1136
|
+ # NOTE: File renames will not be properly
|
|
|
1137
|
+ # tracked if a rename occurs outside of
|
|
|
1138
|
+ # `selected.changes` or
|
|
|
1139
|
+ # `selected.fixups[].changes`, but this is
|
|
|
1140
|
+ # unexpected.
|
|
|
1141
|
+ log_paths.remove(file_change.new_path)
|
|
|
1142
|
+ log_paths.add(file_change.path)
|
|
|
1143
|
+
|
|
|
1144
|
+ # NOTE: This log entry may be empty if none of the paths
|
|
|
1145
|
+ # match.
|
|
|
1146
|
+ # NOTE: We include both log_paths and prev_log_paths to
|
|
|
1147
|
+ # show renames in the diff output.
|
|
|
1148
|
+ git_args.extend(git_path_args(log_paths | prev_log_paths))
|
|
|
1149
|
+ git_sequence.append(git_args)
|
|
|
1150
|
+ # Combine all the logs into one.
|
|
|
1151
|
+ git_run_pager(arg_sequence=git_sequence)
|
|
|
1152
|
+ continue
|
|
|
1153
|
+
|
|
|
1154
|
+ raise ValueError(f"Unexpected response: {response}")
|
|
475
|
1155
|
|
|
476
|
1156
|
|
|
477
|
|
-def auto_fixup(_args):
|
|
|
1157
|
+def auto_commit(_args: argparse.Namespace) -> None:
|
|
478
|
1158
|
"""
|
|
479
|
|
- Automatically find and fix up commits using the current unstaged changes.
|
|
|
1159
|
+ Automatically find and fix up commits for any pending changes.
|
|
480
|
1160
|
"""
|
|
|
1161
|
+ # Want git log and add to be run from the root.
|
|
|
1162
|
+ os.chdir(get_local_root())
|
|
481
|
1163
|
# Only want to search as far back as the firefox commit.
|
|
482
|
1164
|
firefox_commit = get_firefox_ref("HEAD").commit
|
|
483
|
1165
|
|
|
484
|
|
- staged_files = get_changed_files("HEAD", staged=True)
|
|
485
|
|
- if staged_files:
|
|
486
|
|
- raise TbDevException(f"Have already staged files: {staged_files}")
|
|
|
1166
|
+ staged_changes = [f for f in get_changed_files(staged=True)]
|
|
|
1167
|
+ if staged_changes:
|
|
|
1168
|
+ print("Existing staged changes for:")
|
|
|
1169
|
+ for file_change in staged_changes:
|
|
|
1170
|
+ print(f" {file_change.new_path}")
|
|
|
1171
|
+ if not prompt_user(
|
|
|
1172
|
+ "Include staged changes? (y/\x1b[4mn\x1b[0m)", binary_reply_default_no
|
|
|
1173
|
+ ):
|
|
|
1174
|
+ raise TbDevException("Cannot continue with pending staged changes")
|
|
|
1175
|
+ print("")
|
|
487
|
1176
|
|
|
488
|
|
- fixups = {}
|
|
489
|
|
- for filename in get_changed_files("HEAD"):
|
|
490
|
|
- commit = get_fixup_for_file(filename, firefox_commit)
|
|
491
|
|
- if commit is None:
|
|
|
1177
|
+ full_target_list: list[FixupTarget] = []
|
|
|
1178
|
+ # Determine if HEAD points to a branch or not and has an upstream commit.
|
|
|
1179
|
+ # We choose check=False since the exit status is non-zero when we are in a
|
|
|
1180
|
+ # detached state.
|
|
|
1181
|
+ head_symbolic_ref = git_get(["symbolic-ref", "-q", "HEAD"], check=False)
|
|
|
1182
|
+ if not head_symbolic_ref or not bool(
|
|
|
1183
|
+ git_get(["for-each-ref", "--format=%(upstream)", head_symbolic_ref])
|
|
|
1184
|
+ ):
|
|
|
1185
|
+ # Unexpected, but not fatal.
|
|
|
1186
|
+ print("HEAD has no upstream tracking!")
|
|
|
1187
|
+ # Just include all commits since firefox_commit with no fixup depth
|
|
|
1188
|
+ get_fixup_targets(full_target_list, firefox_commit, "HEAD", fixup_depth=0)
|
|
|
1189
|
+ else:
|
|
|
1190
|
+ upstream_commit = get_upstream_basis_commit("HEAD")
|
|
|
1191
|
+ # Only include "fixup!" commits that are between here and the upstream
|
|
|
1192
|
+ # tracking commit.
|
|
|
1193
|
+ get_fixup_targets(
|
|
|
1194
|
+ full_target_list, firefox_commit, upstream_commit, fixup_depth=0
|
|
|
1195
|
+ )
|
|
|
1196
|
+ get_fixup_targets(full_target_list, upstream_commit, "HEAD", fixup_depth=1)
|
|
|
1197
|
+
|
|
|
1198
|
+ # full_target_list is ordered with the earlier commits first. Reverse this.
|
|
|
1199
|
+ full_target_list.reverse()
|
|
|
1200
|
+ # Also reverse the fixups order to follow the same order.
|
|
|
1201
|
+ for target in full_target_list:
|
|
|
1202
|
+ target.fixups.reverse()
|
|
|
1203
|
+
|
|
|
1204
|
+ # Lazy load the list of firefox directories since they are unlikely to be
|
|
|
1205
|
+ # needed.
|
|
|
1206
|
+ @functools.cache
|
|
|
1207
|
+ def firefox_directories_lazy() -> set[str]:
|
|
|
1208
|
+ return {
|
|
|
1209
|
+ dir_name
|
|
|
1210
|
+ for dir_name in git_get(
|
|
|
1211
|
+ [
|
|
|
1212
|
+ "ls-tree",
|
|
|
1213
|
+ "-r",
|
|
|
1214
|
+ "-d",
|
|
|
1215
|
+ "--name-only",
|
|
|
1216
|
+ "--full-tree",
|
|
|
1217
|
+ "-z",
|
|
|
1218
|
+ firefox_commit,
|
|
|
1219
|
+ ],
|
|
|
1220
|
+ strip=False,
|
|
|
1221
|
+ ).split("\0")
|
|
|
1222
|
+ if dir_name
|
|
|
1223
|
+ }
|
|
|
1224
|
+
|
|
|
1225
|
+ # Check untracked files to be added.
|
|
|
1226
|
+ for path in git_get(
|
|
|
1227
|
+ ["ls-files", "--other", "--exclude-standard", "-z"], strip=False
|
|
|
1228
|
+ ).split("\0"):
|
|
|
1229
|
+ if not path:
|
|
492
|
1230
|
continue
|
|
493
|
|
- if commit not in fixups:
|
|
494
|
|
- fixups[commit] = [filename]
|
|
495
|
|
- else:
|
|
496
|
|
- fixups[commit].append(filename)
|
|
|
1231
|
+ if prompt_user(
|
|
|
1232
|
+ f"Start tracking file `{path}`? (y/\x1b[4mn\x1b[0m)",
|
|
|
1233
|
+ binary_reply_default_no,
|
|
|
1234
|
+ ):
|
|
|
1235
|
+ # Include in the git diff output, but do not stage.
|
|
|
1236
|
+ git_run(["add", "--intent-to-add", path])
|
|
497
|
1237
|
print("")
|
|
498
|
1238
|
|
|
499
|
|
- for commit, files in fixups.items():
|
|
500
|
|
- print("")
|
|
501
|
|
- git_run(["add", *files])
|
|
502
|
|
- git_run(["commit", f"--fixup={commit}"])
|
|
|
1239
|
+ aborted = False
|
|
|
1240
|
+ new_commits_list: list[NewCommit | NewFixup] = []
|
|
|
1241
|
+ # First go through staged changes.
|
|
|
1242
|
+ if staged_changes:
|
|
|
1243
|
+ common_fixup_targets = None
|
|
|
1244
|
+ for change in staged_changes:
|
|
|
1245
|
+ target_iter = get_suggested_fixup_targets_for_change(
|
|
|
1246
|
+ change, full_target_list, firefox_directories_lazy
|
|
|
1247
|
+ )
|
|
|
1248
|
+ if common_fixup_targets is None:
|
|
|
1249
|
+ common_fixup_targets = set(target_iter)
|
|
|
1250
|
+ else:
|
|
|
1251
|
+ common_fixup_targets.intersection_update(target_iter)
|
|
|
1252
|
+
|
|
|
1253
|
+ assert common_fixup_targets is not None
|
|
|
1254
|
+
|
|
|
1255
|
+ aborted = ask_for_target(
|
|
|
1256
|
+ staged_changes,
|
|
|
1257
|
+ new_commits_list,
|
|
|
1258
|
+ # Sort in the same order as full_target_list.
|
|
|
1259
|
+ [target for target in full_target_list if target in common_fixup_targets],
|
|
|
1260
|
+ full_target_list,
|
|
|
1261
|
+ staged=True,
|
|
|
1262
|
+ )
|
|
503
|
1263
|
print("")
|
|
504
|
1264
|
|
|
505
|
|
- if prompt_user(
|
|
506
|
|
- "Edit fixup commit message? (y/\x1b[4mn\x1b[0m)", binary_reply_default_no
|
|
507
|
|
- ):
|
|
|
1265
|
+ if not aborted:
|
|
|
1266
|
+ for file_change in get_changed_files():
|
|
|
1267
|
+ target_list = list(
|
|
|
1268
|
+ get_suggested_fixup_targets_for_change(
|
|
|
1269
|
+ file_change, full_target_list, firefox_directories_lazy
|
|
|
1270
|
+ )
|
|
|
1271
|
+ )
|
|
|
1272
|
+ aborted = ask_for_target(
|
|
|
1273
|
+ [file_change],
|
|
|
1274
|
+ new_commits_list,
|
|
|
1275
|
+ target_list,
|
|
|
1276
|
+ full_target_list,
|
|
|
1277
|
+ staged=False,
|
|
|
1278
|
+ )
|
|
|
1279
|
+ print("")
|
|
|
1280
|
+ if aborted:
|
|
|
1281
|
+ break
|
|
|
1282
|
+
|
|
|
1283
|
+ if aborted:
|
|
|
1284
|
+ return
|
|
|
1285
|
+
|
|
|
1286
|
+ # NOTE: Only the first commit can include staged changes.
|
|
|
1287
|
+ # This should already be the case, but we want to double check.
|
|
|
1288
|
+ for commit_index in range(1, len(new_commits_list)):
|
|
|
1289
|
+ if new_commits_list[commit_index].staged_paths:
|
|
|
1290
|
+ raise ValueError(f"Staged changes for commit {commit_index}")
|
|
|
1291
|
+
|
|
|
1292
|
+ for new_commit in new_commits_list:
|
|
|
1293
|
+ print("")
|
|
|
1294
|
+ if new_commit.adding_paths:
|
|
|
1295
|
+ git_run(["add", *git_path_args(new_commit.adding_paths)])
|
|
|
1296
|
+ if isinstance(new_commit, NewFixup):
|
|
|
1297
|
+ git_run(["commit", f"--fixup={new_commit.target.commit}"])
|
|
|
1298
|
+ print("")
|
|
|
1299
|
+ is_double_fixup = bool(new_commit.target.target)
|
|
|
1300
|
+ if not is_double_fixup and prompt_user(
|
|
|
1301
|
+ "Edit fixup commit message? (y/\x1b[4mn\x1b[0m)",
|
|
|
1302
|
+ binary_reply_default_no,
|
|
|
1303
|
+ ):
|
|
|
1304
|
+ git_run(["commit", "--amend"])
|
|
|
1305
|
+ print("")
|
|
|
1306
|
+ else:
|
|
|
1307
|
+ git_run(["commit", "-m", new_commit.alias])
|
|
508
|
1308
|
git_run(["commit", "--amend"])
|
|
|
1309
|
+ print("")
|
|
509
|
1310
|
|
|
510
|
1311
|
|
|
511
|
|
-def clean_fixups(_args):
|
|
|
1312
|
+def clean_fixups(_args: argparse.Namespace) -> None:
|
|
512
|
1313
|
"""
|
|
513
|
1314
|
Perform an interactive rebase that automatically applies fixups, similar to
|
|
514
|
1315
|
--autosquash but also works on fixups of fixups.
|
|
515
|
1316
|
"""
|
|
516
|
|
- user_editor = git_get(["var", "GIT_SEQUENCE_EDITOR"])[0]
|
|
|
1317
|
+ user_editor = git_get(["var", "GIT_SEQUENCE_EDITOR"])
|
|
517
|
1318
|
sub_editor = os.path.join(
|
|
518
|
1319
|
os.path.dirname(os.path.realpath(__file__)), FIXUP_PREPROCESSOR_EDITOR
|
|
519
|
1320
|
)
|
| ... |
... |
@@ -525,7 +1326,7 @@ def clean_fixups(_args): |
|
525
|
1326
|
)
|
|
526
|
1327
|
|
|
527
|
1328
|
|
|
528
|
|
-def show_default(_args):
|
|
|
1329
|
+def show_default(_args: argparse.Namespace) -> None:
|
|
529
|
1330
|
"""
|
|
530
|
1331
|
Print the default branch name from gitlab.
|
|
531
|
1332
|
"""
|
| ... |
... |
@@ -536,7 +1337,7 @@ def show_default(_args): |
|
536
|
1337
|
print(f"{upstream}/{default_branch}")
|
|
537
|
1338
|
|
|
538
|
1339
|
|
|
539
|
|
-def branch_from_default(args):
|
|
|
1340
|
+def branch_from_default(args: argparse.Namespace) -> None:
|
|
540
|
1341
|
"""
|
|
541
|
1342
|
Fetch the default gitlab branch from upstream and create a new local branch.
|
|
542
|
1343
|
"""
|
| ... |
... |
@@ -557,7 +1358,7 @@ def branch_from_default(args): |
|
557
|
1358
|
)
|
|
558
|
1359
|
|
|
559
|
1360
|
|
|
560
|
|
-def move_to_default(args):
|
|
|
1361
|
+def move_to_default(args: argparse.Namespace) -> None:
|
|
561
|
1362
|
"""
|
|
562
|
1363
|
Fetch the default gitlab branch from upstream and move the specified
|
|
563
|
1364
|
branch's commits on top. A new branch will be created tracking the default
|
| ... |
... |
@@ -569,7 +1370,7 @@ def move_to_default(args): |
|
569
|
1370
|
if branch_name is None:
|
|
570
|
1371
|
# Use current branch as default.
|
|
571
|
1372
|
try:
|
|
572
|
|
- branch_name = git_get(["branch", "--show-current"])[0]
|
|
|
1373
|
+ branch_name = git_get(["branch", "--show-current"])
|
|
573
|
1374
|
except IndexError:
|
|
574
|
1375
|
raise TbDevException("No current branch")
|
|
575
|
1376
|
|
| ... |
... |
@@ -608,7 +1409,7 @@ def move_to_default(args): |
|
608
|
1409
|
git_run(["cherry-pick", f"{current_basis}..{old_branch_name}"], check=False)
|
|
609
|
1410
|
|
|
610
|
1411
|
|
|
611
|
|
-def show_range_diff(args):
|
|
|
1412
|
+def show_range_diff(args: argparse.Namespace) -> None:
|
|
612
|
1413
|
"""
|
|
613
|
1414
|
Show the range diff between two branches, from their firefox bases.
|
|
614
|
1415
|
"""
|
| ... |
... |
@@ -624,21 +1425,21 @@ def show_range_diff(args): |
|
624
|
1425
|
)
|
|
625
|
1426
|
|
|
626
|
1427
|
|
|
627
|
|
-def show_diff_diff(args):
|
|
|
1428
|
+def show_diff_diff(args: argparse.Namespace) -> None:
|
|
628
|
1429
|
"""
|
|
629
|
1430
|
Show the diff between the diffs of two branches, relative to their firefox
|
|
630
|
1431
|
bases.
|
|
631
|
1432
|
"""
|
|
632
|
|
- config_res = git_get(["config", "--get", "diff.tool"])
|
|
633
|
|
- if not config_res:
|
|
|
1433
|
+ try:
|
|
|
1434
|
+ diff_tool = next(git_lines(["config", "--get", "diff.tool"]))
|
|
|
1435
|
+ except StopIteration:
|
|
634
|
1436
|
raise TbDevException("No diff.tool configured for git")
|
|
635
|
|
- diff_tool = config_res[0]
|
|
636
|
1437
|
|
|
637
|
1438
|
# Filter out parts of the diff we expect to be different.
|
|
638
|
1439
|
index_regex = re.compile(r"index [0-9a-f]{12}\.\.[0-9a-f]{12}")
|
|
639
|
1440
|
lines_regex = re.compile(r"@@ -[0-9]+,[0-9]+ \+[0-9]+,[0-9]+ @@(?P<rest>.*)")
|
|
640
|
1441
|
|
|
641
|
|
- def save_diff(branch):
|
|
|
1442
|
+ def save_diff(branch: str) -> str:
|
|
642
|
1443
|
firefox_commit = get_firefox_ref(branch).commit
|
|
643
|
1444
|
file_desc, file_name = tempfile.mkstemp(
|
|
644
|
1445
|
text=True, prefix=f'{branch.split("/")[-1]}-'
|
| ... |
... |
@@ -653,6 +1454,7 @@ def show_diff_diff(args): |
|
653
|
1454
|
)
|
|
654
|
1455
|
|
|
655
|
1456
|
with os.fdopen(file_desc, "w") as file:
|
|
|
1457
|
+ assert diff_process.stdout is not None
|
|
656
|
1458
|
for line in diff_process.stdout:
|
|
657
|
1459
|
if index_regex.match(line):
|
|
658
|
1460
|
# Fake data that will match.
|
| ... |
... |
@@ -665,7 +1467,7 @@ def show_diff_diff(args): |
|
665
|
1467
|
continue
|
|
666
|
1468
|
file.write(line)
|
|
667
|
1469
|
|
|
668
|
|
- status = diff_process.poll()
|
|
|
1470
|
+ status = diff_process.wait()
|
|
669
|
1471
|
if status != 0:
|
|
670
|
1472
|
raise TbDevException(f"git diff exited with status {status}")
|
|
671
|
1473
|
|
| ... |
... |
@@ -681,7 +1483,7 @@ def show_diff_diff(args): |
|
681
|
1483
|
# * -------------------- *
|
|
682
|
1484
|
|
|
683
|
1485
|
|
|
684
|
|
-def branch_complete(prefix, parsed_args, **kwargs):
|
|
|
1486
|
+def branch_complete(prefix: str, **_kwargs: Any) -> list[str]:
|
|
685
|
1487
|
"""
|
|
686
|
1488
|
Complete the argument with a branch name.
|
|
687
|
1489
|
"""
|
| ... |
... |
@@ -689,7 +1491,7 @@ def branch_complete(prefix, parsed_args, **kwargs): |
|
689
|
1491
|
return []
|
|
690
|
1492
|
try:
|
|
691
|
1493
|
branches = [ref.name for ref in get_refs("head", "")]
|
|
692
|
|
- branches.extend([ref.name for ref in get_refs("remote", "")])
|
|
|
1494
|
+ branches.extend(ref.name for ref in get_refs("remote", ""))
|
|
693
|
1495
|
branches.append("HEAD")
|
|
694
|
1496
|
except Exception:
|
|
695
|
1497
|
return []
|
| ... |
... |
@@ -699,7 +1501,20 @@ def branch_complete(prefix, parsed_args, **kwargs): |
|
699
|
1501
|
parser = argparse.ArgumentParser()
|
|
700
|
1502
|
subparsers = parser.add_subparsers(required=True)
|
|
701
|
1503
|
|
|
702
|
|
-for name, details in {
|
|
|
1504
|
+
|
|
|
1505
|
+class ArgConfig(TypedDict):
|
|
|
1506
|
+ help: str
|
|
|
1507
|
+ metavar: NotRequired[str]
|
|
|
1508
|
+ nargs: NotRequired[str]
|
|
|
1509
|
+ completer: NotRequired[Callable[[str], list[str]]]
|
|
|
1510
|
+
|
|
|
1511
|
+
|
|
|
1512
|
+class CommandConfig(TypedDict):
|
|
|
1513
|
+ func: Callable[[argparse.Namespace], None]
|
|
|
1514
|
+ args: NotRequired[dict[str, ArgConfig]]
|
|
|
1515
|
+
|
|
|
1516
|
+
|
|
|
1517
|
+all_commands: dict[str, CommandConfig] = {
|
|
703
|
1518
|
"show-upstream-basis-commit": {
|
|
704
|
1519
|
"func": show_upstream_basis_commit,
|
|
705
|
1520
|
},
|
| ... |
... |
@@ -716,8 +1531,8 @@ for name, details in { |
|
716
|
1531
|
},
|
|
717
|
1532
|
},
|
|
718
|
1533
|
},
|
|
719
|
|
- "auto-fixup": {
|
|
720
|
|
- "func": auto_fixup,
|
|
|
1534
|
+ "auto-commit": {
|
|
|
1535
|
+ "func": auto_commit,
|
|
721
|
1536
|
},
|
|
722
|
1537
|
"clean-fixups": {
|
|
723
|
1538
|
"func": clean_fixups,
|
| ... |
... |
@@ -794,20 +1609,25 @@ for name, details in { |
|
794
|
1609
|
"regex": {"help": "the regex that the files must contain"},
|
|
795
|
1610
|
},
|
|
796
|
1611
|
},
|
|
797
|
|
-}.items():
|
|
798
|
|
- help_message = re.sub(r"\s+", " ", details["func"].__doc__).strip()
|
|
|
1612
|
+}
|
|
|
1613
|
+
|
|
|
1614
|
+for name, command_config in all_commands.items():
|
|
|
1615
|
+ help_message = command_config["func"].__doc__
|
|
|
1616
|
+ assert isinstance(help_message, str)
|
|
|
1617
|
+ help_message = re.sub(r"\s+", " ", help_message).strip()
|
|
799
|
1618
|
sub = subparsers.add_parser(name, help=help_message)
|
|
800
|
|
- sub.set_defaults(func=details["func"])
|
|
801
|
|
- for arg, keywords in details.get("args", {}).items():
|
|
|
1619
|
+ sub.set_defaults(func=command_config["func"])
|
|
|
1620
|
+ for arg, keywords in command_config.get("args", {}).items():
|
|
802
|
1621
|
completer = None
|
|
803
|
1622
|
if "completer" in keywords:
|
|
804
|
1623
|
completer = keywords["completer"]
|
|
805
|
1624
|
del keywords["completer"]
|
|
806
|
1625
|
sub_arg = sub.add_argument(arg, **keywords)
|
|
807
|
|
- if completer is not None:
|
|
808
|
|
- sub_arg.completer = completer
|
|
|
1626
|
+ if completer is not None and argcomplete is not None:
|
|
|
1627
|
+ sub_arg.completer = completer # type: ignore
|
|
809
|
1628
|
|
|
810
|
|
-argcomplete.autocomplete(parser)
|
|
|
1629
|
+if argcomplete is not None:
|
|
|
1630
|
+ argcomplete.autocomplete(parser)
|
|
811
|
1631
|
|
|
812
|
1632
|
try:
|
|
813
|
1633
|
if not within_browser_root():
|