gitgotchi 0.3.0__tar.gz → 0.3.1__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.
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/CHANGELOG.md +26 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/MANIFEST.in +1 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/PKG-INFO +11 -6
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/README.md +9 -4
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/pyproject.toml +1 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/__init__.py +1 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/cli.py +28 -4
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/db/models.py +27 -16
- gitgotchi-0.3.1/src/gitgotchi/db/state_manager.py +311 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/demo.py +8 -3
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/haunt.py +87 -26
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/history.py +39 -4
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/installer.py +21 -7
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/post_commit.py +21 -3
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/paths.py +20 -20
- gitgotchi-0.3.1/src/gitgotchi/pet/possession.py +84 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/renderer.py +1 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/states.py +16 -13
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/hauntings.py +45 -20
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/PKG-INFO +11 -6
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/SOURCES.txt +0 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/requires.txt +1 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_cli.py +26 -0
- gitgotchi-0.3.1/tests/test_haunt.py +130 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_hauntings.py +17 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_history.py +1 -1
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_hooks.py +59 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_possession.py +13 -0
- gitgotchi-0.3.1/tests/test_state_manager.py +174 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_states.py +12 -0
- gitgotchi-0.3.0/src/gitgotchi/db/migrations.py +0 -100
- gitgotchi-0.3.0/src/gitgotchi/db/state_manager.py +0 -383
- gitgotchi-0.3.0/src/gitgotchi/pet/possession.py +0 -56
- gitgotchi-0.3.0/tests/test_haunt.py +0 -54
- gitgotchi-0.3.0/tests/test_state_manager.py +0 -73
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/INSTALL.md +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/LICENSE +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/setup.cfg +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/__main__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/db/__init__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/__init__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/__init__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/quality.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/theme.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/__init__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/card.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/effects.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/font.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/forms.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/raster.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/scene.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/terminal.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/__init__.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/story_gen.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/dependency_links.txt +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/entry_points.txt +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/top_level.txt +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/conftest.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_quality.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_render.py +0 -0
- {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_story.py +0 -0
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Security:** GitGotchi loaded every variable from a repo's `.env` file, so a cloned repo could
|
|
8
|
+
inject git settings and run commands during `gitgotchi seance`. Now only `ANTHROPIC_API_KEY` is read.
|
|
9
|
+
- AI-agent detection no longer flags people: co-authors at Anthropic, OpenAI or Gemini, anyone named
|
|
10
|
+
Cline, or messages that merely mention ChatGPT. It now matches agent noreply addresses and bot accounts.
|
|
11
|
+
- The pet now lives in `.git/gitgotchi/`, so every worktree of a repo shares one pet (agents often
|
|
12
|
+
commit from worktrees). Existing pets are moved there automatically.
|
|
13
|
+
- `haunt` watches every local branch, doesn't feed commits that arrive by pull, groups bursts of
|
|
14
|
+
commits into one reaction, keeps running through a busy database, and no longer crashes on
|
|
15
|
+
brackets in commit messages.
|
|
16
|
+
- Amending a commit no longer counts it again.
|
|
17
|
+
- Running `adopt` while a commit is being processed could lose an update; writes are now atomic.
|
|
18
|
+
- `adopt` on an existing pet no longer puts it to sleep.
|
|
19
|
+
- The séance handles non-ASCII file names, renamed files, and more TODOs in big repos; the
|
|
20
|
+
witching hour uses each author's own timezone.
|
|
21
|
+
- Petting a dormant pet now really wakes it, and friendship grows even while it sleeps.
|
|
22
|
+
- The hook works when Python's path contains spaces or shell characters, runs before any `exit` in
|
|
23
|
+
an existing hook, and refuses to edit non-shell hooks.
|
|
24
|
+
- Repos in folders with `?` or `%` in their path work.
|
|
25
|
+
- `gitgotchi name` rejects empty and very long names; `card` reports the size it actually saved;
|
|
26
|
+
`adopt --limit` must be at least 1.
|
|
27
|
+
- Requires `typer>=0.15.4`: older versions crash with current `click`.
|
|
28
|
+
|
|
3
29
|
## 0.3.0 - Halloween
|
|
4
30
|
|
|
5
31
|
### New
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitgotchi
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A 3D pixel-art Halloween spirit that haunts your git repo and reacts as you (and your AI agents) commit
|
|
5
5
|
Author-email: M K Sai Srikar <Srikarmks@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -28,7 +28,7 @@ Requires-Dist: rich>=13.7.0
|
|
|
28
28
|
Requires-Dist: gitpython>=3.1.40
|
|
29
29
|
Requires-Dist: anthropic>=1.0.0
|
|
30
30
|
Requires-Dist: sqlalchemy>=2.0.36
|
|
31
|
-
Requires-Dist: typer>=0.
|
|
31
|
+
Requires-Dist: typer>=0.15.4
|
|
32
32
|
Requires-Dist: python-dotenv>=1.0.0
|
|
33
33
|
Requires-Dist: numpy>=1.24
|
|
34
34
|
Provides-Extra: dev
|
|
@@ -73,7 +73,9 @@ gitgotchi haunt # open the live pane
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Keep `gitgotchi haunt` open in a split next to the terminal where you or your agent are working. Every
|
|
76
|
-
commit gets a reaction, agent commits glitch the screen, and evolutions play out live.
|
|
76
|
+
commit gets a reaction, agent commits glitch the screen, and evolutions play out live. It watches every
|
|
77
|
+
local branch, so it sees commits from other worktrees too. Commits that arrive by `git pull` aren't yours,
|
|
78
|
+
so they don't feed your companion.
|
|
77
79
|
|
|
78
80
|

|
|
79
81
|
|
|
@@ -106,7 +108,9 @@ gitgotchi seance
|
|
|
106
108
|
Your companion digs through the history for its ghosts: the oldest TODO still in the code, the file
|
|
107
109
|
nobody has opened in ages, the biggest deletion, commits made between midnight and 4am, and the first
|
|
108
110
|
commit an AI agent wrote. Then it tells you a story about them. Set `ANTHROPIC_API_KEY` (or put it in a
|
|
109
|
-
`.env` file) to have Claude tell it; otherwise your companion tells it in its own words.
|
|
111
|
+
`.env` file) to have Claude tell it; otherwise your companion tells it in its own words. With a key set,
|
|
112
|
+
the findings (file names, TODO text and recent commit messages) are sent to the Anthropic API to write the
|
|
113
|
+
story. GitGotchi reads only `ANTHROPIC_API_KEY` from `.env` and ignores everything else in it.
|
|
110
114
|
|
|
111
115
|
## 🎮 All commands
|
|
112
116
|
|
|
@@ -158,8 +162,9 @@ also shows the image inline in terminals that support it. Set `GITGOTCHI_GRAPHIC
|
|
|
158
162
|
|
|
159
163
|
## 🗂️ Where state lives
|
|
160
164
|
|
|
161
|
-
Your companion
|
|
162
|
-
|
|
165
|
+
Your companion lives in `.git/gitgotchi/state.db`, inside the repository's git directory. It never
|
|
166
|
+
shows up in `git status`, can't be committed, and every `git worktree` of the repo shares the same pet, so
|
|
167
|
+
commits your agents make in their own worktrees count too.
|
|
163
168
|
|
|
164
169
|
## 🛠️ Development
|
|
165
170
|
|
|
@@ -32,7 +32,9 @@ gitgotchi haunt # open the live pane
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Keep `gitgotchi haunt` open in a split next to the terminal where you or your agent are working. Every
|
|
35
|
-
commit gets a reaction, agent commits glitch the screen, and evolutions play out live.
|
|
35
|
+
commit gets a reaction, agent commits glitch the screen, and evolutions play out live. It watches every
|
|
36
|
+
local branch, so it sees commits from other worktrees too. Commits that arrive by `git pull` aren't yours,
|
|
37
|
+
so they don't feed your companion.
|
|
36
38
|
|
|
37
39
|

|
|
38
40
|
|
|
@@ -65,7 +67,9 @@ gitgotchi seance
|
|
|
65
67
|
Your companion digs through the history for its ghosts: the oldest TODO still in the code, the file
|
|
66
68
|
nobody has opened in ages, the biggest deletion, commits made between midnight and 4am, and the first
|
|
67
69
|
commit an AI agent wrote. Then it tells you a story about them. Set `ANTHROPIC_API_KEY` (or put it in a
|
|
68
|
-
`.env` file) to have Claude tell it; otherwise your companion tells it in its own words.
|
|
70
|
+
`.env` file) to have Claude tell it; otherwise your companion tells it in its own words. With a key set,
|
|
71
|
+
the findings (file names, TODO text and recent commit messages) are sent to the Anthropic API to write the
|
|
72
|
+
story. GitGotchi reads only `ANTHROPIC_API_KEY` from `.env` and ignores everything else in it.
|
|
69
73
|
|
|
70
74
|
## 🎮 All commands
|
|
71
75
|
|
|
@@ -117,8 +121,9 @@ also shows the image inline in terminals that support it. Set `GITGOTCHI_GRAPHIC
|
|
|
117
121
|
|
|
118
122
|
## 🗂️ Where state lives
|
|
119
123
|
|
|
120
|
-
Your companion
|
|
121
|
-
|
|
124
|
+
Your companion lives in `.git/gitgotchi/state.db`, inside the repository's git directory. It never
|
|
125
|
+
shows up in `git status`, can't be committed, and every `git worktree` of the repo shares the same pet, so
|
|
126
|
+
commits your agents make in their own worktrees count too.
|
|
122
127
|
|
|
123
128
|
## 🛠️ Development
|
|
124
129
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Main CLI interface."""
|
|
2
|
+
import os
|
|
2
3
|
import random
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
|
-
from dotenv import
|
|
8
|
+
from dotenv import dotenv_values, find_dotenv
|
|
8
9
|
from rich.panel import Panel
|
|
9
10
|
from rich.table import Table
|
|
10
11
|
from rich.text import Text
|
|
@@ -24,10 +25,24 @@ app = typer.Typer(
|
|
|
24
25
|
|
|
25
26
|
def cli():
|
|
26
27
|
"""Entry point for console script."""
|
|
27
|
-
|
|
28
|
+
load_api_key()
|
|
28
29
|
app()
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def load_api_key() -> None:
|
|
33
|
+
"""Pick up ANTHROPIC_API_KEY from a .env file if it isn't already set.
|
|
34
|
+
|
|
35
|
+
Only that one variable is read: a repo's .env could otherwise inject
|
|
36
|
+
settings such as GIT_CONFIG_* into the git commands GitGotchi runs.
|
|
37
|
+
"""
|
|
38
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
39
|
+
return
|
|
40
|
+
path = find_dotenv(usecwd=True)
|
|
41
|
+
key = dotenv_values(path).get("ANTHROPIC_API_KEY") if path else None
|
|
42
|
+
if key:
|
|
43
|
+
os.environ["ANTHROPIC_API_KEY"] = key
|
|
44
|
+
|
|
45
|
+
|
|
31
46
|
def load_state() -> StateManager:
|
|
32
47
|
try:
|
|
33
48
|
return StateManager()
|
|
@@ -106,6 +121,9 @@ def pet():
|
|
|
106
121
|
raise typer.Exit(1)
|
|
107
122
|
|
|
108
123
|
|
|
124
|
+
MAX_NAME_LENGTH = 30
|
|
125
|
+
|
|
126
|
+
|
|
109
127
|
@app.command()
|
|
110
128
|
def name(new_name: str):
|
|
111
129
|
"""Give your companion a name."""
|
|
@@ -113,6 +131,11 @@ def name(new_name: str):
|
|
|
113
131
|
state_manager = load_state()
|
|
114
132
|
pet_stats = state_manager.get_pet_state()
|
|
115
133
|
|
|
134
|
+
new_name = " ".join(new_name.split())
|
|
135
|
+
if not new_name or len(new_name) > MAX_NAME_LENGTH:
|
|
136
|
+
render_error(f"Names need between 1 and {MAX_NAME_LENGTH} characters.")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
116
139
|
old_name = pet_stats.pet_name
|
|
117
140
|
pet_stats.pet_name = new_name
|
|
118
141
|
state_manager.save_pet_state(pet_stats)
|
|
@@ -245,7 +268,7 @@ def story():
|
|
|
245
268
|
|
|
246
269
|
@app.command()
|
|
247
270
|
def adopt(
|
|
248
|
-
limit: Optional[int] = typer.Option(None, help="Only replay the most recent N commits"),
|
|
271
|
+
limit: Optional[int] = typer.Option(None, min=1, help="Only replay the most recent N commits"),
|
|
249
272
|
reset: bool = typer.Option(False, "--reset", help="Start the pet over before replaying"),
|
|
250
273
|
):
|
|
251
274
|
"""Raise your companion on this repo's existing history."""
|
|
@@ -308,7 +331,8 @@ def card(
|
|
|
308
331
|
repo_name = Path(require_repo().working_tree_dir).name
|
|
309
332
|
|
|
310
333
|
image = make_card(stats, repo_name, state_manager.agent_counts())
|
|
311
|
-
|
|
334
|
+
scale = max(1, min(12, scale))
|
|
335
|
+
output.write_bytes(encode_png(upscale(image, scale)))
|
|
312
336
|
|
|
313
337
|
protocol = graphics_protocol()
|
|
314
338
|
if protocol and console.is_terminal:
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from sqlalchemy import Float, Integer, String, Text, create_engine, inspect, text
|
|
5
|
+
from sqlalchemy import Float, Integer, String, Text, create_engine, event, inspect, text
|
|
6
|
+
from sqlalchemy.engine import URL, Engine
|
|
6
7
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
7
8
|
|
|
8
9
|
|
|
@@ -28,6 +29,7 @@ class PetState(Base):
|
|
|
28
29
|
evolution_points: Mapped[int] = mapped_column(Integer, default=0)
|
|
29
30
|
friend_level: Mapped[float] = mapped_column(Float, default=1.0)
|
|
30
31
|
ai_commits: Mapped[int] = mapped_column(Integer, default=0)
|
|
32
|
+
last_visit: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
|
31
33
|
last_fed: Mapped[datetime] = mapped_column(default=datetime.now)
|
|
32
34
|
current_mood: Mapped[str] = mapped_column(String(20), default="content")
|
|
33
35
|
current_form: Mapped[str] = mapped_column(String(20), default="egg")
|
|
@@ -72,25 +74,31 @@ class StoryMemory(Base):
|
|
|
72
74
|
tokens_used: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
73
75
|
|
|
74
76
|
|
|
75
|
-
def get_engine(db_path: str
|
|
76
|
-
"""Create database engine.
|
|
77
|
+
def get_engine(db_path: str) -> Engine:
|
|
78
|
+
"""Create the database engine.
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
SQLAlchemy engine instance
|
|
80
|
+
Every transaction takes SQLite's write lock up front (BEGIN IMMEDIATE),
|
|
81
|
+
so the hook, haunt and adopt can't interleave a read-modify-write of the
|
|
82
|
+
pet and lose an update. Waiters retry for up to 15 seconds.
|
|
83
83
|
"""
|
|
84
|
-
|
|
84
|
+
engine = create_engine(
|
|
85
|
+
URL.create("sqlite", database=str(db_path)),
|
|
86
|
+
connect_args={"timeout": 15},
|
|
87
|
+
)
|
|
85
88
|
|
|
89
|
+
@event.listens_for(engine, "connect")
|
|
90
|
+
def _no_implicit_transactions(dbapi_connection, _record):
|
|
91
|
+
dbapi_connection.isolation_level = None
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
@event.listens_for(engine, "begin")
|
|
94
|
+
def _begin_immediate(conn):
|
|
95
|
+
conn.exec_driver_sql("BEGIN IMMEDIATE")
|
|
89
96
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
return engine
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def init_db(engine: Engine) -> None:
|
|
101
|
+
"""Create the schema, upgrading older databases in place."""
|
|
94
102
|
Base.metadata.create_all(engine)
|
|
95
103
|
add_missing_columns(engine)
|
|
96
104
|
|
|
@@ -99,13 +107,16 @@ def init_db(db_path: str = ".gitgotchi/state.db") -> None:
|
|
|
99
107
|
ADDED_COLUMNS = [
|
|
100
108
|
("pet_state", "ai_commits", "INTEGER NOT NULL DEFAULT 0"),
|
|
101
109
|
("commit_history", "agent", "VARCHAR(50)"),
|
|
110
|
+
("pet_state", "last_visit", "DATETIME"),
|
|
102
111
|
]
|
|
103
112
|
|
|
104
113
|
|
|
105
114
|
def add_missing_columns(engine) -> None:
|
|
106
115
|
"""Upgrade databases created by older versions in place."""
|
|
107
|
-
|
|
116
|
+
# Inspect on the same connection: every transaction holds the write lock,
|
|
117
|
+
# so a second connection would wait on this one
|
|
108
118
|
with engine.begin() as conn:
|
|
119
|
+
inspector = inspect(conn)
|
|
109
120
|
for table, column, ddl in ADDED_COLUMNS:
|
|
110
121
|
existing = {c["name"] for c in inspector.get_columns(table)}
|
|
111
122
|
if column not in existing:
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""State management for pet persistence."""
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Iterable, List, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import func
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from gitgotchi.db.models import (
|
|
12
|
+
CommitHistory,
|
|
13
|
+
PetState,
|
|
14
|
+
StoryMemory,
|
|
15
|
+
get_engine,
|
|
16
|
+
init_db,
|
|
17
|
+
)
|
|
18
|
+
from gitgotchi.paths import require_repo, state_db_path
|
|
19
|
+
from gitgotchi.pet.possession import detect_agent
|
|
20
|
+
from gitgotchi.pet.states import PetForm, PetMood, PetStats
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from gitgotchi.history import HistoryCommit
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StateManager:
|
|
27
|
+
"""Manages pet state persistence and retrieval."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, db_path: Optional[str] = None) -> None:
|
|
30
|
+
"""Initialize state manager.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
db_path: Path to SQLite database file. Defaults to the pet of
|
|
34
|
+
the git repo containing the current directory.
|
|
35
|
+
"""
|
|
36
|
+
if db_path is None:
|
|
37
|
+
db_path = str(state_db_path(require_repo()))
|
|
38
|
+
self.db_path = db_path
|
|
39
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.engine = get_engine(db_path)
|
|
41
|
+
init_db(self.engine)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
"""Release the database file (needed before deleting it on Windows)."""
|
|
45
|
+
self.engine.dispose()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _row(session: Session) -> PetState:
|
|
49
|
+
pet_state = session.query(PetState).first()
|
|
50
|
+
if pet_state is None:
|
|
51
|
+
pet_state = PetState()
|
|
52
|
+
session.add(pet_state)
|
|
53
|
+
session.flush()
|
|
54
|
+
return pet_state
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _to_stats(pet_state: PetState) -> PetStats:
|
|
58
|
+
stats = PetStats()
|
|
59
|
+
stats.pet_name = pet_state.pet_name
|
|
60
|
+
stats.total_commits = pet_state.total_commits
|
|
61
|
+
stats.lines_added = pet_state.lines_added
|
|
62
|
+
stats.lines_deleted = pet_state.lines_deleted
|
|
63
|
+
stats.merge_conflicts = pet_state.merge_conflicts
|
|
64
|
+
stats.reverts = pet_state.reverts
|
|
65
|
+
stats.quality_score = pet_state.quality_score
|
|
66
|
+
stats.evolution_points = pet_state.evolution_points
|
|
67
|
+
stats.friend_level = pet_state.friend_level
|
|
68
|
+
stats.last_fed = pet_state.last_fed
|
|
69
|
+
stats.last_visit = pet_state.last_visit
|
|
70
|
+
stats.current_mood = PetMood(pet_state.current_mood)
|
|
71
|
+
stats.current_form = PetForm(pet_state.current_form)
|
|
72
|
+
stats.created_at = pet_state.created_at
|
|
73
|
+
stats.ai_commits = pet_state.ai_commits or 0
|
|
74
|
+
return stats
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _write(pet_state: PetState, stats: PetStats) -> None:
|
|
78
|
+
pet_state.pet_name = stats.pet_name
|
|
79
|
+
pet_state.total_commits = stats.total_commits
|
|
80
|
+
pet_state.lines_added = stats.lines_added
|
|
81
|
+
pet_state.lines_deleted = stats.lines_deleted
|
|
82
|
+
pet_state.merge_conflicts = stats.merge_conflicts
|
|
83
|
+
pet_state.reverts = stats.reverts
|
|
84
|
+
pet_state.quality_score = stats.quality_score
|
|
85
|
+
pet_state.evolution_points = stats.evolution_points
|
|
86
|
+
pet_state.friend_level = stats.friend_level
|
|
87
|
+
pet_state.ai_commits = stats.ai_commits
|
|
88
|
+
pet_state.created_at = stats.created_at
|
|
89
|
+
pet_state.last_fed = stats.last_fed
|
|
90
|
+
pet_state.last_visit = stats.last_visit
|
|
91
|
+
pet_state.current_mood = stats.current_mood.value
|
|
92
|
+
pet_state.current_form = stats.current_form.value
|
|
93
|
+
pet_state.updated_at = datetime.now()
|
|
94
|
+
|
|
95
|
+
def get_pet_state(self) -> PetStats:
|
|
96
|
+
"""Load current pet state from database.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
PetStats instance with current state
|
|
100
|
+
"""
|
|
101
|
+
with Session(self.engine) as session:
|
|
102
|
+
stats = self._to_stats(self._row(session))
|
|
103
|
+
session.commit()
|
|
104
|
+
|
|
105
|
+
stats.update_mood()
|
|
106
|
+
return stats
|
|
107
|
+
|
|
108
|
+
def save_pet_state(self, stats: PetStats) -> None:
|
|
109
|
+
"""Save pet state to database.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
stats: PetStats instance to persist
|
|
113
|
+
"""
|
|
114
|
+
with Session(self.engine) as session:
|
|
115
|
+
self._write(self._row(session), stats)
|
|
116
|
+
session.commit()
|
|
117
|
+
|
|
118
|
+
def has_commit(self, commit_hash: str) -> bool:
|
|
119
|
+
"""Check whether a commit has already been processed."""
|
|
120
|
+
with Session(self.engine) as session:
|
|
121
|
+
return (
|
|
122
|
+
session.query(CommitHistory)
|
|
123
|
+
.filter_by(commit_hash=commit_hash)
|
|
124
|
+
.first()
|
|
125
|
+
is not None
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def reset(self) -> None:
|
|
129
|
+
"""Start the pet over from a pumpkin, keeping its name."""
|
|
130
|
+
name = self.get_pet_state().pet_name
|
|
131
|
+
with Session(self.engine) as session:
|
|
132
|
+
session.query(CommitHistory).delete()
|
|
133
|
+
session.query(StoryMemory).delete()
|
|
134
|
+
session.query(PetState).delete()
|
|
135
|
+
session.add(PetState(pet_name=name))
|
|
136
|
+
session.commit()
|
|
137
|
+
|
|
138
|
+
def replay_history(self, commits: Iterable["HistoryCommit"]) -> "Replay":
|
|
139
|
+
"""Feed a batch of past commits (oldest first) in one transaction."""
|
|
140
|
+
rows, agents = [], Counter()
|
|
141
|
+
first: Optional[datetime] = None
|
|
142
|
+
with Session(self.engine) as session:
|
|
143
|
+
pet_state = self._row(session)
|
|
144
|
+
stats = self._to_stats(pet_state)
|
|
145
|
+
was_new = stats.total_commits == 0
|
|
146
|
+
last_fed = stats.last_fed
|
|
147
|
+
known = {row[0] for row in session.query(CommitHistory.commit_hash)}
|
|
148
|
+
|
|
149
|
+
for c in commits:
|
|
150
|
+
if c.hexsha in known:
|
|
151
|
+
continue
|
|
152
|
+
known.add(c.hexsha)
|
|
153
|
+
agent = detect_agent(c.message, c.author_name, c.author_email)
|
|
154
|
+
impact = stats.eat_commit(
|
|
155
|
+
c.message, c.lines_added, c.lines_deleted, c.is_merge, c.is_revert,
|
|
156
|
+
c.files, agent, when=c.timestamp,
|
|
157
|
+
)
|
|
158
|
+
if agent:
|
|
159
|
+
agents[agent] += 1
|
|
160
|
+
first = first or c.timestamp
|
|
161
|
+
rows.append(
|
|
162
|
+
CommitHistory(
|
|
163
|
+
commit_hash=c.hexsha,
|
|
164
|
+
author=f"{c.author_name} <{c.author_email}>",
|
|
165
|
+
message=c.message,
|
|
166
|
+
timestamp=c.timestamp,
|
|
167
|
+
lines_added=c.lines_added,
|
|
168
|
+
lines_deleted=c.lines_deleted,
|
|
169
|
+
files_changed=len(c.files),
|
|
170
|
+
is_merge=c.is_merge,
|
|
171
|
+
is_revert=c.is_revert,
|
|
172
|
+
quality_impact=impact,
|
|
173
|
+
agent=agent,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not was_new:
|
|
178
|
+
# Old history mustn't make a pet that was fed today look starved
|
|
179
|
+
stats.last_fed = max(stats.last_fed, last_fed)
|
|
180
|
+
elif first:
|
|
181
|
+
stats.created_at = first
|
|
182
|
+
session.add_all(rows)
|
|
183
|
+
self._write(pet_state, stats)
|
|
184
|
+
session.commit()
|
|
185
|
+
|
|
186
|
+
stats.update_mood()
|
|
187
|
+
return Replay(stats=stats, count=len(rows), agents=agents, since=first)
|
|
188
|
+
|
|
189
|
+
def agent_counts(self) -> Counter:
|
|
190
|
+
"""How many tracked commits each AI agent wrote."""
|
|
191
|
+
with Session(self.engine) as session:
|
|
192
|
+
rows = (
|
|
193
|
+
session.query(CommitHistory.agent, func.count())
|
|
194
|
+
.filter(CommitHistory.agent.isnot(None))
|
|
195
|
+
.group_by(CommitHistory.agent)
|
|
196
|
+
.all()
|
|
197
|
+
)
|
|
198
|
+
return Counter(dict(rows))
|
|
199
|
+
|
|
200
|
+
def get_recent_events(self, limit: int = 6) -> List[CommitHistory]:
|
|
201
|
+
"""Most recent tracked commits, newest first."""
|
|
202
|
+
with Session(self.engine, expire_on_commit=False) as session:
|
|
203
|
+
return (
|
|
204
|
+
session.query(CommitHistory)
|
|
205
|
+
.order_by(CommitHistory.processed_at.desc(), CommitHistory.id.desc())
|
|
206
|
+
.limit(limit)
|
|
207
|
+
.all()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def replace_commit(self, old_hash: str, new_hash: str, message: str) -> bool:
|
|
211
|
+
"""Point a tracked commit at its amended version.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
False if the old commit was never tracked
|
|
215
|
+
"""
|
|
216
|
+
with Session(self.engine) as session:
|
|
217
|
+
row = session.query(CommitHistory).filter_by(commit_hash=old_hash).first()
|
|
218
|
+
if row is None:
|
|
219
|
+
return False
|
|
220
|
+
row.commit_hash = new_hash
|
|
221
|
+
row.message = message
|
|
222
|
+
session.commit()
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
def get_recent_commits(self, limit: int = 5) -> List[str]:
|
|
226
|
+
"""Most recent tracked commit messages, newest first."""
|
|
227
|
+
with Session(self.engine) as session:
|
|
228
|
+
rows = (
|
|
229
|
+
session.query(CommitHistory.message)
|
|
230
|
+
.order_by(CommitHistory.timestamp.desc())
|
|
231
|
+
.limit(limit)
|
|
232
|
+
.all()
|
|
233
|
+
)
|
|
234
|
+
return [row.message for row in rows]
|
|
235
|
+
|
|
236
|
+
def process_commit_event(
|
|
237
|
+
self,
|
|
238
|
+
commit_hash: str,
|
|
239
|
+
author: str,
|
|
240
|
+
message: str,
|
|
241
|
+
timestamp: datetime,
|
|
242
|
+
lines_added: int = 0,
|
|
243
|
+
lines_deleted: int = 0,
|
|
244
|
+
files_changed: int = 0,
|
|
245
|
+
is_merge: bool = False,
|
|
246
|
+
is_revert: bool = False,
|
|
247
|
+
files: Sequence[str] = (),
|
|
248
|
+
agent: Optional[str] = None,
|
|
249
|
+
) -> PetStats:
|
|
250
|
+
"""Process a commit event and update pet state.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
commit_hash: Git commit SHA
|
|
254
|
+
author: Commit author
|
|
255
|
+
message: Commit message
|
|
256
|
+
timestamp: Commit timestamp
|
|
257
|
+
lines_added: Lines added in commit
|
|
258
|
+
lines_deleted: Lines deleted in commit
|
|
259
|
+
files_changed: Number of files changed
|
|
260
|
+
is_merge: Whether this is a merge commit
|
|
261
|
+
is_revert: Whether this is a revert commit
|
|
262
|
+
files: Paths touched by the commit
|
|
263
|
+
agent: AI agent that wrote the commit, if any
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Updated PetStats
|
|
267
|
+
"""
|
|
268
|
+
with Session(self.engine) as session:
|
|
269
|
+
pet_state = self._row(session)
|
|
270
|
+
stats = self._to_stats(pet_state)
|
|
271
|
+
|
|
272
|
+
# Checked inside the same locked transaction, so the hook and
|
|
273
|
+
# haunt can't both count a commit
|
|
274
|
+
seen = session.query(CommitHistory.id).filter_by(commit_hash=commit_hash).first()
|
|
275
|
+
if seen:
|
|
276
|
+
session.commit()
|
|
277
|
+
stats.update_mood()
|
|
278
|
+
return stats
|
|
279
|
+
|
|
280
|
+
quality_impact = stats.eat_commit(
|
|
281
|
+
message, lines_added, lines_deleted, is_merge, is_revert, files, agent
|
|
282
|
+
)
|
|
283
|
+
session.add(
|
|
284
|
+
CommitHistory(
|
|
285
|
+
commit_hash=commit_hash,
|
|
286
|
+
author=author,
|
|
287
|
+
message=message,
|
|
288
|
+
timestamp=timestamp,
|
|
289
|
+
lines_added=lines_added,
|
|
290
|
+
lines_deleted=lines_deleted,
|
|
291
|
+
files_changed=files_changed,
|
|
292
|
+
is_merge=is_merge,
|
|
293
|
+
is_revert=is_revert,
|
|
294
|
+
quality_impact=quality_impact,
|
|
295
|
+
agent=agent,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
self._write(pet_state, stats)
|
|
299
|
+
session.commit()
|
|
300
|
+
|
|
301
|
+
return stats
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class Replay:
|
|
306
|
+
"""Result of replaying history."""
|
|
307
|
+
|
|
308
|
+
stats: PetStats
|
|
309
|
+
count: int
|
|
310
|
+
agents: Counter
|
|
311
|
+
since: Optional[datetime]
|
|
@@ -5,7 +5,6 @@ import shutil
|
|
|
5
5
|
import subprocess
|
|
6
6
|
import tempfile
|
|
7
7
|
import threading
|
|
8
|
-
import time
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
11
10
|
from git import Repo
|
|
@@ -43,6 +42,8 @@ def run_demo(interval: float = 2.8, fps: int = 12) -> None:
|
|
|
43
42
|
root = Path(tempfile.mkdtemp(prefix="gitgotchi-demo-"))
|
|
44
43
|
cwd = os.getcwd()
|
|
45
44
|
stop = threading.Event()
|
|
45
|
+
state = None
|
|
46
|
+
worker = None
|
|
46
47
|
try:
|
|
47
48
|
repo = Repo.init(root)
|
|
48
49
|
with repo.config_writer() as cw:
|
|
@@ -63,10 +64,14 @@ def run_demo(interval: float = 2.8, fps: int = 12) -> None:
|
|
|
63
64
|
if stop.wait(interval):
|
|
64
65
|
return
|
|
65
66
|
|
|
66
|
-
threading.Thread(target=committer, daemon=True)
|
|
67
|
+
worker = threading.Thread(target=committer, daemon=True)
|
|
68
|
+
worker.start()
|
|
67
69
|
haunt(Repo(root), state, fps=fps, grace=0.2)
|
|
68
70
|
finally:
|
|
69
71
|
stop.set()
|
|
70
|
-
|
|
72
|
+
if worker:
|
|
73
|
+
worker.join(timeout=10) # let a commit in progress finish
|
|
74
|
+
if state:
|
|
75
|
+
state.close() # Windows can't delete an open database
|
|
71
76
|
os.chdir(cwd)
|
|
72
77
|
shutil.rmtree(root, ignore_errors=True)
|