commitrocket 0.1.1__py3-none-any.whl
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.
- commitrocket/__init__.py +3 -0
- commitrocket/__main__.py +3 -0
- commitrocket/batching.py +184 -0
- commitrocket/cli.py +431 -0
- commitrocket/config.py +125 -0
- commitrocket/doctor.py +202 -0
- commitrocket/errors.py +14 -0
- commitrocket/eta.py +40 -0
- commitrocket/filters.py +72 -0
- commitrocket/git.py +228 -0
- commitrocket/journal.py +31 -0
- commitrocket/lfs.py +212 -0
- commitrocket/logs.py +67 -0
- commitrocket/notify.py +162 -0
- commitrocket/options.py +38 -0
- commitrocket/push.py +246 -0
- commitrocket/report.py +130 -0
- commitrocket/runner.py +680 -0
- commitrocket/security.py +50 -0
- commitrocket/template.py +37 -0
- commitrocket/ui.py +216 -0
- commitrocket/verify.py +129 -0
- commitrocket-0.1.1.dist-info/METADATA +520 -0
- commitrocket-0.1.1.dist-info/RECORD +27 -0
- commitrocket-0.1.1.dist-info/WHEEL +4 -0
- commitrocket-0.1.1.dist-info/entry_points.txt +2 -0
- commitrocket-0.1.1.dist-info/licenses/LICENSE +21 -0
commitrocket/security.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Small helpers that keep untrusted text from doing harm."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from commitrocket.errors import CommitRocketError
|
|
7
|
+
|
|
8
|
+
# user:password@ inside a URL, e.g. https://name:token@host/path
|
|
9
|
+
_CREDENTIALS = re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)[^/\s@]+@")
|
|
10
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact(text):
|
|
14
|
+
"""Hide credentials embedded in URLs before text is logged or shown."""
|
|
15
|
+
return _CREDENTIALS.sub(r"\1***@", text)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def redact_value(value):
|
|
19
|
+
"""redact() applied to every string inside nested dicts and lists."""
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return redact(value)
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {key: redact_value(item) for key, item in value.items()}
|
|
24
|
+
if isinstance(value, (list, tuple)):
|
|
25
|
+
return [redact_value(item) for item in value]
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_control_chars(text):
|
|
30
|
+
return bool(_CONTROL.search(text))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_ref_part(value, label):
|
|
34
|
+
"""Reject remote and branch names that Git could read as options."""
|
|
35
|
+
if not value or value.startswith("-") or has_control_chars(value) or any(
|
|
36
|
+
char.isspace() for char in value
|
|
37
|
+
):
|
|
38
|
+
raise CommitRocketError(
|
|
39
|
+
f"Invalid {label} {value!r}: it must not be empty, start with '-', "
|
|
40
|
+
"or contain spaces or control characters."
|
|
41
|
+
)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def restrict_permissions(path):
|
|
46
|
+
"""Make a file readable and writable by its owner only (best effort)."""
|
|
47
|
+
try:
|
|
48
|
+
os.chmod(path, 0o600)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
commitrocket/template.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
DEFAULT_TEMPLATE = "{message} #{n}"
|
|
4
|
+
|
|
5
|
+
PLACEHOLDERS = ("message", "n", "batch", "files", "size", "first", "date")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_message(template, message, n, batch, files, size_mib, first):
|
|
9
|
+
"""Fill in a commit message template for one batch.
|
|
10
|
+
|
|
11
|
+
{message} the --message text, {n} the commit number, {batch} the position
|
|
12
|
+
in this run, {files} the file count, {size} the size in MiB, {first} the
|
|
13
|
+
first file's path and {date} today's date (YYYY-MM-DD).
|
|
14
|
+
"""
|
|
15
|
+
return template.format(
|
|
16
|
+
message=message.rstrip(),
|
|
17
|
+
n=n,
|
|
18
|
+
batch=batch,
|
|
19
|
+
files=files,
|
|
20
|
+
size=size_mib,
|
|
21
|
+
first=first,
|
|
22
|
+
date=date.today().isoformat(),
|
|
23
|
+
).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_template(template):
|
|
27
|
+
"""Return an error string if the template can't be rendered, else None."""
|
|
28
|
+
try:
|
|
29
|
+
text = render_message(template, "msg", 1, 1, 1, 1.0, "a.txt")
|
|
30
|
+
except (KeyError, IndexError, ValueError, AttributeError) as exc:
|
|
31
|
+
return (
|
|
32
|
+
f"invalid --message-template ({exc!r}); "
|
|
33
|
+
f"available placeholders: {', '.join(PLACEHOLDERS)}"
|
|
34
|
+
)
|
|
35
|
+
if not text:
|
|
36
|
+
return "--message-template renders an empty commit message"
|
|
37
|
+
return None
|
commitrocket/ui.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from rich.markup import escape
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.progress import (
|
|
6
|
+
BarColumn,
|
|
7
|
+
Progress,
|
|
8
|
+
ProgressColumn,
|
|
9
|
+
SpinnerColumn,
|
|
10
|
+
TaskProgressColumn,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeElapsedColumn,
|
|
13
|
+
)
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from commitrocket.batching import MIB
|
|
18
|
+
from commitrocket.eta import format_duration
|
|
19
|
+
from commitrocket.git import get_file_size, get_pending_files
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EtaColumn(ProgressColumn):
|
|
23
|
+
"""Shows the run's own ETA for tasks created with an `estimator` field."""
|
|
24
|
+
|
|
25
|
+
def render(self, task):
|
|
26
|
+
estimator = task.fields.get("estimator")
|
|
27
|
+
if estimator is None:
|
|
28
|
+
return Text("")
|
|
29
|
+
|
|
30
|
+
remaining = estimator.estimate(task.completed, task.total or 0)
|
|
31
|
+
if remaining is None:
|
|
32
|
+
return Text("ETA estimating…", style="dim")
|
|
33
|
+
return Text(f"ETA {format_duration(remaining)}", style="progress.remaining")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_progress(console):
|
|
37
|
+
return Progress(
|
|
38
|
+
SpinnerColumn(),
|
|
39
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
40
|
+
BarColumn(),
|
|
41
|
+
TaskProgressColumn(),
|
|
42
|
+
TimeElapsedColumn(),
|
|
43
|
+
EtaColumn(),
|
|
44
|
+
console=console,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_run_info(console, repo, branch, upstream, limit_mib, max_mib, number):
|
|
49
|
+
info = Table.grid(padding=(0, 2))
|
|
50
|
+
info.add_column(style="bold cyan")
|
|
51
|
+
info.add_column()
|
|
52
|
+
info.add_row("Repository", Text(str(repo)))
|
|
53
|
+
info.add_row("Branch", Text(branch))
|
|
54
|
+
info.add_row("Push target", Text(upstream))
|
|
55
|
+
info.add_row("Normal batch limit", f"{limit_mib} MiB")
|
|
56
|
+
info.add_row("Maximum file size", f"{max_mib} MiB")
|
|
57
|
+
info.add_row("Starting commit #", str(number))
|
|
58
|
+
|
|
59
|
+
console.print(Panel(info, title="Git Batch Push", border_style="cyan"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_lfs_required(console, repo, paths, threshold_mib):
|
|
63
|
+
table = Table(title=f"Files over {threshold_mib} MiB", show_lines=True)
|
|
64
|
+
table.add_column("File", overflow="fold")
|
|
65
|
+
table.add_column("Size (MiB)", justify="right")
|
|
66
|
+
|
|
67
|
+
for path in sorted(paths):
|
|
68
|
+
table.add_row(Text(path), f"{get_file_size(repo, path) / MIB:.2f}")
|
|
69
|
+
|
|
70
|
+
console.print(table)
|
|
71
|
+
console.print(
|
|
72
|
+
f"\n[bold red]These files exceed the {threshold_mib} MiB limit of most "
|
|
73
|
+
"Git hosts and the push would be rejected.[/bold red]\n"
|
|
74
|
+
"[yellow]Nothing was committed. Run again with [bold]--use-lfs[/bold] "
|
|
75
|
+
"to store them with Git LFS.[/yellow]"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_resume(console, staged, entry, unpushed):
|
|
80
|
+
lines = []
|
|
81
|
+
if staged:
|
|
82
|
+
lines.append(
|
|
83
|
+
f"{len(staged)} file(s) were staged by an interrupted run and will be "
|
|
84
|
+
f"committed as: {entry['message']}"
|
|
85
|
+
)
|
|
86
|
+
if unpushed:
|
|
87
|
+
lines.append(f"{len(unpushed)} unpushed commit(s) will be pushed first:")
|
|
88
|
+
lines.extend(f" {commit}" for commit in unpushed[:10])
|
|
89
|
+
if len(unpushed) > 10:
|
|
90
|
+
lines.append(f" ... and {len(unpushed) - 10} more")
|
|
91
|
+
|
|
92
|
+
panel = Panel(Text("\n".join(lines)), title="Resuming", border_style="yellow")
|
|
93
|
+
console.print(panel)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_dry_run(
|
|
97
|
+
console, batches, deferred, messages, lfs_files, lfs_scope, no_push=False
|
|
98
|
+
):
|
|
99
|
+
table = Table(title="Dry run: planned batches", show_lines=True)
|
|
100
|
+
table.add_column("Batch", justify="right")
|
|
101
|
+
table.add_column("Commit message")
|
|
102
|
+
table.add_column("Files", justify="right")
|
|
103
|
+
table.add_column("Size (MiB)", justify="right")
|
|
104
|
+
table.add_column("Via LFS", justify="right")
|
|
105
|
+
|
|
106
|
+
total_files = 0
|
|
107
|
+
total_bytes = 0
|
|
108
|
+
for index, (paths, size) in enumerate(batches):
|
|
109
|
+
lfs_count = len(set(paths) & set(lfs_files))
|
|
110
|
+
table.add_row(
|
|
111
|
+
str(index + 1),
|
|
112
|
+
Text(messages[index]),
|
|
113
|
+
str(len(paths)),
|
|
114
|
+
f"{size / MIB:.2f}",
|
|
115
|
+
str(lfs_count) if lfs_count else "-",
|
|
116
|
+
)
|
|
117
|
+
total_files += len(paths)
|
|
118
|
+
total_bytes += size
|
|
119
|
+
|
|
120
|
+
console.print(table)
|
|
121
|
+
console.print(
|
|
122
|
+
f"{len(batches)} batch(es), {total_files} file(s), "
|
|
123
|
+
f"{total_bytes / MIB:.2f} MiB in total."
|
|
124
|
+
)
|
|
125
|
+
if lfs_scope:
|
|
126
|
+
console.print(f"[cyan]{lfs_scope}[/cyan]")
|
|
127
|
+
|
|
128
|
+
if deferred:
|
|
129
|
+
details = Table(title="Would be deferred (over --max-mib)", show_lines=True)
|
|
130
|
+
details.add_column("File", overflow="fold")
|
|
131
|
+
details.add_column("Size (MiB)", justify="right")
|
|
132
|
+
for path, size in sorted(deferred):
|
|
133
|
+
details.add_row(Text(path), f"{size / MIB:.2f}")
|
|
134
|
+
console.print(details)
|
|
135
|
+
|
|
136
|
+
if no_push:
|
|
137
|
+
console.print("[dim]--no-push: batches would be committed, not pushed.[/dim]")
|
|
138
|
+
|
|
139
|
+
console.print(
|
|
140
|
+
"\n[bold green]Dry run only: nothing was staged, committed, pushed or "
|
|
141
|
+
"changed.[/bold green]"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def print_stuck_commits(console, commits, blobs, upstream, threshold_mib):
|
|
146
|
+
table = Table(title=f"Unpushed files over {threshold_mib} MiB", show_lines=True)
|
|
147
|
+
table.add_column("File", overflow="fold")
|
|
148
|
+
table.add_column("Size (MiB)", justify="right")
|
|
149
|
+
|
|
150
|
+
for path, size in blobs:
|
|
151
|
+
table.add_row(Text(path), f"{size / MIB:.2f}")
|
|
152
|
+
|
|
153
|
+
console.print(table)
|
|
154
|
+
console.print(f"\n[bold]{len(commits)} unpushed local commit(s):[/bold]")
|
|
155
|
+
for commit in commits:
|
|
156
|
+
console.print(Text(f" {commit}"))
|
|
157
|
+
|
|
158
|
+
console.print(
|
|
159
|
+
f"\n[bold red]These commits already contain files over {threshold_mib} "
|
|
160
|
+
"MiB, so pushing them would be rejected.[/bold red]\n"
|
|
161
|
+
"[yellow]Nothing was changed. To drop those commits and keep your files "
|
|
162
|
+
"as untracked changes, run:[/yellow]\n\n"
|
|
163
|
+
f" git reset --mixed {escape(upstream)}\n\n"
|
|
164
|
+
"[yellow]then run CommitRocket again (the commits stay recoverable via "
|
|
165
|
+
"'git reflog').[/yellow]"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def print_final_summary(
|
|
170
|
+
console, repo, batch_count, started_at, max_mib, pending_files=None, pushed=True
|
|
171
|
+
):
|
|
172
|
+
"""Report files still deferred after all eligible batches finish."""
|
|
173
|
+
elapsed = time.monotonic() - started_at
|
|
174
|
+
pending = pending_files() if pending_files else get_pending_files(repo)
|
|
175
|
+
|
|
176
|
+
deferred = []
|
|
177
|
+
for path in pending:
|
|
178
|
+
size = get_file_size(repo, path)
|
|
179
|
+
if size > max_mib * MIB:
|
|
180
|
+
deferred.append((path, size))
|
|
181
|
+
|
|
182
|
+
table = Table(title="Execution Summary", show_lines=True)
|
|
183
|
+
table.add_column("Metric", style="cyan")
|
|
184
|
+
table.add_column("Value", justify="right")
|
|
185
|
+
|
|
186
|
+
label = "Batches pushed" if pushed else "Batches committed"
|
|
187
|
+
table.add_row(label, str(batch_count))
|
|
188
|
+
table.add_row("Elapsed time", f"{elapsed:.1f} seconds")
|
|
189
|
+
table.add_row("Deferred files", str(len(deferred)))
|
|
190
|
+
|
|
191
|
+
console.print()
|
|
192
|
+
console.print(Panel(table, border_style="green" if not deferred else "yellow"))
|
|
193
|
+
|
|
194
|
+
if not deferred:
|
|
195
|
+
console.print("[bold green]All eligible files processed.[/bold green]")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
details = Table(title="Deferred Files — Final Report", show_lines=True)
|
|
199
|
+
details.add_column("File", overflow="fold")
|
|
200
|
+
details.add_column("Size (MiB)", justify="right")
|
|
201
|
+
details.add_column("Reason", overflow="fold")
|
|
202
|
+
|
|
203
|
+
for path, size in sorted(deferred):
|
|
204
|
+
details.add_row(
|
|
205
|
+
Text(path),
|
|
206
|
+
f"{size / MIB:.2f}",
|
|
207
|
+
f"Exceeds --max-mib ({max_mib} MiB)",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
console.print(details)
|
|
211
|
+
console.print(
|
|
212
|
+
"\n[yellow]Increase --max-mib to include these files. "
|
|
213
|
+
"Deferred files were not committed by this run.[/yellow]"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return 2
|
commitrocket/verify.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
from commitrocket.errors import CommitRocketError, GitError
|
|
7
|
+
from commitrocket.git import get_remote_head, run_git
|
|
8
|
+
from commitrocket.lfs import LFS_EXCLUDED
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
|
13
|
+
MAX_POINTER_BYTES = 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_remote_head(repo, remote, ref, retries=0, sleep=time.sleep):
|
|
17
|
+
"""Raise unless the remote's branch points at the local HEAD."""
|
|
18
|
+
local = run_git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
19
|
+
|
|
20
|
+
attempt = 0
|
|
21
|
+
while True:
|
|
22
|
+
try:
|
|
23
|
+
remote_head = get_remote_head(repo, remote, ref)
|
|
24
|
+
break
|
|
25
|
+
except GitError:
|
|
26
|
+
if attempt >= retries:
|
|
27
|
+
raise
|
|
28
|
+
attempt += 1
|
|
29
|
+
sleep(2**attempt)
|
|
30
|
+
|
|
31
|
+
if remote_head != local:
|
|
32
|
+
raise CommitRocketError(
|
|
33
|
+
f"Push verification failed: {remote} {ref} is at "
|
|
34
|
+
f"{(remote_head or 'nothing')[:10]} but local HEAD is {local[:10]}."
|
|
35
|
+
)
|
|
36
|
+
logger.info("Verified %s %s is at %s", remote, ref, local[:10])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def expected_lfs_paths(selected, lfs_paths):
|
|
40
|
+
"""Files in a batch that should have been stored as LFS pointers."""
|
|
41
|
+
wanted = set(lfs_paths)
|
|
42
|
+
return [
|
|
43
|
+
path
|
|
44
|
+
for path in selected
|
|
45
|
+
if path in wanted and PurePosixPath(path).name not in LFS_EXCLUDED
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git_cat_file(repo, mode, specs):
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["git", "-C", str(repo), "cat-file", mode],
|
|
52
|
+
input="".join(f"{spec}\n" for spec in specs).encode("utf-8"),
|
|
53
|
+
capture_output=True,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
error = result.stderr.decode(errors="replace")
|
|
57
|
+
raise GitError(f"git cat-file failed:\n{error}")
|
|
58
|
+
return result.stdout
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_pointer(content):
|
|
62
|
+
return content.startswith(POINTER_PREFIX)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unpushed_has_lfs_pointers(repo, revs):
|
|
66
|
+
"""True if commits not yet pushed contain LFS pointer files.
|
|
67
|
+
|
|
68
|
+
Pushing those without the LFS hooks installed would publish pointers whose
|
|
69
|
+
data was never uploaded.
|
|
70
|
+
"""
|
|
71
|
+
objects = run_git(repo, "rev-list", "--objects", *revs).stdout
|
|
72
|
+
if not objects.strip():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
info = _git_cat_file(
|
|
76
|
+
repo,
|
|
77
|
+
"--batch-check=%(objecttype) %(objectname) %(objectsize) %(rest)",
|
|
78
|
+
objects.splitlines(),
|
|
79
|
+
)
|
|
80
|
+
small = []
|
|
81
|
+
for line in info.decode("utf-8", errors="replace").splitlines():
|
|
82
|
+
kind, name, size = (line.split(" ", 3) + ["", "", ""])[:3]
|
|
83
|
+
if kind == "blob" and size.isdigit() and int(size) <= MAX_POINTER_BYTES:
|
|
84
|
+
small.append(name)
|
|
85
|
+
|
|
86
|
+
if not small:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
data = _git_cat_file(repo, "--batch", small)
|
|
90
|
+
position = 0
|
|
91
|
+
for _ in small:
|
|
92
|
+
newline = data.index(b"\n", position)
|
|
93
|
+
size = int(data[position:newline].split()[2])
|
|
94
|
+
content = data[newline + 1 : newline + 1 + size]
|
|
95
|
+
position = newline + 1 + size + 1
|
|
96
|
+
if _is_pointer(content):
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_non_pointers(repo, paths):
|
|
102
|
+
"""Paths whose committed content in HEAD is not an LFS pointer."""
|
|
103
|
+
if not paths:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
specs = [f"HEAD:{path}" for path in paths]
|
|
107
|
+
sizes = _git_cat_file(repo, "--batch-check=%(objectsize)", specs)
|
|
108
|
+
sizes = sizes.decode("utf-8", errors="replace").splitlines()
|
|
109
|
+
|
|
110
|
+
bad = []
|
|
111
|
+
small = []
|
|
112
|
+
for path, size in zip(paths, sizes, strict=True):
|
|
113
|
+
if size.endswith("missing") or int(size) > MAX_POINTER_BYTES:
|
|
114
|
+
bad.append(path)
|
|
115
|
+
else:
|
|
116
|
+
small.append(path)
|
|
117
|
+
|
|
118
|
+
if small:
|
|
119
|
+
data = _git_cat_file(repo, "--batch", [f"HEAD:{path}" for path in small])
|
|
120
|
+
position = 0
|
|
121
|
+
for path in small:
|
|
122
|
+
newline = data.index(b"\n", position)
|
|
123
|
+
size = int(data[position:newline].split()[2])
|
|
124
|
+
content = data[newline + 1 : newline + 1 + size]
|
|
125
|
+
position = newline + 1 + size + 1
|
|
126
|
+
if not content.startswith(POINTER_PREFIX):
|
|
127
|
+
bad.append(path)
|
|
128
|
+
|
|
129
|
+
return bad
|