Coverage for gsb/cli.py: 100%
219 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-09 21:05 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-09 21:05 +0000
1"""Command-line interface"""
3import datetime as dt
4import functools
5import logging
6import sys
7from pathlib import Path
8from typing import Any, Callable
10import click
12from . import _git, _version
13from . import backup as backup_
14from . import export as export_
15from . import fastforward
16from . import history as history_
17from . import onboard
18from . import rewind as rewind_
19from .logging import IMPORTANT, CLIFormatter, verbosity_to_log_level
20from .manifest import Manifest
22LOGGER = logging.getLogger(__package__)
25@click.group()
26@click.help_option("--help", "-h")
27@click.version_option(_version.get_versions()["version"], "--version", "-v", "-V")
28def gsb():
29 """CLI for managing incremental backups of your save states using Git!"""
32def _subcommand_init(command: Callable) -> Callable:
33 """Register a subcommand and add some standard CLI handling"""
35 @functools.wraps(command)
36 def wrapped(path: Path | None, verbose: int, quiet: int, *args, **kwargs) -> None:
37 cli_handler = logging.StreamHandler()
38 cli_handler.setFormatter(CLIFormatter())
39 LOGGER.addHandler(cli_handler)
41 log_level = verbosity_to_log_level(verbose - quiet)
43 cli_handler.setLevel(log_level)
45 # TODO: when we add log files, set this to minimum log level across all handlers
46 LOGGER.setLevel(log_level)
47 try:
48 command((path or Path()).absolute(), *args, **kwargs)
49 except (OSError, ValueError) as oh_no:
50 LOGGER.error(oh_no)
51 sys.exit(1)
53 wrapped = click.option(
54 "--path",
55 type=Path,
56 metavar="SAVE_PATH",
57 help=(
58 "Optionally specify the root directory containing your save data."
59 " If no path is given, the current working directory will be used."
60 ),
61 )(wrapped)
63 wrapped = click.option(
64 "--verbose",
65 "-v",
66 count=True,
67 help="Increase the amount of information that's printed.",
68 )(wrapped)
70 wrapped = click.option(
71 "--quiet",
72 "-q",
73 count=True,
74 help="Decrease the amount of information that's printed.",
75 )(wrapped)
76 return gsb.command()(wrapped)
79@click.option(
80 "--ignore-empty",
81 "-i",
82 is_flag=True,
83 help=(
84 "Do not return an error code if there's nothing to commit"
85 " (this flag only applies for untagged backups)"
86 ),
87)
88@click.option(
89 "--tag",
90 type=str,
91 help='Specify a description for this backup and "tag" it for future reference.',
92 metavar='"MESSAGE"',
93)
94@click.option(
95 "--combine",
96 "-c",
97 count=True,
98 help=(
99 "Combine this backup and the last backup,"
100 " or use -cc to combine ALL backups since the last tagged backup."
101 ),
102)
103@click.argument(
104 "path_as_arg",
105 type=Path,
106 required=False,
107 metavar="[SAVE_PATH]",
108)
109@_subcommand_init
110def backup(
111 repo_root: Path,
112 path_as_arg: Path | None,
113 tag: str | None,
114 combine: int,
115 ignore_empty: bool,
116):
117 """Create a new backup."""
118 parent_hash = None
119 if combine == 1:
120 try:
121 combine_me, parent = history_.get_history(
122 repo_root, tagged_only=False, include_non_gsb=True, limit=2
123 )
124 except ValueError as probably_not_enough_values:
125 if "not enough values to unpack" in str(probably_not_enough_values):
126 LOGGER.error("Cannot combine with the very first backup.")
127 sys.exit(1)
128 raise probably_not_enough_values # pragma: no cover
130 LOGGER.log(IMPORTANT, "Combining with %s", combine_me["identifier"])
131 if combine_me["tagged"]:
132 LOGGER.warning("Are you sure you want to overwrite a tagged backup?")
133 history_.log_revision(combine_me, None)
134 confirmed: bool = click.confirm(
135 "",
136 default=False,
137 show_default=True,
138 )
139 if not confirmed:
140 LOGGER.error("Aborting.")
141 sys.exit(1)
142 _git.delete_tag(repo_root, combine_me["identifier"])
143 parent_hash = parent["identifier"]
144 if combine > 1:
145 try:
146 last_tag = history_.get_history(repo_root, tagged_only=True, limit=1)[0]
147 except IndexError:
148 LOGGER.error("There are no previous tagged backups.")
149 sys.exit(1)
150 LOGGER.log(IMPORTANT, "Combining with the following backups:")
151 combining = history_.show_history(
152 repo_root,
153 tagged_only=False,
154 include_non_gsb=True,
155 since_last_tagged_backup=True,
156 )
157 if not combining:
158 LOGGER.log(IMPORTANT, "(no backups to combine)")
159 parent_hash = last_tag["identifier"]
161 try:
162 backup_.create_backup(
163 path_as_arg or repo_root,
164 tag,
165 parent=parent_hash,
166 )
167 except ValueError as nothing_to_commit:
168 if ignore_empty:
169 LOGGER.warning("Nothing to commit")
170 else:
171 raise nothing_to_commit
174@click.option(
175 "--ignore",
176 type=str,
177 required=False,
178 multiple=True,
179 help=(
180 "Provide a glob pattern to ignore. Each ignore pattern"
181 ' must be prefaced with the "--ignore" flag.'
182 ),
183)
184@click.option(
185 "--track",
186 type=str,
187 required=False,
188 multiple=True,
189 help=(
190 "Provide a glob pattern to track (note: arguments without any flag will"
191 " also be treated as track patterns)."
192 ),
193)
194@click.argument(
195 "track_args", type=str, required=False, nargs=-1, metavar="[TRACK_PATTERN]..."
196)
197@_subcommand_init
198def init(
199 repo_root: Path,
200 track_args: tuple[str, ...],
201 track: tuple[str, ...],
202 ignore: tuple[str, ...],
203):
204 """Start tracking a save."""
205 onboard.create_repo(repo_root, *track_args, *track, ignore=ignore)
208@click.option(
209 "--include-non-gsb",
210 "-g",
211 is_flag=True,
212 help="Include backups created directly with Git / outside of gsb.",
213)
214@click.option("--all", "-a", "all_", is_flag=True, help="Include non-tagged backups.")
215@click.option(
216 "--since",
217 type=click.DateTime(),
218 required=False,
219 help="Only show backups created after the specified date.",
220)
221@click.option(
222 "--limit",
223 "-n",
224 type=int,
225 required=False,
226 help="The maximum number of backups to return.",
227)
228@click.argument(
229 "path_as_arg",
230 type=Path,
231 required=False,
232 metavar="[SAVE_PATH]",
233)
234@_subcommand_init
235def history(
236 repo_root: Path,
237 path_as_arg: Path | None,
238 limit: int | None,
239 since: dt.datetime | None,
240 all_: bool,
241 include_non_gsb: bool,
242):
243 """List the available backups, starting with the most recent."""
245 kwargs: dict[str, Any] = {
246 "tagged_only": not all_,
247 "include_non_gsb": include_non_gsb,
248 }
249 if limit is not None:
250 if limit <= 0:
251 LOGGER.error("Limit must be a positive integer")
252 sys.exit(1)
253 kwargs["limit"] = limit
254 if since is not None:
255 kwargs["since"] = since
257 history_.show_history(
258 path_as_arg or repo_root, **kwargs, always_include_latest=True
259 )
262@click.option(
263 "--include-gsb-settings",
264 is_flag=True,
265 help="Also revert the GSB configuration files (including .gitignore)",
266)
267@click.option(
268 "--delete-original",
269 is_flag=True,
270 help="Delete the original backup from the history (incompatible with --hard)",
271)
272@click.option(
273 "--hard",
274 is_flag=True,
275 help=(
276 "Discard any changes (backed up or no)"
277 " since the specified (default: latest) revision"
278 ),
279)
280@click.argument(
281 "revision",
282 type=str,
283 required=False,
284)
285@_subcommand_init
286def rewind(
287 repo_root: Path,
288 revision: str | None,
289 hard: bool,
290 delete_original: bool,
291 include_gsb_settings: bool,
292):
293 """Restore a backup to the specified REVISION."""
294 if hard:
295 if delete_original:
296 LOGGER.error("--delete-original and --hard cannot be used together")
297 sys.exit(1)
298 if revision is None:
299 revision = history_.get_history(
300 repo_root, tagged_only=False, include_non_gsb=True, limit=1
301 )[0]["identifier"]
302 _enumerate_revisions_to_be_discarded(repo_root, revision)
303 if revision is None:
304 revision = _prompt_for_a_recent_revision(repo_root)
305 try:
306 rewind_.restore_backup(
307 repo_root, revision, keep_gsb_files=not include_gsb_settings, hard=hard
308 )
309 if delete_original:
310 fastforward.delete_backups(repo_root, revision)
311 except ValueError as whats_that:
312 LOGGER.error(whats_that)
313 sys.exit(1)
316def _prompt_for_a_recent_revision(repo_root: Path) -> str:
317 """Select a recent revision from a prompt"""
318 LOGGER.log(IMPORTANT, "Here is a list of recent backups:")
319 revisions = history_.show_history(repo_root, limit=10)
320 if len(revisions) == 0:
321 LOGGER.info("No tagged revisions found. Trying untagged.")
322 revisions = history_.show_history(
323 repo_root,
324 limit=10,
325 tagged_only=False,
326 )
327 if len(revisions) == 0:
328 LOGGER.warning("No GSB revisions found. Trying Git.")
329 revisions = history_.show_history(
330 repo_root,
331 limit=10,
332 tagged_only=False,
333 include_non_gsb=True,
334 )
335 if len(revisions) == 0:
336 LOGGER.error("No revisions found!")
337 sys.exit(1)
338 LOGGER.log(IMPORTANT, "\nMost recent backup:")
339 most_recent_backup = history_.show_history(
340 repo_root, limit=1, always_include_latest=True, numbering=0
341 )[0]
343 LOGGER.log(
344 IMPORTANT,
345 "\nSelect one by number or identifier (or [q]uit and"
346 " call gsb history yourself to get more revisions).",
347 )
349 choice: str = click.prompt(
350 "Select a revision", default="q", show_default=True, type=str
351 ).lower()
353 if choice.lower().strip() == "q":
354 LOGGER.error("Aborting.")
355 sys.exit(1)
356 if choice.strip() == "0":
357 return most_recent_backup["identifier"]
358 if choice.strip() in [str(i + 1) for i in range(len(revisions))]:
359 return revisions[int(choice.strip()) - 1]["identifier"]
360 return choice
363def _enumerate_revisions_to_be_discarded(repo_root: Path, restore_point: str) -> None:
364 """List all the backups to be deleted by a hard restore to the restore-point,
365 then ask the user for confirmation"""
367 # figure out how far back this is in the history
368 break_point = _git.show(repo_root, restore_point)
369 if isinstance(break_point, _git.Tag):
370 break_point = break_point.target
371 limit = 0
372 for revision in _git.log(repo_root):
373 if revision == break_point:
374 break
375 limit += 1
376 else: # pragma: no cover
377 LOGGER.error("Specified revision is not in the current history. Cannot rewind.")
378 sys.exit(1)
380 LOGGER.warning("The following backups will be deleted:")
381 history_.show_history(
382 repo_root, numbering=None, tagged_only=False, include_non_gsb=True, limit=limit
383 )
384 LOGGER.warning("\nalong with any unsaved changes.\n")
386 confirmed: bool = click.confirm(
387 "Are you sure you wish to continue?",
388 default=False,
389 show_default=True,
390 )
391 if not confirmed:
392 LOGGER.error("Aborting.")
393 sys.exit(1)
396@click.argument(
397 "revisions", type=str, required=False, nargs=-1, metavar="[REVISION]..."
398)
399@_subcommand_init
400def delete(repo_root: Path, revisions: tuple[str, ...]):
401 """Delete one or more backups by their specified REVISION."""
402 if not revisions:
403 revisions = _prompt_for_revisions_to_delete(repo_root)
404 try:
405 fastforward.delete_backups(repo_root, *revisions)
406 LOGGER.log(
407 IMPORTANT,
408 'Deleted backups are now marked as "loose."'
409 " To delete them immediately, run the command:"
410 "\n git gc --aggressive --prune=now",
411 )
412 except ValueError as whats_that:
413 LOGGER.error(whats_that)
414 sys.exit(1)
417def _prompt_for_revisions_to_delete(repo_root: Path) -> tuple[str, ...]:
418 """Offer a history of revisions to delete. Unlike similar prompts, this
419 is a multiselect and requires the user to type in each entry (in order to guard
420 against accidental deletions)."""
421 LOGGER.log(IMPORTANT, "Here is a list of recent GSB-created backups:")
422 revisions = history_.show_history(
423 repo_root, tagged_only=False, since_last_tagged_backup=True, numbering=None
424 )
425 revisions.extend(history_.show_history(repo_root, limit=3, numbering=None))
426 if len(revisions) == 0:
427 LOGGER.warning("No GSB revisions found. Trying Git.")
428 revisions = history_.show_history(
429 repo_root, limit=10, tagged_only=False, include_non_gsb=True, numbering=None
430 )
431 LOGGER.log(
432 IMPORTANT,
433 "\nSelect a backup to delete by identifier, or multiple separated by commas."
434 "\nAlternatively, [q]uit and call gsb history yourself to get more revisions.",
435 )
436 if len(revisions) == 0:
437 LOGGER.error("No revisions found!")
438 sys.exit(1)
440 choices: str = click.prompt(
441 "Select a revision or revisions", default="q", show_default=True, type=str
442 ).lower()
444 if choices.lower().strip() == "q":
445 LOGGER.error("Aborting.")
446 sys.exit(1)
447 return tuple(choice.strip() for choice in choices.strip().split(","))
450@click.option(
451 "-J",
452 "xz_flag",
453 is_flag=True,
454 flag_value="tar.xz",
455 help="Export as a .tar.xz archive.",
456)
457@click.option(
458 "-j",
459 "bz2_flag",
460 is_flag=True,
461 flag_value="tar.bz2",
462 help="Export as a .tar.bz2 archive.",
463)
464@click.option(
465 "-z",
466 "gz_flag",
467 is_flag=True,
468 flag_value="tar.gz",
469 help="Export as a .tar.gz archive.",
470)
471@click.option(
472 "-t",
473 "tar_flag",
474 is_flag=True,
475 flag_value="tar",
476 help="Export as an uncompressed .tar archive.",
477)
478@click.option(
479 "-p",
480 "zip_flag",
481 is_flag=True,
482 flag_value="zip",
483 help="Export as a .zip archive.",
484)
485@click.option(
486 "--format",
487 "archive_format",
488 type=str,
489 required=False,
490 help=(
491 "Format for the archived backup. If not specified,"
492 " an appropriate one will be chosen based on your OS."
493 ),
494 metavar="FORMAT",
495)
496@click.option(
497 "--output",
498 "-o",
499 type=Path,
500 required=False,
501 help=(
502 "Explicitly specify a filename for the archived backup."
503 " The format of the archive will be inferred from the extension"
504 " unless a format flag is provided."
505 ),
506 metavar="FILENAME",
507)
508@click.argument(
509 "revision",
510 type=str,
511 required=False,
512)
513@_subcommand_init
514def export(
515 repo_root: Path,
516 revision: str | None,
517 output: Path | None,
518 **format_flags,
519):
520 """Create a stand-alone archive of the specified REVISION."""
521 specified_formats: list[str] = [value for value in format_flags.values() if value]
523 if len(specified_formats) > 1:
524 LOGGER.error("Conflicting values given for archive format")
525 sys.exit(1)
527 if revision is None:
528 revision = _prompt_for_a_recent_revision(repo_root)
530 if len(specified_formats) == 1:
531 archive_format = specified_formats[0]
532 if archive_format.startswith("."):
533 archive_format = archive_format[1:]
534 if output is None:
535 output = Path(
536 export_.generate_archive_name(
537 Manifest.of(repo_root).name, revision, extension=archive_format
538 )
539 )
540 else:
541 output = output.parent / (output.name + f".{archive_format}")
543 try:
544 export_.export_backup(repo_root, revision, output)
545 except ValueError as whats_that:
546 LOGGER.error(whats_that)
547 sys.exit(1)
550@click.argument(
551 "pytest_args",
552 nargs=-1,
553)
554@gsb.command(context_settings={"ignore_unknown_options": True})
555def test(pytest_args: tuple[str, ...]): # pragma: no cover
556 """Run the GSB test suite to ensure that it is running correctly on your system.
557 Requires you to have installed GSB with the test extra
558 (_i.e._ `pipx install gsb[test]`)."""
559 import pytest
561 pytest.main(["--pyargs", "gsb.test", *pytest_args])