Coverage for gsb/cli.py: 100%

219 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-09 21:03 +0000

1"""Command-line interface""" 

2 

3import datetime as dt 

4import functools 

5import logging 

6import sys 

7from pathlib import Path 

8from typing import Any, Callable 

9 

10import click 

11 

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 

21 

22LOGGER = logging.getLogger(__package__) 

23 

24 

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!""" 

30 

31 

32def _subcommand_init(command: Callable) -> Callable: 

33 """Register a subcommand and add some standard CLI handling""" 

34 

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) 

40 

41 log_level = verbosity_to_log_level(verbose - quiet) 

42 

43 cli_handler.setLevel(log_level) 

44 

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) 

52 

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) 

62 

63 wrapped = click.option( 

64 "--verbose", 

65 "-v", 

66 count=True, 

67 help="Increase the amount of information that's printed.", 

68 )(wrapped) 

69 

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) 

77 

78 

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 

129 

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"] 

160 

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 

172 

173 

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) 

206 

207 

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.""" 

244 

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 

256 

257 history_.show_history( 

258 path_as_arg or repo_root, **kwargs, always_include_latest=True 

259 ) 

260 

261 

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) 

314 

315 

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] 

342 

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 ) 

348 

349 choice: str = click.prompt( 

350 "Select a revision", default="q", show_default=True, type=str 

351 ).lower() 

352 

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 

361 

362 

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""" 

366 

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) 

379 

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") 

385 

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) 

394 

395 

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) 

415 

416 

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) 

439 

440 choices: str = click.prompt( 

441 "Select a revision or revisions", default="q", show_default=True, type=str 

442 ).lower() 

443 

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(",")) 

448 

449 

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] 

522 

523 if len(specified_formats) > 1: 

524 LOGGER.error("Conflicting values given for archive format") 

525 sys.exit(1) 

526 

527 if revision is None: 

528 revision = _prompt_for_a_recent_revision(repo_root) 

529 

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}") 

542 

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) 

548 

549 

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 

560 

561 pytest.main(["--pyargs", "gsb.test", *pytest_args])