git-gamify 1.0.0__tar.gz
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.
- git_gamify-1.0.0/PKG-INFO +12 -0
- git_gamify-1.0.0/README.md +0 -0
- git_gamify-1.0.0/pyproject.toml +35 -0
- git_gamify-1.0.0/setup.cfg +4 -0
- git_gamify-1.0.0/src/gg_cli/__init__.py +0 -0
- git_gamify-1.0.0/src/gg_cli/achievements.py +119 -0
- git_gamify-1.0.0/src/gg_cli/core.py +107 -0
- git_gamify-1.0.0/src/gg_cli/definitions/achievements.json +70 -0
- git_gamify-1.0.0/src/gg_cli/definitions/rewards.json +24 -0
- git_gamify-1.0.0/src/gg_cli/definitions/rules.json +7 -0
- git_gamify-1.0.0/src/gg_cli/gamify.py +199 -0
- git_gamify-1.0.0/src/gg_cli/locales/en.json +43 -0
- git_gamify-1.0.0/src/gg_cli/locales/zh.json +43 -0
- git_gamify-1.0.0/src/gg_cli/main.py +213 -0
- git_gamify-1.0.0/src/gg_cli/translator.py +61 -0
- git_gamify-1.0.0/src/gg_cli/utils.py +20 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/PKG-INFO +12 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/SOURCES.txt +23 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/dependency_links.txt +1 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/entry_points.txt +2 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/requires.txt +2 -0
- git_gamify-1.0.0/src/git_gamify.egg-info/top_level.txt +1 -0
- git_gamify-1.0.0/tests/test_achievement_system.py +83 -0
- git_gamify-1.0.0/tests/test_cli_commands.py +51 -0
- git_gamify-1.0.0/tests/test_gamify_logic.py +56 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-gamify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A CLI tool that adds a fun gamification layer to your daily Git usage.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "git-gamify"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Your Name", email = "your.email@example.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A CLI tool that adds a fun gamification layer to your daily Git usage."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
# 使用新的、推荐的 license 格式,修复了警告
|
|
15
|
+
license = { text = "MIT License" }
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"rich>=13.0.0",
|
|
22
|
+
"typer[all]>=0.9.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
gg = "gg_cli.main:cli_entry"
|
|
27
|
+
|
|
28
|
+
# ----------------- 新增和修改的部分在这里 -----------------
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
# 告诉 setuptools 我们的代码包在 src 文件夹里
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
# 告诉 setuptools,要将所有文件夹下的所有 .json 文件都包含进来
|
|
35
|
+
"*" = ["**/*.json"]
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# src/gg_cli/achievements.py
|
|
2
|
+
"""Handles the logic for checking and unlocking all achievements."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from gg_cli.utils import DEFINITIONS_DIR, console
|
|
8
|
+
from gg_cli.translator import Translator
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_achievement_definitions() -> dict:
|
|
13
|
+
"""
|
|
14
|
+
Loads achievement definitions from JSON and flattens them into a single dict.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A dictionary mapping achievement IDs to their definition data.
|
|
18
|
+
"""
|
|
19
|
+
with open(DEFINITIONS_DIR / 'achievements.json', 'r', encoding='utf-8') as f:
|
|
20
|
+
defs = json.load(f)
|
|
21
|
+
# Flatten the structure from categories into a single ID-based dictionary.
|
|
22
|
+
return {ach_id: ach_data for category in defs.values() for ach_id, ach_data in category.items()}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ACHIEVEMENTS_DEF = load_achievement_definitions()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Helper functions for checking specific achievement conditions.
|
|
29
|
+
# These are kept internal to this module.
|
|
30
|
+
|
|
31
|
+
def _check_simple_stat(user_data: dict, stat_key: str, target_value: int, ach_id: str) -> dict | None:
|
|
32
|
+
"""Generic checker for achievements based on a simple statistic count."""
|
|
33
|
+
if user_data["stats"].get(stat_key, 0) >= target_value:
|
|
34
|
+
return {"id": ach_id}
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_midnight_coder(user_data: dict, context: dict, **kwargs) -> dict | None:
|
|
39
|
+
"""Checker for the 'Midnight Coder' achievement."""
|
|
40
|
+
if context.get("command") != "commit":
|
|
41
|
+
return None
|
|
42
|
+
now = datetime.now()
|
|
43
|
+
if 0 <= now.hour < 4: # Check if the time is between 00:00 and 04:00
|
|
44
|
+
return {"id": "midnight_coder"}
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_firefighter(user_data: dict, context: dict, **kwargs) -> dict | None:
|
|
49
|
+
"""Checker for the 'Firefighter' achievement."""
|
|
50
|
+
if context.get("command") != "commit":
|
|
51
|
+
return None
|
|
52
|
+
deletions = context.get("deletions", 0)
|
|
53
|
+
if deletions >= 500:
|
|
54
|
+
return {"id": "firefighter"}
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_storyteller(user_data: dict, context: dict, **kwargs) -> dict | None:
|
|
59
|
+
"""Checker for the 'Storyteller' achievement."""
|
|
60
|
+
if context.get("command") != "commit":
|
|
61
|
+
return None
|
|
62
|
+
commit_message = context.get("commit_message", "")
|
|
63
|
+
if len(commit_message.split()) >= 50:
|
|
64
|
+
return {"id": "storyteller"}
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Maps achievement IDs to their corresponding checker functions for easy iteration.
|
|
69
|
+
ACHIEVEMENT_CHECKERS = {
|
|
70
|
+
"first_commit": lambda u, **kwargs: _check_simple_stat(u, "total_commits", 1, "first_commit"),
|
|
71
|
+
"commit_10": lambda u, **kwargs: _check_simple_stat(u, "total_commits", 10, "commit_10"),
|
|
72
|
+
"commit_100": lambda u, **kwargs: _check_simple_stat(u, "total_commits", 100, "commit_100"),
|
|
73
|
+
"commit_1000": lambda u, **kwargs: _check_simple_stat(u, "total_commits", 1000, "commit_1000"),
|
|
74
|
+
"first_push": lambda u, **kwargs: _check_simple_stat(u, "total_pushes", 1, "first_push"),
|
|
75
|
+
"push_50": lambda u, **kwargs: _check_simple_stat(u, "total_pushes", 50, "push_50"),
|
|
76
|
+
"combo_3": lambda u, **kwargs: _check_simple_stat(u, "consecutive_commit_days", 3, "combo_3"),
|
|
77
|
+
"combo_7": lambda u, **kwargs: _check_simple_stat(u, "consecutive_commit_days", 7, "combo_7"),
|
|
78
|
+
"combo_30": lambda u, **kwargs: _check_simple_stat(u, "consecutive_commit_days", 30, "combo_30"),
|
|
79
|
+
"midnight_coder": _check_midnight_coder,
|
|
80
|
+
"firefighter": _check_firefighter,
|
|
81
|
+
"storyteller": _check_storyteller,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_all_achievements(user_data: dict, translator: Translator, context: dict) -> int:
|
|
86
|
+
"""
|
|
87
|
+
Iterates through all defined achievements and checks if they should be unlocked.
|
|
88
|
+
|
|
89
|
+
If an achievement is unlocked, it updates user_data, prints a notification,
|
|
90
|
+
and contributes to the total XP gained in this session.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
user_data: The user's current data dictionary.
|
|
94
|
+
translator: An instance of the Translator class for notifications.
|
|
95
|
+
context: A dictionary containing contextual info about the command (e.g., command name).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The total XP awarded from all newly unlocked achievements.
|
|
99
|
+
"""
|
|
100
|
+
xp_from_achievements = 0
|
|
101
|
+
|
|
102
|
+
for ach_id, checker_func in ACHIEVEMENT_CHECKERS.items():
|
|
103
|
+
if ach_id not in user_data["achievements_unlocked"]:
|
|
104
|
+
result = checker_func(user_data, context=context)
|
|
105
|
+
if result:
|
|
106
|
+
user_data["achievements_unlocked"][ach_id] = date.today().isoformat()
|
|
107
|
+
|
|
108
|
+
reward = ACHIEVEMENTS_DEF[ach_id].get("xp_reward", 0)
|
|
109
|
+
xp_from_achievements += reward
|
|
110
|
+
|
|
111
|
+
# Display a notification to the user.
|
|
112
|
+
name = translator.t(ACHIEVEMENTS_DEF[ach_id]["name_key"])
|
|
113
|
+
desc = translator.t(ACHIEVEMENTS_DEF[ach_id]["desc_key"])
|
|
114
|
+
panel_title = translator.t("achievement_unlocked_panel_title")
|
|
115
|
+
console.print(Panel(
|
|
116
|
+
f"[bold cyan]{name}[/bold cyan]\n[italic]{desc}[/italic]\n\n✨ [bold]Gained +{reward} XP![/bold]",
|
|
117
|
+
title=panel_title, border_style="yellow", expand=False))
|
|
118
|
+
|
|
119
|
+
return xp_from_achievements
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# src/gg_cli/core.py
|
|
2
|
+
"""Core functionalities for data management, user profile handling, and Git repo interactions."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import hashlib
|
|
7
|
+
from datetime import date
|
|
8
|
+
from gg_cli.utils import DATA_DIR
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_in_git_repo() -> bool:
|
|
12
|
+
"""Check if the current directory is inside a Git working tree."""
|
|
13
|
+
try:
|
|
14
|
+
# This command returns 'true' if inside a repo, otherwise errors out.
|
|
15
|
+
output = subprocess.check_output(
|
|
16
|
+
['git', 'rev-parse', '--is-inside-work-tree'],
|
|
17
|
+
text=True,
|
|
18
|
+
stderr=subprocess.DEVNULL
|
|
19
|
+
)
|
|
20
|
+
return output.strip() == 'true'
|
|
21
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_git_email() -> str | None:
|
|
26
|
+
"""Retrieve the user.email from the local Git configuration."""
|
|
27
|
+
try:
|
|
28
|
+
return subprocess.check_output(
|
|
29
|
+
['git', 'config', 'user.email'],
|
|
30
|
+
text=True,
|
|
31
|
+
stderr=subprocess.DEVNULL
|
|
32
|
+
).strip()
|
|
33
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_profile_filename(email: str) -> str | None:
|
|
38
|
+
"""Generate a unique, safe filename for a user profile based on their email."""
|
|
39
|
+
if not email:
|
|
40
|
+
return None
|
|
41
|
+
# Use SHA1 hash to anonymize and create a filesystem-safe name.
|
|
42
|
+
return hashlib.sha1(email.encode('utf-8')).hexdigest() + ".json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_default_user_data(email: str | None = None) -> dict:
|
|
46
|
+
"""Return a dictionary containing the default data structure for a new user."""
|
|
47
|
+
return {
|
|
48
|
+
"config": {"language": "en", "user_email": email},
|
|
49
|
+
"user": {"xp": 0, "level": 1},
|
|
50
|
+
"achievements_unlocked": {},
|
|
51
|
+
"stats": {
|
|
52
|
+
"total_commits": 0,
|
|
53
|
+
"total_pushes": 0,
|
|
54
|
+
"last_commit_date": "1970-01-01",
|
|
55
|
+
"last_push_date": "1970-01-01",
|
|
56
|
+
"consecutive_commit_days": 0
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_user_data() -> dict:
|
|
62
|
+
"""
|
|
63
|
+
Load user data from their JSON profile file.
|
|
64
|
+
|
|
65
|
+
Identifies the user by their git email, finds the corresponding profile,
|
|
66
|
+
and merges it with the default data structure to ensure forward compatibility
|
|
67
|
+
if new fields are added to the app. Creates a new profile if one doesn't exist.
|
|
68
|
+
"""
|
|
69
|
+
email = get_current_git_email()
|
|
70
|
+
if not email:
|
|
71
|
+
return get_default_user_data()
|
|
72
|
+
|
|
73
|
+
filename = get_profile_filename(email)
|
|
74
|
+
profile_path = DATA_DIR / filename
|
|
75
|
+
|
|
76
|
+
if not profile_path.exists():
|
|
77
|
+
user_data = get_default_user_data(email)
|
|
78
|
+
save_user_data(user_data)
|
|
79
|
+
return user_data
|
|
80
|
+
|
|
81
|
+
# Start with default data to ensure all keys exist.
|
|
82
|
+
user_data = get_default_user_data(email)
|
|
83
|
+
try:
|
|
84
|
+
with open(profile_path, 'r', encoding='utf-8') as f:
|
|
85
|
+
disk_data = json.load(f)
|
|
86
|
+
# Merge saved data into the default structure. This makes the data
|
|
87
|
+
# robust against schema changes (e.g., adding new stats).
|
|
88
|
+
for main_key in user_data:
|
|
89
|
+
if main_key in disk_data and isinstance(user_data[main_key], dict):
|
|
90
|
+
user_data[main_key].update(disk_data[main_key])
|
|
91
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
92
|
+
# If the file is corrupted or deleted mid-operation, save a clean default state.
|
|
93
|
+
save_user_data(user_data)
|
|
94
|
+
|
|
95
|
+
return user_data
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_user_data(data: dict) -> None:
|
|
99
|
+
"""Save the user's data dictionary to their corresponding JSON profile file."""
|
|
100
|
+
email = data.get("config", {}).get("user_email")
|
|
101
|
+
if not email:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
filename = get_profile_filename(email)
|
|
105
|
+
profile_path = DATA_DIR / filename
|
|
106
|
+
with open(profile_path, 'w', encoding='utf-8') as f:
|
|
107
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"onboarding": {
|
|
3
|
+
"first_commit": {
|
|
4
|
+
"name_key": "ach_first_commit_name",
|
|
5
|
+
"desc_key": "ach_first_commit_desc",
|
|
6
|
+
"xp_reward": 50
|
|
7
|
+
},
|
|
8
|
+
"first_push": {
|
|
9
|
+
"name_key": "ach_first_push_name",
|
|
10
|
+
"desc_key": "ach_first_push_desc",
|
|
11
|
+
"xp_reward": 75
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"milestone": {
|
|
15
|
+
"commit_10": {
|
|
16
|
+
"name_key": "ach_commit_10_name",
|
|
17
|
+
"desc_key": "ach_commit_10_desc",
|
|
18
|
+
"xp_reward": 100
|
|
19
|
+
},
|
|
20
|
+
"commit_100": {
|
|
21
|
+
"name_key": "ach_commit_100_name",
|
|
22
|
+
"desc_key": "ach_commit_100_desc",
|
|
23
|
+
"xp_reward": 300
|
|
24
|
+
},
|
|
25
|
+
"push_50": {
|
|
26
|
+
"name_key": "ach_push_50_name",
|
|
27
|
+
"desc_key": "ach_push_50_desc",
|
|
28
|
+
"xp_reward": 350
|
|
29
|
+
},
|
|
30
|
+
"commit_1000": {
|
|
31
|
+
"name_key": "ach_commit_1000_name",
|
|
32
|
+
"desc_key": "ach_commit_1000_desc",
|
|
33
|
+
"xp_reward": 1000
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"consistency": {
|
|
37
|
+
"combo_3": {
|
|
38
|
+
"name_key": "ach_combo_3_name",
|
|
39
|
+
"desc_key": "ach_combo_3_desc",
|
|
40
|
+
"xp_reward": 120
|
|
41
|
+
},
|
|
42
|
+
"combo_7": {
|
|
43
|
+
"name_key": "ach_combo_7_name",
|
|
44
|
+
"desc_key": "ach_combo_7_desc",
|
|
45
|
+
"xp_reward": 300
|
|
46
|
+
},
|
|
47
|
+
"combo_30": {
|
|
48
|
+
"name_key": "ach_combo_30_name",
|
|
49
|
+
"desc_key": "ach_combo_30_desc",
|
|
50
|
+
"xp_reward": 1200
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"special": {
|
|
54
|
+
"midnight_coder": {
|
|
55
|
+
"name_key": "ach_midnight_coder_name",
|
|
56
|
+
"desc_key": "ach_midnight_coder_desc",
|
|
57
|
+
"xp_reward": 150
|
|
58
|
+
},
|
|
59
|
+
"firefighter": {
|
|
60
|
+
"name_key": "ach_firefighter_name",
|
|
61
|
+
"desc_key": "ach_firefighter_desc",
|
|
62
|
+
"xp_reward": 200
|
|
63
|
+
},
|
|
64
|
+
"storyteller": {
|
|
65
|
+
"name_key": "ach_storyteller_name",
|
|
66
|
+
"desc_key": "ach_storyteller_desc",
|
|
67
|
+
"xp_reward": 100
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"quotes": {
|
|
3
|
+
"en": [
|
|
4
|
+
"Talk is cheap. Show me the code. - Linus Torvalds",
|
|
5
|
+
"Any fool can write code that a computer can understand. Good programmers write code that humans can understand. - Martin Fowler",
|
|
6
|
+
"First, solve the problem. Then, write the code. - John Johnson"
|
|
7
|
+
],
|
|
8
|
+
"zh": [
|
|
9
|
+
"废话少说,放码过来。——林纳斯·托瓦兹",
|
|
10
|
+
"任何傻瓜都能写出计算机能理解的代码。好的程序员能写出人类能理解的代码。——马丁·福勒",
|
|
11
|
+
"先解决问题,再写代码。——约翰·约翰逊"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"jokes": {
|
|
15
|
+
"en": [
|
|
16
|
+
"Why do programmers prefer dark mode? Because light attracts bugs.",
|
|
17
|
+
"A programmer puts two glasses on his bedside table. One full of water, and one empty. The full one is in case he gets thirsty, and the empty one is in case he doesn't."
|
|
18
|
+
],
|
|
19
|
+
"zh": [
|
|
20
|
+
"程序员为什么喜欢暗色模式?因为光(light)会吸引bug(虫子)。",
|
|
21
|
+
"一个程序员在床头放了两个杯子。一个装满了水,一个空的。满的是为了口渴时喝,空的是为了不渴时用。"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# src/gg_cli/gamify.py
|
|
2
|
+
"""The core gamification engine. Calculates XP, manages levels, and processes game logic after Git commands."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import subprocess
|
|
7
|
+
import re
|
|
8
|
+
from datetime import date
|
|
9
|
+
from gg_cli.core import load_user_data, save_user_data
|
|
10
|
+
from gg_cli.translator import Translator
|
|
11
|
+
from gg_cli.utils import console, DEFINITIONS_DIR
|
|
12
|
+
from gg_cli.achievements import check_all_achievements
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
# Defines the level progression system.
|
|
16
|
+
# Each tuple represents: (level_cap, xp_per_level_in_tier, title_translation_key)
|
|
17
|
+
LEVEL_TIERS = [
|
|
18
|
+
(10, 100, "level_title_novice"),
|
|
19
|
+
(20, 250, "level_title_apprentice"),
|
|
20
|
+
(30, 500, "level_title_journeyman"),
|
|
21
|
+
(40, 1000, "level_title_adept"),
|
|
22
|
+
(50, 2500, "level_title_master"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_level_info(level: int) -> tuple[int, int, str]:
|
|
27
|
+
"""Retrieves tier information for a given level."""
|
|
28
|
+
if not isinstance(level, int) or level < 1:
|
|
29
|
+
level = 1
|
|
30
|
+
for max_level, xp_per_level, title_key in LEVEL_TIERS:
|
|
31
|
+
if level <= max_level:
|
|
32
|
+
return max_level, xp_per_level, title_key
|
|
33
|
+
# Default to the last tier for levels beyond the defined caps.
|
|
34
|
+
return LEVEL_TIERS[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_total_xp_for_level(target_level: int) -> int:
|
|
38
|
+
"""Calculates the total XP required to reach the beginning of a target level."""
|
|
39
|
+
total_xp = 0
|
|
40
|
+
current_level = 1
|
|
41
|
+
while current_level < target_level:
|
|
42
|
+
_, xp_per_level, _ = get_level_info(current_level)
|
|
43
|
+
total_xp += xp_per_level
|
|
44
|
+
current_level += 1
|
|
45
|
+
return total_xp
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_level_from_xp(xp: int) -> int:
|
|
49
|
+
"""Calculates a user's level based on their total XP."""
|
|
50
|
+
if not isinstance(xp, int) or xp < 0:
|
|
51
|
+
xp = 0
|
|
52
|
+
level = 1
|
|
53
|
+
xp_needed_for_next_level = 0
|
|
54
|
+
while True:
|
|
55
|
+
_, xp_per_level, _ = get_level_info(level)
|
|
56
|
+
xp_needed_for_next_level += xp_per_level
|
|
57
|
+
if xp < xp_needed_for_next_level:
|
|
58
|
+
return level
|
|
59
|
+
level += 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_rewards() -> dict:
|
|
63
|
+
"""Loads random rewards (quotes, jokes) from the JSON definition file."""
|
|
64
|
+
with open(DEFINITIONS_DIR / 'rewards.json', 'r', encoding='utf-8') as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
rewards_def = load_rewards()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process_gamify_logic(git_command_args: list[str]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
The main entry point for all gamification logic.
|
|
74
|
+
|
|
75
|
+
This function is called after a successful git command and handles
|
|
76
|
+
XP calculation, stats updates, and achievement checks.
|
|
77
|
+
"""
|
|
78
|
+
user_data = load_user_data()
|
|
79
|
+
if not user_data or not user_data.get("config", {}).get("user_email"):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
translator = Translator(user_data.get("config", {}).get("language", "en"))
|
|
83
|
+
command = git_command_args[0] if git_command_args else ""
|
|
84
|
+
today = date.today()
|
|
85
|
+
xp_to_add = 0
|
|
86
|
+
context = {"command": command}
|
|
87
|
+
|
|
88
|
+
if command == "commit":
|
|
89
|
+
# Update commit stats
|
|
90
|
+
user_data["stats"]["total_commits"] += 1
|
|
91
|
+
last_commit_date_str = user_data["stats"].get("last_commit_date", "1970-01-01")
|
|
92
|
+
|
|
93
|
+
if last_commit_date_str != "1970-01-01":
|
|
94
|
+
last_commit_date = date.fromisoformat(last_commit_date_str)
|
|
95
|
+
if (today - last_commit_date).days == 1:
|
|
96
|
+
user_data["stats"]["consecutive_commit_days"] += 1
|
|
97
|
+
elif (today - last_commit_date).days > 1:
|
|
98
|
+
user_data["stats"]["consecutive_commit_days"] = 1
|
|
99
|
+
else: # First commit ever for this user.
|
|
100
|
+
user_data["stats"]["consecutive_commit_days"] = 1
|
|
101
|
+
|
|
102
|
+
if last_commit_date_str != today.isoformat():
|
|
103
|
+
user_data["stats"]["last_commit_date"] = today.isoformat()
|
|
104
|
+
|
|
105
|
+
# Calculate XP for the commit
|
|
106
|
+
xp_to_add += 10 # Base XP
|
|
107
|
+
xp_to_add += min(user_data["stats"]["consecutive_commit_days"], 15) # Combo bonus
|
|
108
|
+
|
|
109
|
+
# Bonus XP based on code volume
|
|
110
|
+
try:
|
|
111
|
+
diff_stats = subprocess.check_output(
|
|
112
|
+
["git", "diff", "--shortstat", "HEAD~1", "HEAD"],
|
|
113
|
+
text=True, stderr=subprocess.DEVNULL
|
|
114
|
+
).strip()
|
|
115
|
+
changes = sum(int(s) for s in diff_stats.split() if s.isdigit())
|
|
116
|
+
xp_to_add += min(int(changes / 20), 20)
|
|
117
|
+
|
|
118
|
+
# Gather context for achievements
|
|
119
|
+
deletions_match = re.search(r'(\d+)\s+deletions', diff_stats)
|
|
120
|
+
context["deletions"] = int(deletions_match.group(1)) if deletions_match else 0
|
|
121
|
+
context["commit_message"] = subprocess.check_output(["git", "log", "-1", "--pretty=%B"], text=True).strip()
|
|
122
|
+
except Exception:
|
|
123
|
+
# Ignore errors if not in a state to diff (e.g., first commit).
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
elif command == "push":
|
|
127
|
+
# Update push stats
|
|
128
|
+
user_data["stats"]["total_pushes"] += 1
|
|
129
|
+
last_push_date = date.fromisoformat(user_data["stats"]["last_push_date"])
|
|
130
|
+
|
|
131
|
+
# Calculate XP for the push
|
|
132
|
+
xp_to_add += 25 # Base XP
|
|
133
|
+
if today != last_push_date:
|
|
134
|
+
xp_to_add += 50 # Daily bonus
|
|
135
|
+
user_data["stats"]["last_push_date"] = today.isoformat()
|
|
136
|
+
|
|
137
|
+
# Check for any newly unlocked achievements and add their XP.
|
|
138
|
+
xp_from_achievements = check_all_achievements(user_data, translator, context)
|
|
139
|
+
xp_to_add += xp_from_achievements
|
|
140
|
+
|
|
141
|
+
if xp_to_add > 0:
|
|
142
|
+
current_level = user_data.get("user", {}).get("level", 1)
|
|
143
|
+
current_xp = user_data.get("user", {}).get("xp", 0)
|
|
144
|
+
|
|
145
|
+
new_xp = current_xp + xp_to_add
|
|
146
|
+
new_level = get_level_from_xp(new_xp)
|
|
147
|
+
user_data["user"] = {"xp": new_xp, "level": new_level}
|
|
148
|
+
|
|
149
|
+
# Display XP gain message
|
|
150
|
+
_, xp_per_level_current, _ = get_level_info(new_level)
|
|
151
|
+
xp_base_for_current_level = get_total_xp_for_level(new_level)
|
|
152
|
+
next_level_xp_target = xp_base_for_current_level + xp_per_level_current
|
|
153
|
+
console.print(translator.t(
|
|
154
|
+
"xp_gain_message",
|
|
155
|
+
xp=xp_to_add,
|
|
156
|
+
level=new_level,
|
|
157
|
+
current_xp=new_xp,
|
|
158
|
+
next_level_xp=next_level_xp_target
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
# Handle level up event
|
|
162
|
+
if new_level > current_level:
|
|
163
|
+
_, _, title_key = get_level_info(new_level)
|
|
164
|
+
console.print(
|
|
165
|
+
translator.t("level_up_message", level=new_level, title=translator.t(title_key)),
|
|
166
|
+
style="bold magenta"
|
|
167
|
+
)
|
|
168
|
+
# Give a random reward
|
|
169
|
+
lang = user_data.get("config", {}).get("language", "en")
|
|
170
|
+
reward_type = random.choice(["quotes", "jokes"])
|
|
171
|
+
reward = random.choice(rewards_def[reward_type][lang])
|
|
172
|
+
console.print(Panel(
|
|
173
|
+
f"[italic cyan]{reward}[/italic cyan]",
|
|
174
|
+
title=translator.t("random_reward_title"),
|
|
175
|
+
border_style="green", expand=False
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
# Give a level up XP bonus
|
|
179
|
+
_, xp_per_level, _ = get_level_info(new_level)
|
|
180
|
+
xp_from_bonus = int(xp_per_level * 0.20)
|
|
181
|
+
user_data["user"]["xp"] += xp_from_bonus
|
|
182
|
+
|
|
183
|
+
# Recalculate stats for the bonus message
|
|
184
|
+
bonus_final_xp = user_data["user"]["xp"]
|
|
185
|
+
bonus_final_level = get_level_from_xp(bonus_final_xp)
|
|
186
|
+
_, bonus_xp_per_level, _ = get_level_info(bonus_final_level)
|
|
187
|
+
bonus_xp_base = get_total_xp_for_level(bonus_final_level)
|
|
188
|
+
bonus_next_level_target = bonus_xp_base + bonus_xp_per_level
|
|
189
|
+
|
|
190
|
+
console.print(translator.t(
|
|
191
|
+
"xp_gain_message",
|
|
192
|
+
xp=xp_from_bonus,
|
|
193
|
+
level=bonus_final_level,
|
|
194
|
+
current_xp=bonus_final_xp,
|
|
195
|
+
next_level_xp=bonus_next_level_target
|
|
196
|
+
))
|
|
197
|
+
user_data["user"]["level"] = bonus_final_level
|
|
198
|
+
|
|
199
|
+
save_user_data(user_data)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"level_title": "Level",
|
|
3
|
+
"xp_progress_title": "XP Progress",
|
|
4
|
+
"profile_email_label": "Email",
|
|
5
|
+
"achievements_unlocked_title": "Achievements Unlocked",
|
|
6
|
+
"profile_title": "Git Gamify Profile",
|
|
7
|
+
"level_up_message": "🎉 LEVEL UP! You have reached Level {level}: {title}! 🎉",
|
|
8
|
+
"xp_gain_message": "✨ You gained +{xp} XP! Current level {level} ({current_xp}/{next_level_xp}).",
|
|
9
|
+
"random_reward_title": "A little wisdom for your journey:",
|
|
10
|
+
"config_language_set": "Language has been set to English.",
|
|
11
|
+
"achievement_unlocked_panel_title": "🏆 Achievement Unlocked! 🏆",
|
|
12
|
+
|
|
13
|
+
"ach_first_commit_name": "First Step",
|
|
14
|
+
"ach_first_commit_desc": "Complete your very first commit.",
|
|
15
|
+
"ach_first_push_name": "Sharing is Caring",
|
|
16
|
+
"ach_first_push_desc": "Complete your very first push.",
|
|
17
|
+
"ach_commit_10_name": "Getting Started",
|
|
18
|
+
"ach_commit_10_desc": "Complete 10 commits.",
|
|
19
|
+
"ach_commit_100_name": "Centurion",
|
|
20
|
+
"ach_commit_100_desc": "Complete 100 commits.",
|
|
21
|
+
"ach_push_50_name": "Reliable Contributor",
|
|
22
|
+
"ach_push_50_desc": "Complete 50 pushes.",
|
|
23
|
+
"ach_commit_1000_name": "Commit King",
|
|
24
|
+
"ach_commit_1000_desc": "A true legend. Complete 1000 commits.",
|
|
25
|
+
"ach_combo_3_name": "Warming Up",
|
|
26
|
+
"ach_combo_3_desc": "Commit on 3 consecutive days.",
|
|
27
|
+
"ach_combo_7_name": "Combo Master",
|
|
28
|
+
"ach_combo_7_desc": "Commit on 7 consecutive days.",
|
|
29
|
+
"ach_combo_30_name": "Marathon Runner",
|
|
30
|
+
"ach_combo_30_desc": "A supreme effort. Commit on 30 consecutive days.",
|
|
31
|
+
"ach_midnight_coder_name": "Midnight Coder",
|
|
32
|
+
"ach_midnight_coder_desc": "Commit code between midnight and 4 AM.",
|
|
33
|
+
"ach_firefighter_name": "Firefighter",
|
|
34
|
+
"ach_firefighter_desc": "Delete more than 500 lines of code in a single commit.",
|
|
35
|
+
"ach_storyteller_name": "Storyteller",
|
|
36
|
+
"ach_storyteller_desc": "Write a commit message with more than 50 words.",
|
|
37
|
+
|
|
38
|
+
"level_title_novice": "Git Novice",
|
|
39
|
+
"level_title_apprentice": "Git Apprentice",
|
|
40
|
+
"level_title_journeyman": "Git Journeyman",
|
|
41
|
+
"level_title_adept": "Git Adept",
|
|
42
|
+
"level_title_master": "Git Master"
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"level_title": "等级",
|
|
3
|
+
"xp_progress_title": "经验值进度",
|
|
4
|
+
"profile_email_label": "邮箱",
|
|
5
|
+
"achievements_unlocked_title": "已解锁成就",
|
|
6
|
+
"profile_title": "Git Gamify 玩家档案",
|
|
7
|
+
"level_up_message": "🎉 等级提升!你已达到 {level} 级:{title}!🎉",
|
|
8
|
+
"xp_gain_message": "✨ 你获得了 +{xp} XP!当前等级 {level} ({current_xp}/{next_level_xp})。",
|
|
9
|
+
"random_reward_title": "一点旅途中的智慧:",
|
|
10
|
+
"config_language_set": "语言已成功设置为中文。",
|
|
11
|
+
"achievement_unlocked_panel_title": "🏆 成就解锁!🏆",
|
|
12
|
+
|
|
13
|
+
"ach_first_commit_name": "迈出第一步",
|
|
14
|
+
"ach_first_commit_desc": "完成你的第一次提交。",
|
|
15
|
+
"ach_first_push_name": "分享的喜悦",
|
|
16
|
+
"ach_first_push_desc": "完成你的第一次推送。",
|
|
17
|
+
"ach_commit_10_name": "小试牛刀",
|
|
18
|
+
"ach_commit_10_desc": "累计完成10次提交。",
|
|
19
|
+
"ach_commit_100_name": "百战老兵",
|
|
20
|
+
"ach_commit_100_desc": "累计完成100次提交。",
|
|
21
|
+
"ach_push_50_name": "稳定贡献者",
|
|
22
|
+
"ach_push_50_desc": "累计完成50次推送。",
|
|
23
|
+
"ach_commit_1000_name": "万码奔腾",
|
|
24
|
+
"ach_commit_1000_desc": "真正的传奇!累计完成1000次提交。",
|
|
25
|
+
"ach_combo_3_name": "初露锋芒",
|
|
26
|
+
"ach_combo_3_desc": "连续3天都有提交。",
|
|
27
|
+
"ach_combo_7_name": "连击大师",
|
|
28
|
+
"ach_combo_7_desc": "连续7天都有提交。",
|
|
29
|
+
"ach_combo_30_name": "马拉松选手",
|
|
30
|
+
"ach_combo_30_desc": "伟大的坚持!连续30天都有提交。",
|
|
31
|
+
"ach_midnight_coder_name": "午夜码农",
|
|
32
|
+
"ach_midnight_coder_desc": "在凌晨0点到4点之间提交代码。",
|
|
33
|
+
"ach_firefighter_name": "救火队员",
|
|
34
|
+
"ach_firefighter_desc": "在一次提交中删除了超过500行代码。",
|
|
35
|
+
"ach_storyteller_name": "吟游诗人",
|
|
36
|
+
"ach_storyteller_desc": "编写一个包含超过50个单词的提交信息。",
|
|
37
|
+
|
|
38
|
+
"level_title_novice": "Git 新手",
|
|
39
|
+
"level_title_apprentice": "Git 学徒",
|
|
40
|
+
"level_title_journeyman": "Git 行家",
|
|
41
|
+
"level_title_adept": "Git 高手",
|
|
42
|
+
"level_title_master": "Git 大师"
|
|
43
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# src/gg_cli/main.py
|
|
2
|
+
"""The main entry point for the Git-Gamify CLI. Defines all user-facing commands and handles command-line argument parsing."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import subprocess
|
|
6
|
+
import typer
|
|
7
|
+
import os
|
|
8
|
+
import traceback
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.prompt import Confirm
|
|
12
|
+
from rich.progress_bar import ProgressBar
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.console import Group
|
|
15
|
+
from gg_cli.core import (
|
|
16
|
+
is_in_git_repo, load_user_data, save_user_data,
|
|
17
|
+
get_current_git_email, get_profile_filename, get_default_user_data
|
|
18
|
+
)
|
|
19
|
+
from gg_cli.gamify import process_gamify_logic, get_level_info, get_total_xp_for_level
|
|
20
|
+
from gg_cli.translator import Translator
|
|
21
|
+
from gg_cli.utils import console, DATA_DIR
|
|
22
|
+
|
|
23
|
+
# Initialize the Typer application.
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
help="Run `gg help` for a list of gamify commands.",
|
|
26
|
+
add_help_option=False, # We use a custom 'help' command.
|
|
27
|
+
no_args_is_help=True, # Show help if no command is provided.
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_translator() -> Translator:
|
|
32
|
+
"""Helper function to get a translator instance based on user's config."""
|
|
33
|
+
user_data = load_user_data()
|
|
34
|
+
lang_code = user_data.get("config", {}).get("language", "en")
|
|
35
|
+
return Translator(lang_code)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main_callback(ctx: typer.Context):
|
|
40
|
+
"""
|
|
41
|
+
A callback that runs before any command.
|
|
42
|
+
|
|
43
|
+
Used here to perform prerequisite checks, like ensuring the command
|
|
44
|
+
(if it's not 'help') is run inside a Git repository.
|
|
45
|
+
"""
|
|
46
|
+
# Allow 'help' command to run anywhere.
|
|
47
|
+
if ctx.invoked_subcommand != 'help' and not is_in_git_repo():
|
|
48
|
+
console.print(f"[bold red]Error:[/bold red] The `gg {ctx.invoked_subcommand}` command must be run inside a Git repository.")
|
|
49
|
+
raise typer.Exit(code=1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("help")
|
|
53
|
+
def show_help():
|
|
54
|
+
"""Show the custom Git-Gamify help message."""
|
|
55
|
+
table = Table(box=None, show_header=False, show_edge=False)
|
|
56
|
+
table.add_column(style="cyan", justify="left", width=12)
|
|
57
|
+
table.add_column()
|
|
58
|
+
table.add_row("profile", "Display user profile, stats, or reset progress.")
|
|
59
|
+
table.add_row("config", "Get or set configuration values.")
|
|
60
|
+
table.add_row("help", "Show this help message and exit.")
|
|
61
|
+
console.print(Panel(
|
|
62
|
+
Group(
|
|
63
|
+
Text("Usage: gg COMMAND [OPTIONS]..."),
|
|
64
|
+
Text("Gamify your Git experience. Your aliased `git` commands are tracked automatically.\n"),
|
|
65
|
+
Text("[bold]Internal Commands:[/bold]"),
|
|
66
|
+
table
|
|
67
|
+
),
|
|
68
|
+
title="[bold]Git-Gamify Help[/bold]", border_style="green", expand=False
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("profile")
|
|
73
|
+
def manage_profile(
|
|
74
|
+
stats: bool = typer.Option(False, "--stats", "-s", help="Display detailed statistics."),
|
|
75
|
+
reset: bool = typer.Option(False, "--reset", help="Reset all progress for the current user.")
|
|
76
|
+
):
|
|
77
|
+
"""Display user profile, stats, or reset progress."""
|
|
78
|
+
if reset:
|
|
79
|
+
email = get_current_git_email()
|
|
80
|
+
if not email:
|
|
81
|
+
console.print("[red]Error: Cannot find git user email. Is git configured?[/red]")
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
|
|
84
|
+
profile_path = DATA_DIR / get_profile_filename(email)
|
|
85
|
+
if Confirm.ask(f"[bold yellow]Are you sure you want to reset all progress for '{email}'?[/bold yellow]"):
|
|
86
|
+
if profile_path.exists():
|
|
87
|
+
try:
|
|
88
|
+
os.remove(profile_path)
|
|
89
|
+
console.print(f"[green]Profile for '{email}' has been successfully reset![/green]")
|
|
90
|
+
except OSError as e:
|
|
91
|
+
console.print(f"[bold red]Error: Could not delete profile file. Reason: {e}[/bold red]")
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[yellow]No profile found for '{email}' to reset.[/yellow]")
|
|
94
|
+
else:
|
|
95
|
+
console.print("[cyan]Reset cancelled.[/cyan]")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if stats:
|
|
99
|
+
user_data = load_user_data()
|
|
100
|
+
s = user_data.get("stats", {})
|
|
101
|
+
console.print(f"Total commits: {s.get('total_commits', 0)}")
|
|
102
|
+
console.print(f"Total pushes: {s.get('total_pushes', 0)}")
|
|
103
|
+
console.print(f"Consecutive commit days: {s.get('consecutive_commit_days', 0)}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Default profile display
|
|
107
|
+
translator = get_translator()
|
|
108
|
+
user_data = load_user_data()
|
|
109
|
+
# This line now works correctly because get_default_user_data is imported.
|
|
110
|
+
user = user_data.get("user", get_default_user_data()["user"])
|
|
111
|
+
profile_email = user_data.get("config", {}).get("user_email")
|
|
112
|
+
|
|
113
|
+
# Prepare data for display
|
|
114
|
+
level = user.get('level', 1)
|
|
115
|
+
xp = user.get('xp', 0)
|
|
116
|
+
_, xp_per_level, title_key = get_level_info(level)
|
|
117
|
+
translated_level_title = translator.t(title_key)
|
|
118
|
+
xp_current_level_base = get_total_xp_for_level(level)
|
|
119
|
+
xp_next_level_base = xp_current_level_base + xp_per_level
|
|
120
|
+
progress_value = xp - xp_current_level_base
|
|
121
|
+
progress_total = xp_next_level_base - xp_current_level_base
|
|
122
|
+
if progress_total <= 0: progress_total = 1
|
|
123
|
+
|
|
124
|
+
# Build Rich elements for the profile panel
|
|
125
|
+
progress_bar = ProgressBar(total=progress_total, completed=progress_value, width=20)
|
|
126
|
+
progress_text = Text(f" {progress_value}/{progress_total} ({progress_value / progress_total:.1%})")
|
|
127
|
+
progress_table = Table.grid(expand=True)
|
|
128
|
+
progress_table.add_column(); progress_table.add_column(justify="right");
|
|
129
|
+
progress_table.add_row(progress_bar, progress_text)
|
|
130
|
+
profile_text = Text.from_markup(
|
|
131
|
+
f" [bold]{translator.t('profile_email_label')}:[/bold] [cyan]{profile_email}[/cyan]\n"
|
|
132
|
+
f" [bold]{translator.t('level_title')}:[/bold] {level} - {translated_level_title}\n\n"
|
|
133
|
+
f" [bold]{translator.t('xp_progress_title')}:[/bold]"
|
|
134
|
+
)
|
|
135
|
+
panel_group = Group(profile_text, progress_table)
|
|
136
|
+
console.print(Panel(panel_group, title=translator.t("profile_title"), border_style="magenta", padding=(0, 1), expand=False))
|
|
137
|
+
|
|
138
|
+
# Display unlocked achievements
|
|
139
|
+
unlocked_achievements = user_data.get("achievements_unlocked", {})
|
|
140
|
+
if unlocked_achievements:
|
|
141
|
+
from gg_cli.achievements import ACHIEVEMENTS_DEF as achievements_def
|
|
142
|
+
display_items = [f"🏆 {translator.t(achievements_def.get(ach_id, {}).get('name_key', ach_id))}" for ach_id in unlocked_achievements]
|
|
143
|
+
console.print(Panel("\n".join(display_items), title=translator.t("achievements_unlocked_title"), border_style="yellow", expand=False))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("config")
|
|
147
|
+
def manage_config(
|
|
148
|
+
set_value: str = typer.Option(None, "--set", help="Set a value (e.g., 'language=zh')."),
|
|
149
|
+
get_value: str = typer.Option(None, "--get", help="Get a value (e.g., 'language').")
|
|
150
|
+
):
|
|
151
|
+
"""Get or set configuration values."""
|
|
152
|
+
if not set_value and not get_value:
|
|
153
|
+
console.print("[yellow]Please provide an option: --set or --get. Run 'gg help' for more info.[/yellow]")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
user_data = load_user_data()
|
|
157
|
+
if set_value:
|
|
158
|
+
try:
|
|
159
|
+
key, value = set_value.split('=', 1)
|
|
160
|
+
if key.lower() == 'language':
|
|
161
|
+
user_data['config']['language'] = value
|
|
162
|
+
save_user_data(user_data)
|
|
163
|
+
confirm_translator = Translator(value)
|
|
164
|
+
console.print(Panel(confirm_translator.t("config_language_set"), border_style="green", expand=False))
|
|
165
|
+
else:
|
|
166
|
+
console.print(f"[red]Error: Unknown config key '[cyan]{key}[/cyan]'. Only 'language' is supported.[/red]")
|
|
167
|
+
except ValueError:
|
|
168
|
+
console.print("[red]Error: Invalid format. Please use '--set key=value'.[/red]")
|
|
169
|
+
if get_value:
|
|
170
|
+
if get_value.lower() == 'language':
|
|
171
|
+
console.print(user_data.get('config', {}).get('language', 'en'))
|
|
172
|
+
else:
|
|
173
|
+
console.print(f"[red]Error: Unknown config key '[cyan]{get_value}[/cyan]'. Only 'language' is supported.[/red]")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_git_wrapper(git_args: list[str]) -> None:
|
|
177
|
+
"""Execute the real git command and trigger gamification logic on success."""
|
|
178
|
+
try:
|
|
179
|
+
result = subprocess.run(['git'] + git_args, capture_output=True, text=True, check=False, encoding='utf-8')
|
|
180
|
+
if result.stdout:
|
|
181
|
+
sys.stdout.write(result.stdout)
|
|
182
|
+
if result.stderr:
|
|
183
|
+
sys.stderr.write(result.stderr)
|
|
184
|
+
|
|
185
|
+
# If the git command was successful, process gamify logic.
|
|
186
|
+
if result.returncode == 0:
|
|
187
|
+
command = git_args[0] if git_args else ""
|
|
188
|
+
if command in ["commit", "push"]:
|
|
189
|
+
console.print("-" * 20)
|
|
190
|
+
process_gamify_logic(git_args)
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
console.print("[bold red]Error: 'git' command not found. Is Git installed and in your PATH?[/bold red]")
|
|
193
|
+
except Exception:
|
|
194
|
+
console.print("[bold red]An unexpected error occurred. Full traceback below:[/bold red]")
|
|
195
|
+
traceback.print_exc()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cli_entry():
|
|
199
|
+
"""
|
|
200
|
+
The main CLI entry point.
|
|
201
|
+
|
|
202
|
+
Determines if the command is a special 'git' wrapper command or a standard
|
|
203
|
+
internal command to be handled by Typer.
|
|
204
|
+
"""
|
|
205
|
+
# Check for our special 'git' wrapper command.
|
|
206
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'git':
|
|
207
|
+
run_git_wrapper(sys.argv[2:])
|
|
208
|
+
else:
|
|
209
|
+
# For all other cases, delegate entirely to Typer.
|
|
210
|
+
app()
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
cli_entry()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# src/gg_cli/translator.py
|
|
2
|
+
"""Provides internationalization (i18n) support through a Translator class."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from gg_cli.utils import LOCALES_DIR, console
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Translator:
|
|
9
|
+
"""Manages loading and retrieving translated strings from JSON locale files."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, lang_code: str = "en"):
|
|
12
|
+
"""
|
|
13
|
+
Initializes the Translator for a specific language.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
lang_code: The language code (e.g., "en", "zh") to use for translations.
|
|
17
|
+
"""
|
|
18
|
+
self.strings = {}
|
|
19
|
+
# Always load English first as a reliable fallback.
|
|
20
|
+
self._load_language("en")
|
|
21
|
+
# If the requested language is not English, try to load it.
|
|
22
|
+
# It will override the English strings if successful.
|
|
23
|
+
if lang_code.lower() != "en":
|
|
24
|
+
self.load_strings(lang_code)
|
|
25
|
+
|
|
26
|
+
def _load_language(self, lang_code: str) -> bool:
|
|
27
|
+
"""
|
|
28
|
+
Internal helper to load a single language file with robust error handling.
|
|
29
|
+
Returns True on success, False on failure.
|
|
30
|
+
"""
|
|
31
|
+
lang_file = LOCALES_DIR / f"{lang_code.lower()}.json"
|
|
32
|
+
try:
|
|
33
|
+
with open(lang_file, 'r', encoding='utf-8-sig') as f:
|
|
34
|
+
self.strings.update(json.load(f))
|
|
35
|
+
return True
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
console.print(f"[yellow]Warning: Language file for '{lang_code}' not found.[/yellow]")
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
console.print(
|
|
40
|
+
f"[bold red]Error: Failed to decode language file for '{lang_code}'. The file might be corrupted.[/bold red]")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
console.print(
|
|
43
|
+
f"[bold red]An unexpected error occurred while loading language '{lang_code}': {e}[/bold red]")
|
|
44
|
+
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def load_strings(self, lang_code: str):
|
|
48
|
+
"""
|
|
49
|
+
Loads the translation strings for the given language, with English as a fallback.
|
|
50
|
+
"""
|
|
51
|
+
if not self._load_language(lang_code):
|
|
52
|
+
console.print("[yellow]Falling back to English.[/yellow]")
|
|
53
|
+
# No need to load English again, it's already there as the base.
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def t(self, key: str, **kwargs) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Retrieves a translated string by its key and formats it with given arguments.
|
|
59
|
+
"""
|
|
60
|
+
template = self.strings.get(key, key)
|
|
61
|
+
return template.format(**kwargs)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# src/gg_cli/utils.py
|
|
2
|
+
"""Global utilities and path definitions for the Git-Gamify project."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
# Global Rich Console instance for consistent output styling.
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
# Define key project paths relative to this file's location to ensure they
|
|
11
|
+
# work correctly even when packaged.
|
|
12
|
+
_CODE_DIR = Path(__file__).parent
|
|
13
|
+
DEFINITIONS_DIR = _CODE_DIR / "definitions"
|
|
14
|
+
LOCALES_DIR = _CODE_DIR / "locales"
|
|
15
|
+
|
|
16
|
+
# Define the user data directory in the user's home folder.
|
|
17
|
+
DATA_DIR = Path.home() / ".git-gamify"
|
|
18
|
+
|
|
19
|
+
# Ensure the user data directory exists upon import.
|
|
20
|
+
DATA_DIR.mkdir(exist_ok=True)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-gamify
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A CLI tool that adds a fun gamification layer to your daily Git usage.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/gg_cli/__init__.py
|
|
4
|
+
src/gg_cli/achievements.py
|
|
5
|
+
src/gg_cli/core.py
|
|
6
|
+
src/gg_cli/gamify.py
|
|
7
|
+
src/gg_cli/main.py
|
|
8
|
+
src/gg_cli/translator.py
|
|
9
|
+
src/gg_cli/utils.py
|
|
10
|
+
src/gg_cli/definitions/achievements.json
|
|
11
|
+
src/gg_cli/definitions/rewards.json
|
|
12
|
+
src/gg_cli/definitions/rules.json
|
|
13
|
+
src/gg_cli/locales/en.json
|
|
14
|
+
src/gg_cli/locales/zh.json
|
|
15
|
+
src/git_gamify.egg-info/PKG-INFO
|
|
16
|
+
src/git_gamify.egg-info/SOURCES.txt
|
|
17
|
+
src/git_gamify.egg-info/dependency_links.txt
|
|
18
|
+
src/git_gamify.egg-info/entry_points.txt
|
|
19
|
+
src/git_gamify.egg-info/requires.txt
|
|
20
|
+
src/git_gamify.egg-info/top_level.txt
|
|
21
|
+
tests/test_achievement_system.py
|
|
22
|
+
tests/test_cli_commands.py
|
|
23
|
+
tests/test_gamify_logic.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gg_cli
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# tests/test_achievement_system.py
|
|
2
|
+
"""Unit tests for the achievement unlocking logic."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from gg_cli.achievements import check_all_achievements
|
|
6
|
+
from gg_cli.translator import Translator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def mock_translator() -> Translator:
|
|
11
|
+
"""
|
|
12
|
+
Provides a mock Translator instance that avoids file I/O.
|
|
13
|
+
The mock `t` method simply returns the key, which is sufficient for logic testing.
|
|
14
|
+
"""
|
|
15
|
+
translator = Translator()
|
|
16
|
+
translator.t = lambda key, **kwargs: key
|
|
17
|
+
return translator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def base_user_data() -> dict:
|
|
22
|
+
"""Provides a clean, default user data structure for each test."""
|
|
23
|
+
return {
|
|
24
|
+
"stats": {
|
|
25
|
+
"total_commits": 0,
|
|
26
|
+
"total_pushes": 0,
|
|
27
|
+
"consecutive_commit_days": 0,
|
|
28
|
+
},
|
|
29
|
+
"achievements_unlocked": {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_unlock_first_commit(base_user_data: dict, mock_translator: Translator):
|
|
34
|
+
"""Tests that the 'first_commit' achievement unlocks at exactly 1 commit."""
|
|
35
|
+
# Arrange: Set user stats to meet the condition
|
|
36
|
+
base_user_data["stats"]["total_commits"] = 1
|
|
37
|
+
|
|
38
|
+
# Act: Run the achievement checker
|
|
39
|
+
xp = check_all_achievements(base_user_data, mock_translator, context={"command": "commit"})
|
|
40
|
+
|
|
41
|
+
# Assert: Check that the achievement is unlocked and the correct XP is awarded
|
|
42
|
+
assert "first_commit" in base_user_data["achievements_unlocked"]
|
|
43
|
+
assert xp == 50
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_unlock_combo_master_only(base_user_data: dict, mock_translator: Translator):
|
|
47
|
+
"""
|
|
48
|
+
Tests unlocking a higher-tier achievement ('combo_7') without also getting
|
|
49
|
+
credit for a lower-tier one ('combo_3') that is already unlocked.
|
|
50
|
+
"""
|
|
51
|
+
# Arrange: Assume 'combo_3' is already unlocked and the user meets the 'combo_7' condition
|
|
52
|
+
base_user_data["achievements_unlocked"]["combo_3"] = "2025-01-01"
|
|
53
|
+
base_user_data["stats"]["consecutive_commit_days"] = 7
|
|
54
|
+
|
|
55
|
+
xp = check_all_achievements(base_user_data, mock_translator, context={"command": "commit"})
|
|
56
|
+
|
|
57
|
+
assert "combo_7" in base_user_data["achievements_unlocked"]
|
|
58
|
+
# Assert: The awarded XP should only be from the 'combo_7' achievement.
|
|
59
|
+
assert xp == 300
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_unlock_firefighter(base_user_data: dict, mock_translator: Translator):
|
|
63
|
+
"""Tests that the 'firefighter' achievement unlocks with enough deletions."""
|
|
64
|
+
# Arrange: Provide a context with sufficient deletion stats
|
|
65
|
+
context = {"command": "commit", "deletions": 501}
|
|
66
|
+
|
|
67
|
+
xp = check_all_achievements(base_user_data, mock_translator, context)
|
|
68
|
+
|
|
69
|
+
assert "firefighter" in base_user_data["achievements_unlocked"]
|
|
70
|
+
assert xp == 200
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_no_unlock_if_already_unlocked(base_user_data: dict, mock_translator: Translator):
|
|
74
|
+
"""Tests that an already unlocked achievement does not grant XP again."""
|
|
75
|
+
# Arrange: Set up a user who has already unlocked 'first_commit'
|
|
76
|
+
base_user_data["stats"]["total_commits"] = 5
|
|
77
|
+
base_user_data["achievements_unlocked"]["first_commit"] = "2025-01-01"
|
|
78
|
+
|
|
79
|
+
# Act: Run the checker again
|
|
80
|
+
xp = check_all_achievements(base_user_data, mock_translator, context={"command": "commit"})
|
|
81
|
+
|
|
82
|
+
# Assert: No new XP should be awarded.
|
|
83
|
+
assert xp == 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# tests/test_cli_commands.py
|
|
2
|
+
"""
|
|
3
|
+
End-to-end tests for the CLI commands, simulating user interaction.
|
|
4
|
+
These tests verify command behavior, error handling, and output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
from gg_cli.main import app
|
|
9
|
+
|
|
10
|
+
# A single CliRunner instance can be reused for all tests.
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_cli_help_command():
|
|
15
|
+
"""Tests that `gg help` runs successfully and contains expected text."""
|
|
16
|
+
result = runner.invoke(app, ["help"])
|
|
17
|
+
|
|
18
|
+
assert result.exit_code == 0
|
|
19
|
+
assert "profile" in result.stdout
|
|
20
|
+
assert "config" in result.stdout
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cli_profile_needs_git_repo(monkeypatch):
|
|
24
|
+
"""
|
|
25
|
+
Tests that running `gg profile` outside a Git repository exits with an error.
|
|
26
|
+
"""
|
|
27
|
+
# Arrange: Use monkeypatch to simulate being outside a Git repository.
|
|
28
|
+
# We patch the function at the point of use to ensure the patch takes effect.
|
|
29
|
+
monkeypatch.setattr('gg_cli.main.is_in_git_repo', lambda: False)
|
|
30
|
+
|
|
31
|
+
# Act: Invoke the 'profile' command.
|
|
32
|
+
result = runner.invoke(app, ["profile"])
|
|
33
|
+
|
|
34
|
+
# Assert: The command should fail with a non-zero exit code and show an error message.
|
|
35
|
+
assert result.exit_code != 0
|
|
36
|
+
assert "must be run inside a Git repository" in result.stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cli_help_works_outside_git_repo(monkeypatch):
|
|
40
|
+
"""
|
|
41
|
+
Tests that the `gg help` command is accessible even when outside a Git repository.
|
|
42
|
+
"""
|
|
43
|
+
# Arrange: Simulate being outside a Git repository.
|
|
44
|
+
monkeypatch.setattr('gg_cli.main.is_in_git_repo', lambda: False)
|
|
45
|
+
|
|
46
|
+
# Act: Invoke the 'help' command.
|
|
47
|
+
result = runner.invoke(app, ["help"])
|
|
48
|
+
|
|
49
|
+
# Assert: The command should run successfully with a zero exit code.
|
|
50
|
+
assert result.exit_code == 0
|
|
51
|
+
assert "Usage: gg COMMAND" in result.stdout
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# tests/test_gamify_logic.py
|
|
2
|
+
"""
|
|
3
|
+
Unit tests for the core gamification numerical logic, such as level and XP calculations.
|
|
4
|
+
These tests ensure the mathematical correctness of the leveling system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from gg_cli.gamify import get_level_from_xp, get_total_xp_for_level, get_level_info
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("xp, expected_level", [
|
|
11
|
+
(0, 1), # Initial state
|
|
12
|
+
(99, 1), # Just before leveling up
|
|
13
|
+
(100, 2), # Exactly at the threshold for level 2
|
|
14
|
+
(199, 2), # In the middle of level 2
|
|
15
|
+
(999, 10), # Just before leaving the first tier
|
|
16
|
+
(1000, 11), # Exactly at the threshold for level 11 (10 * 100 XP)
|
|
17
|
+
(1249, 11), # In the middle of level 11
|
|
18
|
+
(3499, 20), # Just before leaving the second tier
|
|
19
|
+
(3500, 21), # Exactly at the threshold for level 21 (10*100 + 10*250 XP)
|
|
20
|
+
])
|
|
21
|
+
def test_get_level_from_xp_scenarios(xp, expected_level):
|
|
22
|
+
"""
|
|
23
|
+
Tests that get_level_from_xp returns the correct level for various XP values,
|
|
24
|
+
covering initial, intermediate, and boundary conditions.
|
|
25
|
+
"""
|
|
26
|
+
assert get_level_from_xp(xp) == expected_level
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("target_level, expected_total_xp", [
|
|
29
|
+
(1, 0), # No XP required to reach level 1
|
|
30
|
+
(2, 100), # 100 XP required to reach level 2
|
|
31
|
+
(3, 200), # 100 (for L2) + 100 (for L3) = 200 XP
|
|
32
|
+
(11, 1000), # 10 levels * 100 XP/level = 1000 XP
|
|
33
|
+
(12, 1250), # 1000 (for L1-10) + 250 (for L11) = 1250 XP
|
|
34
|
+
(21, 3500), # 1000 (for L1-10) + 2500 (for L11-20) = 3500 XP
|
|
35
|
+
])
|
|
36
|
+
def test_get_total_xp_for_level_scenarios(target_level, expected_total_xp):
|
|
37
|
+
"""
|
|
38
|
+
Tests that get_total_xp_for_level correctly calculates the cumulative XP
|
|
39
|
+
needed to reach a specific target level.
|
|
40
|
+
"""
|
|
41
|
+
assert get_total_xp_for_level(target_level) == expected_total_xp
|
|
42
|
+
|
|
43
|
+
def test_get_level_info_boundaries():
|
|
44
|
+
"""
|
|
45
|
+
Tests that get_level_info returns the correct tier information
|
|
46
|
+
at the boundaries between level tiers.
|
|
47
|
+
"""
|
|
48
|
+
# Level 10 should be in the first tier (100 XP/level)
|
|
49
|
+
_, xp_per_level_10, title_key_10 = get_level_info(10)
|
|
50
|
+
assert xp_per_level_10 == 100
|
|
51
|
+
assert title_key_10 == "level_title_novice"
|
|
52
|
+
|
|
53
|
+
# Level 11 should be in the second tier (250 XP/level)
|
|
54
|
+
_, xp_per_level_11, title_key_11 = get_level_info(11)
|
|
55
|
+
assert xp_per_level_11 == 250
|
|
56
|
+
assert title_key_11 == "level_title_apprentice"
|