gitgym 0.1.0__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.
Files changed (111) hide show
  1. gitgym/__init__.py +6 -0
  2. gitgym/__main__.py +3 -0
  3. gitgym/cli.py +441 -0
  4. gitgym/config.py +9 -0
  5. gitgym/display.py +95 -0
  6. gitgym/exercise.py +45 -0
  7. gitgym/exercises/.gitkeep +0 -0
  8. gitgym/exercises/01_basics/01_init/exercise.toml +20 -0
  9. gitgym/exercises/01_basics/01_init/setup.sh +15 -0
  10. gitgym/exercises/01_basics/01_init/verify.sh +15 -0
  11. gitgym/exercises/01_basics/02_staging/exercise.toml +21 -0
  12. gitgym/exercises/01_basics/02_staging/setup.sh +18 -0
  13. gitgym/exercises/01_basics/02_staging/verify.sh +15 -0
  14. gitgym/exercises/01_basics/03_status/exercise.toml +28 -0
  15. gitgym/exercises/01_basics/03_status/setup.sh +27 -0
  16. gitgym/exercises/01_basics/03_status/verify.sh +35 -0
  17. gitgym/exercises/01_basics/04_first_commit/exercise.toml +22 -0
  18. gitgym/exercises/01_basics/04_first_commit/setup.sh +19 -0
  19. gitgym/exercises/01_basics/04_first_commit/verify.sh +24 -0
  20. gitgym/exercises/01_basics/05_gitignore/exercise.toml +30 -0
  21. gitgym/exercises/01_basics/05_gitignore/setup.sh +25 -0
  22. gitgym/exercises/01_basics/05_gitignore/verify.sh +35 -0
  23. gitgym/exercises/02_committing/01_amend/exercise.toml +22 -0
  24. gitgym/exercises/02_committing/01_amend/setup.sh +19 -0
  25. gitgym/exercises/02_committing/01_amend/verify.sh +28 -0
  26. gitgym/exercises/02_committing/02_multi_commit/exercise.toml +25 -0
  27. gitgym/exercises/02_committing/02_multi_commit/setup.sh +19 -0
  28. gitgym/exercises/02_committing/02_multi_commit/verify.sh +24 -0
  29. gitgym/exercises/02_committing/03_diff/exercise.toml +24 -0
  30. gitgym/exercises/02_committing/03_diff/setup.sh +29 -0
  31. gitgym/exercises/02_committing/03_diff/verify.sh +38 -0
  32. gitgym/exercises/03_branching/01_create_branch/exercise.toml +21 -0
  33. gitgym/exercises/03_branching/01_create_branch/setup.sh +20 -0
  34. gitgym/exercises/03_branching/01_create_branch/verify.sh +23 -0
  35. gitgym/exercises/03_branching/02_switch_branches/exercise.toml +21 -0
  36. gitgym/exercises/03_branching/02_switch_branches/setup.sh +23 -0
  37. gitgym/exercises/03_branching/02_switch_branches/verify.sh +16 -0
  38. gitgym/exercises/03_branching/03_delete_branch/exercise.toml +21 -0
  39. gitgym/exercises/03_branching/03_delete_branch/setup.sh +30 -0
  40. gitgym/exercises/03_branching/03_delete_branch/verify.sh +22 -0
  41. gitgym/exercises/04_merging/01_fast_forward/exercise.toml +25 -0
  42. gitgym/exercises/04_merging/01_fast_forward/setup.sh +33 -0
  43. gitgym/exercises/04_merging/01_fast_forward/verify.sh +42 -0
  44. gitgym/exercises/04_merging/02_three_way_merge/exercise.toml +24 -0
  45. gitgym/exercises/04_merging/02_three_way_merge/setup.sh +34 -0
  46. gitgym/exercises/04_merging/02_three_way_merge/verify.sh +39 -0
  47. gitgym/exercises/04_merging/03_merge_conflict/exercise.toml +25 -0
  48. gitgym/exercises/04_merging/03_merge_conflict/setup.sh +34 -0
  49. gitgym/exercises/04_merging/03_merge_conflict/verify.sh +46 -0
  50. gitgym/exercises/05_history/01_log_basics/exercise.toml +27 -0
  51. gitgym/exercises/05_history/01_log_basics/setup.sh +59 -0
  52. gitgym/exercises/05_history/01_log_basics/verify.sh +47 -0
  53. gitgym/exercises/05_history/02_log_graph/exercise.toml +28 -0
  54. gitgym/exercises/05_history/02_log_graph/setup.sh +44 -0
  55. gitgym/exercises/05_history/02_log_graph/verify.sh +35 -0
  56. gitgym/exercises/05_history/03_blame/exercise.toml +25 -0
  57. gitgym/exercises/05_history/03_blame/setup.sh +46 -0
  58. gitgym/exercises/05_history/03_blame/verify.sh +48 -0
  59. gitgym/exercises/05_history/04_show/exercise.toml +28 -0
  60. gitgym/exercises/05_history/04_show/setup.sh +35 -0
  61. gitgym/exercises/05_history/04_show/verify.sh +43 -0
  62. gitgym/exercises/06_undoing/01_restore_file/exercise.toml +27 -0
  63. gitgym/exercises/06_undoing/01_restore_file/setup.sh +32 -0
  64. gitgym/exercises/06_undoing/01_restore_file/verify.sh +33 -0
  65. gitgym/exercises/06_undoing/02_unstage/exercise.toml +24 -0
  66. gitgym/exercises/06_undoing/02_unstage/setup.sh +27 -0
  67. gitgym/exercises/06_undoing/02_unstage/verify.sh +30 -0
  68. gitgym/exercises/06_undoing/03_revert/exercise.toml +27 -0
  69. gitgym/exercises/06_undoing/03_revert/setup.sh +41 -0
  70. gitgym/exercises/06_undoing/03_revert/verify.sh +41 -0
  71. gitgym/exercises/06_undoing/04_reset_soft/exercise.toml +26 -0
  72. gitgym/exercises/06_undoing/04_reset_soft/setup.sh +28 -0
  73. gitgym/exercises/06_undoing/04_reset_soft/verify.sh +33 -0
  74. gitgym/exercises/06_undoing/05_reset_mixed/exercise.toml +27 -0
  75. gitgym/exercises/06_undoing/05_reset_mixed/setup.sh +32 -0
  76. gitgym/exercises/06_undoing/05_reset_mixed/verify.sh +33 -0
  77. gitgym/exercises/07_rebase/01_basic_rebase/exercise.toml +25 -0
  78. gitgym/exercises/07_rebase/01_basic_rebase/setup.sh +41 -0
  79. gitgym/exercises/07_rebase/01_basic_rebase/verify.sh +43 -0
  80. gitgym/exercises/07_rebase/02_interactive_rebase/exercise.toml +32 -0
  81. gitgym/exercises/07_rebase/02_interactive_rebase/setup.sh +37 -0
  82. gitgym/exercises/07_rebase/02_interactive_rebase/verify.sh +40 -0
  83. gitgym/exercises/07_rebase/03_rebase_conflict/exercise.toml +28 -0
  84. gitgym/exercises/07_rebase/03_rebase_conflict/setup.sh +35 -0
  85. gitgym/exercises/07_rebase/03_rebase_conflict/verify.sh +41 -0
  86. gitgym/exercises/08_stashing/01_stash_basics/exercise.toml +26 -0
  87. gitgym/exercises/08_stashing/01_stash_basics/setup.sh +33 -0
  88. gitgym/exercises/08_stashing/01_stash_basics/verify.sh +31 -0
  89. gitgym/exercises/08_stashing/02_stash_pop_apply/exercise.toml +29 -0
  90. gitgym/exercises/08_stashing/02_stash_pop_apply/setup.sh +28 -0
  91. gitgym/exercises/08_stashing/02_stash_pop_apply/verify.sh +33 -0
  92. gitgym/exercises/09_advanced/01_cherry_pick/exercise.toml +31 -0
  93. gitgym/exercises/09_advanced/01_cherry_pick/setup.sh +43 -0
  94. gitgym/exercises/09_advanced/01_cherry_pick/verify.sh +39 -0
  95. gitgym/exercises/09_advanced/02_bisect/exercise.toml +38 -0
  96. gitgym/exercises/09_advanced/02_bisect/setup.sh +73 -0
  97. gitgym/exercises/09_advanced/02_bisect/verify.sh +48 -0
  98. gitgym/exercises/09_advanced/03_tags/exercise.toml +31 -0
  99. gitgym/exercises/09_advanced/03_tags/setup.sh +30 -0
  100. gitgym/exercises/09_advanced/03_tags/verify.sh +65 -0
  101. gitgym/exercises/09_advanced/04_aliases/exercise.toml +33 -0
  102. gitgym/exercises/09_advanced/04_aliases/setup.sh +19 -0
  103. gitgym/exercises/09_advanced/04_aliases/verify.sh +27 -0
  104. gitgym/progress.py +86 -0
  105. gitgym/runner.py +105 -0
  106. gitgym/watcher.py +188 -0
  107. gitgym-0.1.0.dist-info/METADATA +140 -0
  108. gitgym-0.1.0.dist-info/RECORD +111 -0
  109. gitgym-0.1.0.dist-info/WHEEL +4 -0
  110. gitgym-0.1.0.dist-info/entry_points.txt +2 -0
  111. gitgym-0.1.0.dist-info/licenses/LICENSE +21 -0
gitgym/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("gitgym")
5
+ except PackageNotFoundError:
6
+ __version__ = "unknown"
gitgym/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from gitgym.cli import main
2
+
3
+ main()
gitgym/cli.py ADDED
@@ -0,0 +1,441 @@
1
+ import shutil
2
+ import subprocess
3
+
4
+ import click
5
+
6
+ from gitgym import __version__
7
+ from gitgym.config import GITGYM_HOME, WORKSPACE_DIR
8
+ from gitgym.display import (
9
+ print_exercise_header,
10
+ print_exercise_list,
11
+ print_progress_summary,
12
+ )
13
+ from gitgym.exercise import Exercise, load_all_exercises
14
+ from gitgym.progress import (
15
+ get_current_exercise,
16
+ increment_hints_used,
17
+ load_progress,
18
+ mark_completed,
19
+ mark_in_progress,
20
+ reset_all_progress,
21
+ reset_exercise_progress,
22
+ )
23
+ from gitgym.runner import run_setup, run_verify
24
+ from gitgym.watcher import watch_and_verify
25
+
26
+
27
+ class GitGymGroup(click.Group):
28
+ """Custom click.Group that checks for git before invoking any command."""
29
+
30
+ def invoke(self, ctx: click.Context) -> object:
31
+ if not _is_git_installed():
32
+ click.echo(
33
+ click.style(
34
+ "Error: git is not installed or not found in PATH.", fg="red"
35
+ ),
36
+ err=True,
37
+ )
38
+ click.echo(
39
+ "Please install git before using gitgym:\n"
40
+ " macOS: brew install git\n"
41
+ " Ubuntu: sudo apt install git\n"
42
+ " Windows: https://git-scm.com/download/win",
43
+ err=True,
44
+ )
45
+ ctx.exit(1)
46
+ return super().invoke(ctx)
47
+
48
+
49
+ def _is_git_installed() -> bool:
50
+ """Return True if git is available on PATH and responds to --version."""
51
+ if shutil.which("git") is None:
52
+ return False
53
+ try:
54
+ result = subprocess.run(
55
+ ["git", "--version"],
56
+ capture_output=True,
57
+ timeout=5,
58
+ )
59
+ return result.returncode == 0
60
+ except (OSError, subprocess.TimeoutExpired):
61
+ return False
62
+
63
+
64
+ @click.group(cls=GitGymGroup)
65
+ @click.version_option(version=__version__, prog_name="gitgym")
66
+ def main():
67
+ """gitgym - Learn git through interactive exercises."""
68
+
69
+
70
+ @main.command("list")
71
+ def list_exercises():
72
+ """List all exercises grouped by topic, showing completion status."""
73
+ exercises = load_all_exercises()
74
+ progress = load_progress()
75
+ print_exercise_list(exercises, progress)
76
+
77
+
78
+ def _exercise_key(exercise: Exercise) -> str:
79
+ """Derive the progress key (topic_dir/exercise_dir) from the exercise path."""
80
+ return f"{exercise.path.parent.name}/{exercise.path.name}"
81
+
82
+
83
+ def _check_all_completed() -> None:
84
+ """Print a congratulations message if every exercise is now completed."""
85
+ exercises = load_all_exercises()
86
+ progress = load_progress()
87
+ ex_progress = progress.get("exercises", {})
88
+ for exercise in exercises:
89
+ key = _exercise_key(exercise)
90
+ if ex_progress.get(key, {}).get("status") != "completed":
91
+ return
92
+ click.echo()
93
+ click.echo(
94
+ click.style(
95
+ "You've completed all exercises! Congratulations!", fg="green", bold=True
96
+ )
97
+ )
98
+ click.echo("Run 'gitgym clean' to remove exercise data from your system.")
99
+
100
+
101
+ def _find_next_incomplete(exercises: list[Exercise], progress: dict) -> Exercise | None:
102
+ """Return the first exercise that is not completed."""
103
+ ex_progress = progress.get("exercises", {})
104
+ for exercise in exercises:
105
+ key = _exercise_key(exercise)
106
+ status = ex_progress.get(key, {}).get("status", "not_started")
107
+ if status != "completed":
108
+ return exercise
109
+ return None
110
+
111
+
112
+ def _find_by_name(exercises: list[Exercise], name: str) -> Exercise | None:
113
+ """Return the exercise with the given name, or None if not found."""
114
+ for exercise in exercises:
115
+ if exercise.name == name:
116
+ return exercise
117
+ return None
118
+
119
+
120
+ @main.command("start")
121
+ @click.argument("exercise", required=False)
122
+ def start_exercise(exercise: str | None):
123
+ """Set up an exercise repo. If no name given, starts the next incomplete exercise."""
124
+ exercises = load_all_exercises()
125
+ progress = load_progress()
126
+
127
+ if exercise:
128
+ target = _find_by_name(exercises, exercise)
129
+ if target is None:
130
+ click.echo(
131
+ click.style(f"Error: No exercise named '{exercise}' found.", fg="red"),
132
+ err=True,
133
+ )
134
+ click.echo(
135
+ "Run 'gitgym list' to see exercise names (e.g. init, staging, amend).",
136
+ err=True,
137
+ )
138
+ raise SystemExit(1)
139
+ else:
140
+ target = _find_next_incomplete(exercises, progress)
141
+ if target is None:
142
+ click.echo(
143
+ click.style("All exercises are completed! Great work.", fg="green")
144
+ )
145
+ return
146
+
147
+ success = run_setup(target)
148
+ if not success:
149
+ raise SystemExit(1)
150
+
151
+ key = _exercise_key(target)
152
+ mark_in_progress(key)
153
+
154
+ workspace_path = WORKSPACE_DIR / target.path.parent.name / target.path.name
155
+ click.echo(click.style(f"Exercise directory: {workspace_path}", fg="cyan"))
156
+ click.echo(f" cd {workspace_path}\n")
157
+ print_exercise_header(target)
158
+
159
+
160
+ @main.command("next")
161
+ @click.pass_context
162
+ def next_exercise(ctx: click.Context):
163
+ """Alias for 'gitgym start' with no argument (starts the next incomplete exercise)."""
164
+ ctx.invoke(start_exercise)
165
+
166
+
167
+ @main.command("describe")
168
+ def describe_exercise():
169
+ """Print the current exercise's description and goal."""
170
+ current_key = get_current_exercise()
171
+ if current_key is None:
172
+ click.echo(
173
+ click.style("No exercise is currently in progress.", fg="yellow"),
174
+ err=True,
175
+ )
176
+ click.echo(
177
+ "Run 'gitgym start' or 'gitgym list' to begin an exercise.", err=True
178
+ )
179
+ raise SystemExit(1)
180
+
181
+ exercises = load_all_exercises()
182
+ target = None
183
+ for exercise in exercises:
184
+ if _exercise_key(exercise) == current_key:
185
+ target = exercise
186
+ break
187
+
188
+ if target is None:
189
+ click.echo(
190
+ click.style(
191
+ f"Error: Exercise '{current_key}' not found in exercise definitions.",
192
+ fg="red",
193
+ ),
194
+ err=True,
195
+ )
196
+ raise SystemExit(1)
197
+
198
+ print_exercise_header(target)
199
+
200
+
201
+ @main.command("verify")
202
+ def verify_exercise():
203
+ """Check if the current exercise's goal state is met."""
204
+ current_key = get_current_exercise()
205
+ if current_key is None:
206
+ click.echo(
207
+ click.style("No exercise is currently in progress.", fg="yellow"),
208
+ err=True,
209
+ )
210
+ click.echo(
211
+ "Run 'gitgym start' or 'gitgym list' to begin an exercise.", err=True
212
+ )
213
+ raise SystemExit(1)
214
+
215
+ exercises = load_all_exercises()
216
+ target = None
217
+ for exercise in exercises:
218
+ if _exercise_key(exercise) == current_key:
219
+ target = exercise
220
+ break
221
+
222
+ if target is None:
223
+ click.echo(
224
+ click.style(
225
+ f"Error: Exercise '{current_key}' not found in exercise definitions.",
226
+ fg="red",
227
+ ),
228
+ err=True,
229
+ )
230
+ raise SystemExit(1)
231
+
232
+ success, output, is_script_error = run_verify(target)
233
+
234
+ if output:
235
+ click.echo(output)
236
+
237
+ if success:
238
+ click.echo(click.style("Exercise complete! Great work.", fg="green"))
239
+ mark_completed(current_key)
240
+ _check_all_completed()
241
+ elif is_script_error:
242
+ click.echo(
243
+ click.style(
244
+ "The verify script encountered an unexpected error.\n"
245
+ "Try 'gitgym reset' to restore the exercise.",
246
+ fg="red",
247
+ ),
248
+ err=True,
249
+ )
250
+ raise SystemExit(1)
251
+ else:
252
+ click.echo(
253
+ click.style("Not quite right yet. Keep trying!", fg="yellow"), err=True
254
+ )
255
+ raise SystemExit(1)
256
+
257
+
258
+ @main.command("hint")
259
+ def hint_exercise():
260
+ """Show the next progressive hint for the current exercise."""
261
+ current_key = get_current_exercise()
262
+ if current_key is None:
263
+ click.echo(
264
+ click.style("No exercise is currently in progress.", fg="yellow"),
265
+ err=True,
266
+ )
267
+ click.echo(
268
+ "Run 'gitgym start' or 'gitgym list' to begin an exercise.", err=True
269
+ )
270
+ raise SystemExit(1)
271
+
272
+ exercises = load_all_exercises()
273
+ target = None
274
+ for exercise in exercises:
275
+ if _exercise_key(exercise) == current_key:
276
+ target = exercise
277
+ break
278
+
279
+ if target is None:
280
+ click.echo(
281
+ click.style(
282
+ f"Error: Exercise '{current_key}' not found in exercise definitions.",
283
+ fg="red",
284
+ ),
285
+ err=True,
286
+ )
287
+ raise SystemExit(1)
288
+
289
+ progress = load_progress()
290
+ hints_used = progress.get("exercises", {}).get(current_key, {}).get("hints_used", 0)
291
+ total_hints = len(target.hints)
292
+
293
+ if hints_used >= total_hints:
294
+ click.echo(click.style("No more hints available.", fg="yellow"))
295
+ return
296
+
297
+ hint_text = target.hints[hints_used]
298
+ click.echo(f"Hint {hints_used + 1}/{total_hints}: {hint_text}")
299
+
300
+ if hints_used + 1 >= total_hints:
301
+ click.echo("(No more hints available.)")
302
+
303
+ increment_hints_used(current_key)
304
+
305
+
306
+ @main.command("reset")
307
+ @click.argument("exercise", required=False)
308
+ @click.option(
309
+ "--all",
310
+ "reset_all",
311
+ is_flag=True,
312
+ default=False,
313
+ help="Reset all exercises and clear progress.",
314
+ )
315
+ def reset_exercise(exercise: str | None, reset_all: bool):
316
+ """Reset an exercise to its initial state.
317
+
318
+ If --all is given, deletes the workspace and clears all progress.
319
+ If EXERCISE is given, resets that exercise.
320
+ If neither is given, resets the current in-progress exercise.
321
+ """
322
+ if reset_all:
323
+ # Delete workspace directory and progress file
324
+ if WORKSPACE_DIR.exists():
325
+ shutil.rmtree(WORKSPACE_DIR)
326
+ reset_all_progress()
327
+ click.echo(click.style("All exercises reset. Progress cleared.", fg="green"))
328
+ return
329
+
330
+ exercises = load_all_exercises()
331
+
332
+ if exercise:
333
+ target = _find_by_name(exercises, exercise)
334
+ if target is None:
335
+ click.echo(
336
+ click.style(f"Error: No exercise named '{exercise}' found.", fg="red"),
337
+ err=True,
338
+ )
339
+ click.echo(
340
+ "Run 'gitgym list' to see exercise names (e.g. init, staging, amend).",
341
+ err=True,
342
+ )
343
+ raise SystemExit(1)
344
+ else:
345
+ current_key = get_current_exercise()
346
+ if current_key is None:
347
+ click.echo(
348
+ click.style("No exercise is currently in progress.", fg="yellow"),
349
+ err=True,
350
+ )
351
+ click.echo(
352
+ "Run 'gitgym start' or 'gitgym list' to begin an exercise.", err=True
353
+ )
354
+ raise SystemExit(1)
355
+
356
+ target = None
357
+ for ex in exercises:
358
+ if _exercise_key(ex) == current_key:
359
+ target = ex
360
+ break
361
+
362
+ if target is None:
363
+ click.echo(
364
+ click.style(
365
+ f"Error: Exercise '{current_key}' not found in exercise definitions.",
366
+ fg="red",
367
+ ),
368
+ err=True,
369
+ )
370
+ raise SystemExit(1)
371
+
372
+ success = run_setup(target)
373
+ if not success:
374
+ raise SystemExit(1)
375
+
376
+ key = _exercise_key(target)
377
+ reset_exercise_progress(key)
378
+ click.echo(click.style(f"Exercise '{target.name}' has been reset.", fg="green"))
379
+
380
+
381
+ @main.command("watch")
382
+ def watch_exercise():
383
+ """Watch mode: automatically re-verify on repo changes."""
384
+ current_key = get_current_exercise()
385
+ if current_key is None:
386
+ click.echo(
387
+ click.style("No exercise is currently in progress.", fg="yellow"),
388
+ err=True,
389
+ )
390
+ click.echo(
391
+ "Run 'gitgym start' or 'gitgym list' to begin an exercise.", err=True
392
+ )
393
+ raise SystemExit(1)
394
+
395
+ exercises = load_all_exercises()
396
+ target = None
397
+ for exercise in exercises:
398
+ if _exercise_key(exercise) == current_key:
399
+ target = exercise
400
+ break
401
+
402
+ if target is None:
403
+ click.echo(
404
+ click.style(
405
+ f"Error: Exercise '{current_key}' not found in exercise definitions.",
406
+ fg="red",
407
+ ),
408
+ err=True,
409
+ )
410
+ raise SystemExit(1)
411
+
412
+ def _on_completed():
413
+ mark_completed(current_key)
414
+ _check_all_completed()
415
+
416
+ watch_and_verify(target, on_completed=_on_completed)
417
+
418
+
419
+ @main.command("progress")
420
+ def show_progress():
421
+ """Show overall progress summary."""
422
+ exercises = load_all_exercises()
423
+ progress = load_progress()
424
+ print_progress_summary(exercises, progress)
425
+
426
+
427
+ @main.command("clean")
428
+ def clean():
429
+ """Remove all gitgym data (exercises, progress) from your system."""
430
+ if not GITGYM_HOME.exists():
431
+ click.echo("Nothing to clean up — no gitgym data found.")
432
+ return
433
+
434
+ click.echo(f"This will delete all gitgym data at {GITGYM_HOME}")
435
+ click.echo(" (exercise repos, progress, and all local state)")
436
+ if not click.confirm("Are you sure?"):
437
+ click.echo("Cancelled.")
438
+ return
439
+
440
+ shutil.rmtree(GITGYM_HOME)
441
+ click.echo(click.style("All cleaned up. Happy gitting!", fg="green"))
gitgym/config.py ADDED
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ # User's home directory for workspace and progress file
4
+ GITGYM_HOME = Path.home() / ".gitgym"
5
+ WORKSPACE_DIR = GITGYM_HOME / "exercises"
6
+ PROGRESS_FILE = GITGYM_HOME / "progress.json"
7
+
8
+ # Package-relative exercises directory (shipped with the package)
9
+ EXERCISES_DIR = Path(__file__).parent / "exercises"
gitgym/display.py ADDED
@@ -0,0 +1,95 @@
1
+ import click
2
+
3
+ from gitgym.exercise import Exercise
4
+
5
+
6
+ def print_success(msg: str) -> None:
7
+ """Print a success message in green."""
8
+ click.echo(click.style(msg, fg="green"))
9
+
10
+
11
+ def print_error(msg: str) -> None:
12
+ """Print an error message in red."""
13
+ click.echo(click.style(msg, fg="red"), err=True)
14
+
15
+
16
+ def print_info(msg: str) -> None:
17
+ """Print an informational message in cyan."""
18
+ click.echo(click.style(msg, fg="cyan"))
19
+
20
+
21
+ def print_exercise_header(exercise: Exercise) -> None:
22
+ """Print a formatted header for an exercise."""
23
+ click.echo(click.style(f"── {exercise.topic} ──", fg="yellow"))
24
+ click.echo(click.style(f"{exercise.title}", fg="bright_white", bold=True))
25
+ click.echo(f"\n{exercise.description.strip()}\n")
26
+ click.echo(click.style(f"Goal: {exercise.goal_summary}", fg="yellow"))
27
+
28
+
29
+ def _exercise_key(exercise: Exercise) -> str:
30
+ """Derive the progress key (topic_dir/exercise_dir) from the exercise path."""
31
+ return f"{exercise.path.parent.name}/{exercise.path.name}"
32
+
33
+
34
+ def print_exercise_list(exercises: list[Exercise], progress: dict) -> None:
35
+ """Print all exercises grouped by topic with completion status indicators."""
36
+ ex_progress = progress.get("exercises", {})
37
+ current_topic = None
38
+ for exercise in exercises:
39
+ if exercise.topic != current_topic:
40
+ current_topic = exercise.topic
41
+ click.echo(click.style(f"\n{exercise.topic}", fg="yellow", bold=True))
42
+ key = _exercise_key(exercise)
43
+ status = ex_progress.get(key, {}).get("status", "not_started")
44
+ if status == "completed":
45
+ indicator = click.style("✓", fg="green")
46
+ elif status == "in_progress":
47
+ indicator = click.style("→", fg="cyan")
48
+ else:
49
+ indicator = click.style("○", fg="white")
50
+ click.echo(f" {indicator} {exercise.name:<20} {exercise.title}")
51
+
52
+
53
+ def print_progress_summary(exercises: list[Exercise], progress: dict) -> None:
54
+ """Print overall progress stats: completed/total and per-topic breakdown."""
55
+ ex_progress = progress.get("exercises", {})
56
+
57
+ total = len(exercises)
58
+ completed_total = sum(
59
+ 1
60
+ for ex in exercises
61
+ if ex_progress.get(_exercise_key(ex), {}).get("status") == "completed"
62
+ )
63
+
64
+ click.echo(
65
+ click.style(
66
+ f"Progress: {completed_total}/{total} exercises completed", bold=True
67
+ )
68
+ )
69
+
70
+ # Group by topic
71
+ topics: dict[str, list[Exercise]] = {}
72
+ for ex in exercises:
73
+ topics.setdefault(ex.topic, []).append(ex)
74
+
75
+ for topic, topic_exercises in topics.items():
76
+ completed = sum(
77
+ 1
78
+ for ex in topic_exercises
79
+ if ex_progress.get(_exercise_key(ex), {}).get("status") == "completed"
80
+ )
81
+ total_in_topic = len(topic_exercises)
82
+ bar_filled = int(completed / total_in_topic * 10) if total_in_topic else 0
83
+ bar = "█" * bar_filled + "░" * (10 - bar_filled)
84
+ click.echo(f" {topic:<20} [{bar}] {completed}/{total_in_topic}")
85
+
86
+ if completed_total == total:
87
+ click.echo()
88
+ click.echo(
89
+ click.style(
90
+ "You've completed all exercises! Congratulations!",
91
+ fg="green",
92
+ bold=True,
93
+ )
94
+ )
95
+ click.echo("Run 'gitgym clean' to remove exercise data from your system.")
gitgym/exercise.py ADDED
@@ -0,0 +1,45 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from gitgym.config import EXERCISES_DIR
6
+
7
+
8
+ @dataclass
9
+ class Exercise:
10
+ name: str
11
+ topic: str
12
+ title: str
13
+ description: str
14
+ goal_summary: str
15
+ hints: list[str]
16
+ path: Path
17
+
18
+
19
+ def load_exercise(exercise_dir: Path) -> "Exercise":
20
+ toml_path = exercise_dir / "exercise.toml"
21
+ with open(toml_path, "rb") as f:
22
+ data = tomllib.load(f)
23
+ ex = data["exercise"]
24
+ hints = [h["text"] for h in data.get("hints", [])]
25
+ return Exercise(
26
+ name=ex["name"],
27
+ topic=ex["topic"],
28
+ title=ex["title"],
29
+ description=ex["description"],
30
+ goal_summary=data["goal"]["summary"],
31
+ hints=hints,
32
+ path=exercise_dir,
33
+ )
34
+
35
+
36
+ def load_all_exercises() -> list[Exercise]:
37
+ """Discover all NN_topic/NN_exercise/ directories under EXERCISES_DIR and return them sorted."""
38
+ exercise_dirs = sorted(
39
+ d
40
+ for topic_dir in sorted(EXERCISES_DIR.iterdir())
41
+ if topic_dir.is_dir()
42
+ for d in sorted(topic_dir.iterdir())
43
+ if d.is_dir()
44
+ )
45
+ return [load_exercise(d) for d in exercise_dirs]
File without changes
@@ -0,0 +1,20 @@
1
+ [exercise]
2
+ name = "init"
3
+ topic = "Basics"
4
+ title = "Initialize a Repository"
5
+ description = """
6
+ Every git journey starts with `git init`. In this exercise, you'll \
7
+ initialize a new git repository in the provided directory.
8
+ """
9
+
10
+ [goal]
11
+ summary = "The directory should be a valid git repository."
12
+
13
+ [[hints]]
14
+ text = "Look at the `git init` command."
15
+
16
+ [[hints]]
17
+ text = "Run `git init` inside the exercise directory."
18
+
19
+ [[hints]]
20
+ text = "Just run: git init"
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ EXERCISE_DIR="$1"
5
+ mkdir -p "$EXERCISE_DIR"
6
+ cd "$EXERCISE_DIR"
7
+
8
+ # Remove any existing git repo so the exercise is in the expected initial state
9
+ # (idempotent: running setup.sh a second time resets back to pre-init state)
10
+ if [ -d ".git" ]; then
11
+ rm -rf .git
12
+ fi
13
+
14
+ # Create a file for the user to work with
15
+ echo "Hello, git!" >hello.txt
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ EXERCISE_DIR="$1"
5
+ cd "$EXERCISE_DIR"
6
+
7
+ # Check that it's a git repo
8
+ if [ ! -d ".git" ]; then
9
+ echo "This directory is not a git repository yet."
10
+ echo "Use 'git init' to initialize it."
11
+ exit 1
12
+ fi
13
+
14
+ echo "Great job! You initialized a git repository."
15
+ exit 0
@@ -0,0 +1,21 @@
1
+ [exercise]
2
+ name = "staging"
3
+ topic = "Basics"
4
+ title = "Stage a File"
5
+ description = """
6
+ Before git can track a file, you need to stage it with `git add`. \
7
+ In this exercise, a file called `hello.txt` exists in the repo but \
8
+ has not been staged yet. Stage it so git will include it in the next commit.
9
+ """
10
+
11
+ [goal]
12
+ summary = "hello.txt should be staged (in the index)."
13
+
14
+ [[hints]]
15
+ text = "Use `git status` to see the current state of the repository."
16
+
17
+ [[hints]]
18
+ text = "Look at the `git add` command."
19
+
20
+ [[hints]]
21
+ text = "Run `git add hello.txt` to stage the file."