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.
- gitgym/__init__.py +6 -0
- gitgym/__main__.py +3 -0
- gitgym/cli.py +441 -0
- gitgym/config.py +9 -0
- gitgym/display.py +95 -0
- gitgym/exercise.py +45 -0
- gitgym/exercises/.gitkeep +0 -0
- gitgym/exercises/01_basics/01_init/exercise.toml +20 -0
- gitgym/exercises/01_basics/01_init/setup.sh +15 -0
- gitgym/exercises/01_basics/01_init/verify.sh +15 -0
- gitgym/exercises/01_basics/02_staging/exercise.toml +21 -0
- gitgym/exercises/01_basics/02_staging/setup.sh +18 -0
- gitgym/exercises/01_basics/02_staging/verify.sh +15 -0
- gitgym/exercises/01_basics/03_status/exercise.toml +28 -0
- gitgym/exercises/01_basics/03_status/setup.sh +27 -0
- gitgym/exercises/01_basics/03_status/verify.sh +35 -0
- gitgym/exercises/01_basics/04_first_commit/exercise.toml +22 -0
- gitgym/exercises/01_basics/04_first_commit/setup.sh +19 -0
- gitgym/exercises/01_basics/04_first_commit/verify.sh +24 -0
- gitgym/exercises/01_basics/05_gitignore/exercise.toml +30 -0
- gitgym/exercises/01_basics/05_gitignore/setup.sh +25 -0
- gitgym/exercises/01_basics/05_gitignore/verify.sh +35 -0
- gitgym/exercises/02_committing/01_amend/exercise.toml +22 -0
- gitgym/exercises/02_committing/01_amend/setup.sh +19 -0
- gitgym/exercises/02_committing/01_amend/verify.sh +28 -0
- gitgym/exercises/02_committing/02_multi_commit/exercise.toml +25 -0
- gitgym/exercises/02_committing/02_multi_commit/setup.sh +19 -0
- gitgym/exercises/02_committing/02_multi_commit/verify.sh +24 -0
- gitgym/exercises/02_committing/03_diff/exercise.toml +24 -0
- gitgym/exercises/02_committing/03_diff/setup.sh +29 -0
- gitgym/exercises/02_committing/03_diff/verify.sh +38 -0
- gitgym/exercises/03_branching/01_create_branch/exercise.toml +21 -0
- gitgym/exercises/03_branching/01_create_branch/setup.sh +20 -0
- gitgym/exercises/03_branching/01_create_branch/verify.sh +23 -0
- gitgym/exercises/03_branching/02_switch_branches/exercise.toml +21 -0
- gitgym/exercises/03_branching/02_switch_branches/setup.sh +23 -0
- gitgym/exercises/03_branching/02_switch_branches/verify.sh +16 -0
- gitgym/exercises/03_branching/03_delete_branch/exercise.toml +21 -0
- gitgym/exercises/03_branching/03_delete_branch/setup.sh +30 -0
- gitgym/exercises/03_branching/03_delete_branch/verify.sh +22 -0
- gitgym/exercises/04_merging/01_fast_forward/exercise.toml +25 -0
- gitgym/exercises/04_merging/01_fast_forward/setup.sh +33 -0
- gitgym/exercises/04_merging/01_fast_forward/verify.sh +42 -0
- gitgym/exercises/04_merging/02_three_way_merge/exercise.toml +24 -0
- gitgym/exercises/04_merging/02_three_way_merge/setup.sh +34 -0
- gitgym/exercises/04_merging/02_three_way_merge/verify.sh +39 -0
- gitgym/exercises/04_merging/03_merge_conflict/exercise.toml +25 -0
- gitgym/exercises/04_merging/03_merge_conflict/setup.sh +34 -0
- gitgym/exercises/04_merging/03_merge_conflict/verify.sh +46 -0
- gitgym/exercises/05_history/01_log_basics/exercise.toml +27 -0
- gitgym/exercises/05_history/01_log_basics/setup.sh +59 -0
- gitgym/exercises/05_history/01_log_basics/verify.sh +47 -0
- gitgym/exercises/05_history/02_log_graph/exercise.toml +28 -0
- gitgym/exercises/05_history/02_log_graph/setup.sh +44 -0
- gitgym/exercises/05_history/02_log_graph/verify.sh +35 -0
- gitgym/exercises/05_history/03_blame/exercise.toml +25 -0
- gitgym/exercises/05_history/03_blame/setup.sh +46 -0
- gitgym/exercises/05_history/03_blame/verify.sh +48 -0
- gitgym/exercises/05_history/04_show/exercise.toml +28 -0
- gitgym/exercises/05_history/04_show/setup.sh +35 -0
- gitgym/exercises/05_history/04_show/verify.sh +43 -0
- gitgym/exercises/06_undoing/01_restore_file/exercise.toml +27 -0
- gitgym/exercises/06_undoing/01_restore_file/setup.sh +32 -0
- gitgym/exercises/06_undoing/01_restore_file/verify.sh +33 -0
- gitgym/exercises/06_undoing/02_unstage/exercise.toml +24 -0
- gitgym/exercises/06_undoing/02_unstage/setup.sh +27 -0
- gitgym/exercises/06_undoing/02_unstage/verify.sh +30 -0
- gitgym/exercises/06_undoing/03_revert/exercise.toml +27 -0
- gitgym/exercises/06_undoing/03_revert/setup.sh +41 -0
- gitgym/exercises/06_undoing/03_revert/verify.sh +41 -0
- gitgym/exercises/06_undoing/04_reset_soft/exercise.toml +26 -0
- gitgym/exercises/06_undoing/04_reset_soft/setup.sh +28 -0
- gitgym/exercises/06_undoing/04_reset_soft/verify.sh +33 -0
- gitgym/exercises/06_undoing/05_reset_mixed/exercise.toml +27 -0
- gitgym/exercises/06_undoing/05_reset_mixed/setup.sh +32 -0
- gitgym/exercises/06_undoing/05_reset_mixed/verify.sh +33 -0
- gitgym/exercises/07_rebase/01_basic_rebase/exercise.toml +25 -0
- gitgym/exercises/07_rebase/01_basic_rebase/setup.sh +41 -0
- gitgym/exercises/07_rebase/01_basic_rebase/verify.sh +43 -0
- gitgym/exercises/07_rebase/02_interactive_rebase/exercise.toml +32 -0
- gitgym/exercises/07_rebase/02_interactive_rebase/setup.sh +37 -0
- gitgym/exercises/07_rebase/02_interactive_rebase/verify.sh +40 -0
- gitgym/exercises/07_rebase/03_rebase_conflict/exercise.toml +28 -0
- gitgym/exercises/07_rebase/03_rebase_conflict/setup.sh +35 -0
- gitgym/exercises/07_rebase/03_rebase_conflict/verify.sh +41 -0
- gitgym/exercises/08_stashing/01_stash_basics/exercise.toml +26 -0
- gitgym/exercises/08_stashing/01_stash_basics/setup.sh +33 -0
- gitgym/exercises/08_stashing/01_stash_basics/verify.sh +31 -0
- gitgym/exercises/08_stashing/02_stash_pop_apply/exercise.toml +29 -0
- gitgym/exercises/08_stashing/02_stash_pop_apply/setup.sh +28 -0
- gitgym/exercises/08_stashing/02_stash_pop_apply/verify.sh +33 -0
- gitgym/exercises/09_advanced/01_cherry_pick/exercise.toml +31 -0
- gitgym/exercises/09_advanced/01_cherry_pick/setup.sh +43 -0
- gitgym/exercises/09_advanced/01_cherry_pick/verify.sh +39 -0
- gitgym/exercises/09_advanced/02_bisect/exercise.toml +38 -0
- gitgym/exercises/09_advanced/02_bisect/setup.sh +73 -0
- gitgym/exercises/09_advanced/02_bisect/verify.sh +48 -0
- gitgym/exercises/09_advanced/03_tags/exercise.toml +31 -0
- gitgym/exercises/09_advanced/03_tags/setup.sh +30 -0
- gitgym/exercises/09_advanced/03_tags/verify.sh +65 -0
- gitgym/exercises/09_advanced/04_aliases/exercise.toml +33 -0
- gitgym/exercises/09_advanced/04_aliases/setup.sh +19 -0
- gitgym/exercises/09_advanced/04_aliases/verify.sh +27 -0
- gitgym/progress.py +86 -0
- gitgym/runner.py +105 -0
- gitgym/watcher.py +188 -0
- gitgym-0.1.0.dist-info/METADATA +140 -0
- gitgym-0.1.0.dist-info/RECORD +111 -0
- gitgym-0.1.0.dist-info/WHEEL +4 -0
- gitgym-0.1.0.dist-info/entry_points.txt +2 -0
- gitgym-0.1.0.dist-info/licenses/LICENSE +21 -0
gitgym/__init__.py
ADDED
gitgym/__main__.py
ADDED
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."
|