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.
Files changed (61) hide show
  1. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/CHANGELOG.md +26 -0
  2. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/MANIFEST.in +1 -0
  3. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/PKG-INFO +11 -6
  4. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/README.md +9 -4
  5. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/pyproject.toml +1 -1
  6. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/__init__.py +1 -1
  7. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/cli.py +28 -4
  8. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/db/models.py +27 -16
  9. gitgotchi-0.3.1/src/gitgotchi/db/state_manager.py +311 -0
  10. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/demo.py +8 -3
  11. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/haunt.py +87 -26
  12. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/history.py +39 -4
  13. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/installer.py +21 -7
  14. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/post_commit.py +21 -3
  15. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/paths.py +20 -20
  16. gitgotchi-0.3.1/src/gitgotchi/pet/possession.py +84 -0
  17. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/renderer.py +1 -1
  18. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/states.py +16 -13
  19. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/hauntings.py +45 -20
  20. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/PKG-INFO +11 -6
  21. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/SOURCES.txt +0 -1
  22. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/requires.txt +1 -1
  23. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_cli.py +26 -0
  24. gitgotchi-0.3.1/tests/test_haunt.py +130 -0
  25. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_hauntings.py +17 -0
  26. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_history.py +1 -1
  27. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_hooks.py +59 -0
  28. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_possession.py +13 -0
  29. gitgotchi-0.3.1/tests/test_state_manager.py +174 -0
  30. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_states.py +12 -0
  31. gitgotchi-0.3.0/src/gitgotchi/db/migrations.py +0 -100
  32. gitgotchi-0.3.0/src/gitgotchi/db/state_manager.py +0 -383
  33. gitgotchi-0.3.0/src/gitgotchi/pet/possession.py +0 -56
  34. gitgotchi-0.3.0/tests/test_haunt.py +0 -54
  35. gitgotchi-0.3.0/tests/test_state_manager.py +0 -73
  36. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/INSTALL.md +0 -0
  37. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/LICENSE +0 -0
  38. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/setup.cfg +0 -0
  39. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/__main__.py +0 -0
  40. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/db/__init__.py +0 -0
  41. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/hooks/__init__.py +0 -0
  42. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/__init__.py +0 -0
  43. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/quality.py +0 -0
  44. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/pet/theme.py +0 -0
  45. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/__init__.py +0 -0
  46. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/card.py +0 -0
  47. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/effects.py +0 -0
  48. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/font.py +0 -0
  49. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/forms.py +0 -0
  50. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/raster.py +0 -0
  51. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/scene.py +0 -0
  52. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/render/terminal.py +0 -0
  53. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/__init__.py +0 -0
  54. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi/seance/story_gen.py +0 -0
  55. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/dependency_links.txt +0 -0
  56. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/entry_points.txt +0 -0
  57. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/src/gitgotchi.egg-info/top_level.txt +0 -0
  58. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/conftest.py +0 -0
  59. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_quality.py +0 -0
  60. {gitgotchi-0.3.0 → gitgotchi-0.3.1}/tests/test_render.py +0 -0
  61. {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
@@ -7,3 +7,4 @@ global-exclude __pycache__
7
7
  global-exclude *.py[co]
8
8
  prune docs
9
9
  prune previews
10
+ prune site
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitgotchi
3
- Version: 0.3.0
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.12.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
  ![The haunt pane reacting to commits](https://raw.githubusercontent.com/Srikarmk/Gitgotchi/main/docs/haunt.gif)
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 is stored in `.gitgotchi/state.db` at the root of the repository. GitGotchi adds
162
- `.gitgotchi/` to `.git/info/exclude`, so it never shows up in `git status` and is never committed.
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
  ![The haunt pane reacting to commits](https://raw.githubusercontent.com/Srikarmk/Gitgotchi/main/docs/haunt.gif)
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 is stored in `.gitgotchi/state.db` at the root of the repository. GitGotchi adds
121
- `.gitgotchi/` to `.git/info/exclude`, so it never shows up in `git status` and is never committed.
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
 
@@ -44,7 +44,7 @@ dependencies = [
44
44
  "gitpython>=3.1.40",
45
45
  "anthropic>=1.0.0",
46
46
  "sqlalchemy>=2.0.36",
47
- "typer>=0.12.0",
47
+ "typer>=0.15.4",
48
48
  "python-dotenv>=1.0.0",
49
49
  "numpy>=1.24",
50
50
  ]
@@ -1,3 +1,3 @@
1
1
  """GitGotchi - A Halloween-inspired terminal companion for your git repository."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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 find_dotenv, load_dotenv
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
- load_dotenv(find_dotenv(usecwd=True))
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
- output.write_bytes(encode_png(upscale(image, max(1, min(12, scale)))))
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 = ".gitgotchi/state.db"):
76
- """Create database engine.
77
+ def get_engine(db_path: str) -> Engine:
78
+ """Create the database engine.
77
79
 
78
- Args:
79
- db_path: Path to SQLite database file
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
- return create_engine(f"sqlite:///{db_path}", echo=False)
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
- def init_db(db_path: str = ".gitgotchi/state.db") -> None:
88
- """Initialize database schema.
93
+ @event.listens_for(engine, "begin")
94
+ def _begin_immediate(conn):
95
+ conn.exec_driver_sql("BEGIN IMMEDIATE")
89
96
 
90
- Args:
91
- db_path: Path to SQLite database file
92
- """
93
- engine = get_engine(db_path)
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
- inspector = inspect(engine)
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).start()
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
- time.sleep(0.1)
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)