gitgotchi 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.
@@ -0,0 +1,277 @@
1
+ """State management for pet persistence."""
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from sqlalchemy.orm import Session
7
+
8
+ from src.db.models import (
9
+ CommitHistory,
10
+ PetState,
11
+ StoryMemory,
12
+ get_engine,
13
+ init_db,
14
+ )
15
+ from src.pet.states import PetForm, PetMood, PetStats
16
+
17
+
18
+ class StateManager:
19
+ """Manages pet state persistence and retrieval."""
20
+
21
+ def __init__(self, db_path: str = ".gitgotchi/state.db") -> None:
22
+ """Initialize state manager.
23
+
24
+ Args:
25
+ db_path: Path to SQLite database file
26
+ """
27
+ self.db_path = db_path
28
+ self._ensure_db_exists()
29
+ self.engine = get_engine(db_path)
30
+
31
+ def _ensure_db_exists(self) -> None:
32
+ """Ensure database directory and schema exist."""
33
+ db_dir = Path(self.db_path).parent
34
+ db_dir.mkdir(parents=True, exist_ok=True)
35
+ init_db(self.db_path)
36
+
37
+ def get_pet_state(self) -> PetStats:
38
+ """Load current pet state from database.
39
+
40
+ Returns:
41
+ PetStats instance with current state
42
+ """
43
+ with Session(self.engine) as session:
44
+ pet_state = session.query(PetState).first()
45
+
46
+ if not pet_state:
47
+ # Create initial state
48
+ pet_state = PetState()
49
+ session.add(pet_state)
50
+ session.commit()
51
+
52
+ # Convert to PetStats
53
+ stats = PetStats()
54
+ stats.pet_name = pet_state.pet_name
55
+ stats.total_commits = pet_state.total_commits
56
+ stats.lines_added = pet_state.lines_added
57
+ stats.lines_deleted = pet_state.lines_deleted
58
+ stats.merge_conflicts = pet_state.merge_conflicts
59
+ stats.reverts = pet_state.reverts
60
+ stats.quality_score = pet_state.quality_score
61
+ stats.evolution_points = pet_state.evolution_points
62
+ stats.friend_level = pet_state.friend_level
63
+ stats.last_fed = pet_state.last_fed
64
+ stats.current_mood = PetMood(pet_state.current_mood)
65
+ stats.current_form = PetForm(pet_state.current_form)
66
+
67
+ return stats
68
+
69
+ def save_pet_state(self, stats: PetStats) -> None:
70
+ """Save pet state to database.
71
+
72
+ Args:
73
+ stats: PetStats instance to persist
74
+ """
75
+ with Session(self.engine) as session:
76
+ pet_state = session.query(PetState).first()
77
+
78
+ if not pet_state:
79
+ pet_state = PetState()
80
+ session.add(pet_state)
81
+
82
+ pet_state.pet_name = stats.pet_name
83
+ pet_state.total_commits = stats.total_commits
84
+ pet_state.lines_added = stats.lines_added
85
+ pet_state.lines_deleted = stats.lines_deleted
86
+ pet_state.merge_conflicts = stats.merge_conflicts
87
+ pet_state.reverts = stats.reverts
88
+ pet_state.quality_score = stats.quality_score
89
+ pet_state.evolution_points = stats.evolution_points
90
+ pet_state.friend_level = stats.friend_level
91
+ pet_state.last_fed = stats.last_fed
92
+ pet_state.current_mood = stats.current_mood.value
93
+ pet_state.current_form = stats.current_form.value
94
+ pet_state.updated_at = datetime.now()
95
+
96
+ session.commit()
97
+
98
+ def log_commit(
99
+ self,
100
+ commit_hash: str,
101
+ author: str,
102
+ message: str,
103
+ timestamp: datetime,
104
+ lines_added: int = 0,
105
+ lines_deleted: int = 0,
106
+ files_changed: int = 0,
107
+ is_merge: bool = False,
108
+ is_revert: bool = False,
109
+ quality_impact: float = 0.0,
110
+ ) -> None:
111
+ """Log a commit to history.
112
+
113
+ Args:
114
+ commit_hash: Git commit SHA
115
+ author: Commit author
116
+ message: Commit message
117
+ timestamp: Commit timestamp
118
+ lines_added: Lines added in commit
119
+ lines_deleted: Lines deleted in commit
120
+ files_changed: Number of files changed
121
+ is_merge: Whether this is a merge commit
122
+ is_revert: Whether this is a revert commit
123
+ quality_impact: Impact on quality score
124
+ """
125
+ with Session(self.engine) as session:
126
+ # Check if already logged
127
+ existing = (
128
+ session.query(CommitHistory)
129
+ .filter_by(commit_hash=commit_hash)
130
+ .first()
131
+ )
132
+
133
+ if existing:
134
+ return
135
+
136
+ commit = CommitHistory(
137
+ commit_hash=commit_hash,
138
+ author=author,
139
+ message=message,
140
+ timestamp=timestamp,
141
+ lines_added=lines_added,
142
+ lines_deleted=lines_deleted,
143
+ files_changed=files_changed,
144
+ is_merge=is_merge,
145
+ is_revert=is_revert,
146
+ quality_impact=quality_impact,
147
+ )
148
+ session.add(commit)
149
+ session.commit()
150
+
151
+ def save_story(
152
+ self,
153
+ commit_hash: str,
154
+ story_text: str,
155
+ story_type: str,
156
+ pet_form: PetForm,
157
+ pet_mood: PetMood,
158
+ tokens_used: Optional[int] = None,
159
+ ) -> None:
160
+ """Save generated story to cache.
161
+
162
+ Args:
163
+ commit_hash: Associated commit hash
164
+ story_text: Generated story content
165
+ story_type: Type of story
166
+ pet_form: Pet form at time of generation
167
+ pet_mood: Pet mood at time of generation
168
+ tokens_used: Number of tokens used
169
+ """
170
+ with Session(self.engine) as session:
171
+ story = StoryMemory(
172
+ commit_hash=commit_hash,
173
+ story_text=story_text,
174
+ story_type=story_type,
175
+ pet_form_at_time=pet_form.value,
176
+ pet_mood_at_time=pet_mood.value,
177
+ tokens_used=tokens_used,
178
+ )
179
+ session.add(story)
180
+ session.commit()
181
+
182
+ def get_story(self, commit_hash: str) -> Optional[str]:
183
+ """Retrieve cached story for a commit.
184
+
185
+ Args:
186
+ commit_hash: Git commit SHA
187
+
188
+ Returns:
189
+ Story text if cached, None otherwise
190
+ """
191
+ with Session(self.engine) as session:
192
+ story = (
193
+ session.query(StoryMemory)
194
+ .filter_by(commit_hash=commit_hash)
195
+ .first()
196
+ )
197
+ return story.story_text if story else None
198
+
199
+ def process_commit_event(
200
+ self,
201
+ commit_hash: str,
202
+ author: str,
203
+ message: str,
204
+ timestamp: datetime,
205
+ lines_added: int = 0,
206
+ lines_deleted: int = 0,
207
+ files_changed: int = 0,
208
+ is_merge: bool = False,
209
+ is_revert: bool = False,
210
+ ) -> PetStats:
211
+ """Process a commit event and update pet state.
212
+
213
+ Args:
214
+ commit_hash: Git commit SHA
215
+ author: Commit author
216
+ message: Commit message
217
+ timestamp: Commit timestamp
218
+ lines_added: Lines added in commit
219
+ lines_deleted: Lines deleted in commit
220
+ files_changed: Number of files changed
221
+ is_merge: Whether this is a merge commit
222
+ is_revert: Whether this is a revert commit
223
+
224
+ Returns:
225
+ Updated PetStats
226
+ """
227
+ # Load current state
228
+ stats = self.get_pet_state()
229
+
230
+ # Update stats
231
+ stats.total_commits += 1
232
+ stats.lines_added += lines_added
233
+ stats.lines_deleted += lines_deleted
234
+
235
+ if is_merge:
236
+ stats.merge_conflicts += 1
237
+
238
+ if is_revert:
239
+ stats.reverts += 1
240
+
241
+ # Feed the pet (updates last_fed and mood)
242
+ stats.feed()
243
+
244
+ # Update form based on new stats
245
+ stats.update_form()
246
+
247
+ # Calculate quality impact
248
+ quality_impact = 0.0
249
+ if is_revert:
250
+ quality_impact = -5.0
251
+ elif is_merge:
252
+ quality_impact = -2.0
253
+ else:
254
+ quality_impact = 1.0
255
+
256
+ stats.quality_score = max(
257
+ 0.0, min(100.0, stats.quality_score + quality_impact)
258
+ )
259
+
260
+ # Log commit
261
+ self.log_commit(
262
+ commit_hash=commit_hash,
263
+ author=author,
264
+ message=message,
265
+ timestamp=timestamp,
266
+ lines_added=lines_added,
267
+ lines_deleted=lines_deleted,
268
+ files_changed=files_changed,
269
+ is_merge=is_merge,
270
+ is_revert=is_revert,
271
+ quality_impact=quality_impact,
272
+ )
273
+
274
+ # Save updated state
275
+ self.save_pet_state(stats)
276
+
277
+ return stats
src/hooks/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Git hooks module."""
src/hooks/installer.py ADDED
@@ -0,0 +1,128 @@
1
+ """Hook installation."""
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from git import InvalidGitRepositoryError, Repo
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def find_git_root() -> Optional[Path]:
14
+ """Find git repository root.
15
+
16
+ Returns:
17
+ Path to git root, or None if not in a git repo
18
+ """
19
+ try:
20
+ repo = Repo(".", search_parent_directories=True)
21
+ return Path(repo.git_dir).parent
22
+ except InvalidGitRepositoryError:
23
+ return None
24
+
25
+
26
+ def install_hooks() -> bool:
27
+ """Install git hooks for GitGotchi.
28
+
29
+ Returns:
30
+ True if successful, False otherwise
31
+ """
32
+ git_root = find_git_root()
33
+
34
+ if not git_root:
35
+ console.print(
36
+ "[red]👻 No git repository found! Initialize git first.[/red]"
37
+ )
38
+ return False
39
+
40
+ hooks_dir = git_root / ".git" / "hooks"
41
+ hooks_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ post_commit_path = hooks_dir / "post-commit"
44
+
45
+ # GitGotchi hook content
46
+ python_path = sys.executable.replace("\\", "/")
47
+ gitgotchi_hook = f"""#!/bin/sh
48
+ # GitGotchi post-commit hook
49
+ "{python_path}" -m src.hooks.post_commit
50
+ """
51
+
52
+ # Check if hook already exists
53
+ if post_commit_path.exists():
54
+ existing_content = post_commit_path.read_text()
55
+
56
+ if "GitGotchi" in existing_content:
57
+ console.print("[yellow]👻 GitGotchi hooks already installed![/yellow]")
58
+ return True
59
+
60
+ # Append to existing hook
61
+ console.print(
62
+ "[yellow]⚠️ Existing post-commit hook found. Appending GitGotchi...[/yellow]"
63
+ )
64
+ with open(post_commit_path, "a") as f:
65
+ f.write("\n" + gitgotchi_hook)
66
+ else:
67
+ # Create new hook
68
+ post_commit_path.write_text(gitgotchi_hook)
69
+
70
+ # Make executable
71
+ post_commit_path.chmod(0o755)
72
+
73
+ console.print("[green]✨ GitGotchi hooks installed successfully![/green]")
74
+ console.print(f"[dim]Hook location: {post_commit_path}[/dim]")
75
+
76
+ return True
77
+
78
+
79
+ def uninstall_hooks() -> bool:
80
+ """Uninstall GitGotchi git hooks.
81
+
82
+ Returns:
83
+ True if successful, False otherwise
84
+ """
85
+ git_root = find_git_root()
86
+
87
+ if not git_root:
88
+ console.print("[red]👻 No git repository found![/red]")
89
+ return False
90
+
91
+ post_commit_path = git_root / ".git" / "hooks" / "post-commit"
92
+
93
+ if not post_commit_path.exists():
94
+ console.print("[yellow]👻 No hooks found to uninstall.[/yellow]")
95
+ return True
96
+
97
+ existing_content = post_commit_path.read_text()
98
+
99
+ if "GitGotchi" not in existing_content:
100
+ console.print("[yellow]👻 GitGotchi hooks not found.[/yellow]")
101
+ return True
102
+
103
+ # Remove GitGotchi section
104
+ lines = existing_content.split("\n")
105
+ filtered_lines = []
106
+ skip_next = False
107
+
108
+ for line in lines:
109
+ if "GitGotchi" in line:
110
+ skip_next = True
111
+ continue
112
+ if skip_next and line.strip().startswith("python"):
113
+ skip_next = False
114
+ continue
115
+ filtered_lines.append(line)
116
+
117
+ new_content = "\n".join(filtered_lines).strip()
118
+
119
+ if new_content and new_content != "#!/bin/sh":
120
+ # Other hooks remain, update file
121
+ post_commit_path.write_text(new_content)
122
+ console.print("[green]✨ GitGotchi hooks removed.[/green]")
123
+ else:
124
+ # No other hooks, delete file
125
+ post_commit_path.unlink()
126
+ console.print("[green]✨ GitGotchi hooks removed (file deleted).[/green]")
127
+
128
+ return True
@@ -0,0 +1,70 @@
1
+ """Git hook handlers."""
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from git import Repo
6
+
7
+ from src.db.state_manager import StateManager
8
+ from src.pet.renderer import render_commit_response, render_error, render_evolution
9
+
10
+
11
+ def handle_post_commit() -> None:
12
+ """Handle post-commit hook."""
13
+ try:
14
+ # Get git repo
15
+ repo = Repo(".", search_parent_directories=True)
16
+ git_root = Path(repo.git_dir).parent
17
+
18
+ # Get latest commit
19
+ commit = repo.head.commit
20
+
21
+ # Parse commit metadata
22
+ commit_hash = commit.hexsha
23
+ author = str(commit.author)
24
+ message = commit.message
25
+ timestamp = datetime.fromtimestamp(commit.committed_date)
26
+
27
+ # Calculate stats
28
+ stats = commit.stats.total
29
+ lines_added = stats.get("insertions", 0)
30
+ lines_deleted = stats.get("deletions", 0)
31
+ files_changed = stats.get("files", 0)
32
+
33
+ # Check for merge/revert
34
+ is_merge = len(commit.parents) > 1
35
+ is_revert = message.lower().startswith("revert")
36
+
37
+ # Initialize state manager
38
+ db_path = git_root / ".gitgotchi" / "state.db"
39
+ state_manager = StateManager(str(db_path))
40
+
41
+ # Get old form for evolution check
42
+ old_stats = state_manager.get_pet_state()
43
+ old_form = old_stats.current_form
44
+
45
+ # Process commit event
46
+ new_stats = state_manager.process_commit_event(
47
+ commit_hash=commit_hash,
48
+ author=author,
49
+ message=message,
50
+ timestamp=timestamp,
51
+ lines_added=lines_added,
52
+ lines_deleted=lines_deleted,
53
+ files_changed=files_changed,
54
+ is_merge=is_merge,
55
+ is_revert=is_revert,
56
+ )
57
+
58
+ # Check for evolution
59
+ if new_stats.current_form != old_form:
60
+ render_evolution(old_form, new_stats.current_form)
61
+
62
+ # Render commit response
63
+ render_commit_response(new_stats, lines_added, lines_deleted)
64
+
65
+ except Exception as e:
66
+ render_error(f"Failed to process commit: {str(e)}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ handle_post_commit()
src/pet/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Pet module."""
src/pet/evolution.py ADDED
@@ -0,0 +1,5 @@
1
+ """Pet evolution logic."""
2
+
3
+ def evolve_pet():
4
+ """Handle pet evolution."""
5
+ pass
src/pet/renderer.py ADDED
@@ -0,0 +1,208 @@
1
+ """ASCII art & terminal UI."""
2
+ import sys
3
+
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+
8
+ from src.pet.states import PetForm, PetMood, PetStats
9
+
10
+ # Force UTF-8 encoding for Windows
11
+ if sys.platform == "win32":
12
+ sys.stdout.reconfigure(encoding="utf-8")
13
+
14
+ console = Console(force_terminal=True, legacy_windows=False)
15
+
16
+ # ASCII art for each pet form
17
+ PET_SPRITES = {
18
+ PetForm.EGG: """
19
+ .-""-.
20
+ / \\
21
+ | o o |
22
+ \\ __ /
23
+ '-..-'
24
+ """,
25
+ PetForm.GHOST: """
26
+ .-.
27
+ (o.o)
28
+ |=|
29
+ __|__
30
+ / \\
31
+ | () () |
32
+ \\ /
33
+ `---'
34
+ """,
35
+ PetForm.ANGEL: """
36
+ \\|/
37
+ .-'o'-.
38
+ / \\_/ \\
39
+ | (o.o) |
40
+ \\ | /
41
+ '--|--'
42
+ |
43
+ / \\
44
+ """,
45
+ PetForm.DEMON: """
46
+ /\\ /\\
47
+ ( o.o )
48
+ > ^ <
49
+ /| |\\
50
+ (_| |_)
51
+ | |
52
+ /| |\\
53
+ """,
54
+ PetForm.ZOMBIE: """
55
+ .---.
56
+ /x x\\
57
+ | ___ |
58
+ \\ \\_/ /
59
+ |||||
60
+ /| |\\
61
+ / | | \\
62
+ """,
63
+ PetForm.WRAITH: """
64
+ ___
65
+ .' '.
66
+ / o o \\
67
+ | ^ |
68
+ \\ \\_/ /
69
+ '.___.'
70
+ |||||
71
+ ~~~~~~~
72
+ """,
73
+ }
74
+
75
+ # Mood-based colors (Stardew Valley inspired pastels)
76
+ MOOD_COLORS = {
77
+ PetMood.JOYFUL: "bright_yellow", # Gold sparkles
78
+ PetMood.HAPPY: "cyan", # Sky blue
79
+ PetMood.CONTENT: "bright_white", # Soft white
80
+ PetMood.HUNGRY: "yellow", # Gentle yellow
81
+ PetMood.TIRED: "magenta", # Lavender
82
+ PetMood.SLEEPING: "blue", # Soft blue
83
+ }
84
+
85
+ # Mood messages (friendly and encouraging)
86
+ MOOD_MESSAGES = {
87
+ PetMood.JOYFUL: "✨ Your friend is glowing with happiness! ✨",
88
+ PetMood.HAPPY: "💛 Your companion is content and cheerful!",
89
+ PetMood.CONTENT: "🌟 Your friend is peaceful and happy",
90
+ PetMood.HUNGRY: "🍂 Your friend misses you... time for a commit?",
91
+ PetMood.TIRED: "💤 Your companion is getting sleepy...",
92
+ PetMood.SLEEPING: "😴 Zzz... Your friend is dreaming of code...",
93
+ }
94
+
95
+
96
+ def render_pet(stats: PetStats, show_stats: bool = True) -> None:
97
+ """Render pet to terminal with haunted styling.
98
+
99
+ Args:
100
+ stats: Current pet statistics
101
+ show_stats: Whether to show detailed stats
102
+ """
103
+ sprite = PET_SPRITES.get(stats.current_form, PET_SPRITES[PetForm.GHOST])
104
+ color = MOOD_COLORS.get(stats.current_mood, "white")
105
+ mood_msg = MOOD_MESSAGES.get(stats.current_mood, "")
106
+
107
+ # Create styled sprite
108
+ styled_sprite = Text(sprite, style=color)
109
+
110
+ # Build title
111
+ title = f"👻 {stats.current_form.value.upper()} - {stats.current_mood.value}"
112
+
113
+ # Build subtitle with mood message
114
+ subtitle = Text(mood_msg, style=color)
115
+
116
+ # Create panel content
117
+ content = styled_sprite
118
+
119
+ if show_stats:
120
+ stats_text = Text("\n")
121
+ stats_text.append(f"💀 Commits: {stats.total_commits}\n", style="cyan")
122
+ stats_text.append(f"✨ Quality: {stats.quality_score:.1f}/100\n", style="cyan")
123
+ stats_text.append(f"➕ Lines Added: {stats.lines_added}\n", style="green")
124
+ stats_text.append(f"➖ Lines Deleted: {stats.lines_deleted}\n", style="red")
125
+ stats_text.append(f"⚔️ Conflicts: {stats.merge_conflicts}\n", style="yellow")
126
+ stats_text.append(f"🔄 Reverts: {stats.reverts}\n", style="magenta")
127
+
128
+ content = Text.assemble(styled_sprite, stats_text)
129
+
130
+ # Render panel
131
+ panel = Panel(
132
+ content,
133
+ title=title,
134
+ subtitle=subtitle,
135
+ border_style=color,
136
+ padding=(1, 2),
137
+ )
138
+
139
+ console.print(panel)
140
+
141
+
142
+ def render_evolution(old_form: PetForm, new_form: PetForm) -> None:
143
+ """Render evolution animation.
144
+
145
+ Args:
146
+ old_form: Previous pet form
147
+ new_form: New pet form
148
+ """
149
+ console.print("\n")
150
+ console.print("✨" * 20, style="bright_yellow")
151
+ console.print(
152
+ f"[bold bright_yellow]Something magical is happening! ✨[/bold bright_yellow]"
153
+ )
154
+ console.print(
155
+ f"[bold bright_cyan]Your {old_form.value} is evolving into a {new_form.value}! 🎉[/bold bright_cyan]"
156
+ )
157
+ console.print("✨" * 20, style="bright_yellow")
158
+ console.print("\n")
159
+
160
+
161
+ def render_commit_response(
162
+ stats: PetStats, lines_added: int, lines_deleted: int
163
+ ) -> None:
164
+ """Render response to a commit.
165
+
166
+ Args:
167
+ stats: Updated pet statistics
168
+ lines_added: Lines added in commit
169
+ lines_deleted: Lines deleted in commit
170
+ """
171
+ console.print("\n")
172
+
173
+ # Friendly commit messages
174
+ messages = [
175
+ "Your friend is happy! Great commit! ✨",
176
+ "Yay! Your companion grows stronger! 🌟",
177
+ "Another step forward together! 💛",
178
+ "Your friend appreciates your hard work! 🍂",
179
+ "Wonderful! Your bond deepens! ❤️",
180
+ ]
181
+
182
+ import random
183
+
184
+ msg = random.choice(messages)
185
+ console.print(f"[cyan]{msg}[/cyan]")
186
+
187
+ # Show quick stats
188
+ console.print(
189
+ f"[green]+{lines_added}[/green] [red]-{lines_deleted}[/red] lines"
190
+ )
191
+
192
+ # Render pet
193
+ render_pet(stats, show_stats=False)
194
+
195
+
196
+ def render_error(message: str) -> None:
197
+ """Render error with friendly flavor.
198
+
199
+ Args:
200
+ message: Error message
201
+ """
202
+ console.print(
203
+ Panel(
204
+ f"[bold yellow]🌙 Oh no! Something went wrong...[/bold yellow]\n\n{message}",
205
+ border_style="yellow",
206
+ title="⚠️ Oops",
207
+ )
208
+ )