deadpush 0.2.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.
deadpush/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
deadpush/churn.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ Git Churn Analytics — detects files that are being rewritten excessively.
3
+
4
+ In vibe coding sessions, AI agents frequently rewrite the same files multiple
5
+ times in slightly different ways, creating instability. High churn is a strong
6
+ signal that a file is being "thrashed" and needs architectural attention.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ import time
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ CHURN_CACHE_FILE = Path.home() / ".deadpush" / "churn_cache.json"
22
+ CHURN_CACHE_MAX_AGE = 3600
23
+
24
+
25
+ @dataclass
26
+ class FileChurn:
27
+ """Churn metrics for a single file."""
28
+ path: str
29
+ commit_count: int
30
+ author_count: int
31
+ first_commit: str
32
+ last_commit: str
33
+ churn_score: float # 0-1 normalized
34
+ flag_reason: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class ChurnReport:
39
+ """Complete churn analysis report."""
40
+ total_files_analyzed: int
41
+ high_churn_files: list[FileChurn] = field(default_factory=list)
42
+ total_commits_in_window: int = 0
43
+ generated_at: str = ""
44
+
45
+
46
+ class ChurnAnalyzer:
47
+ """Analyzes git churn for a repository."""
48
+
49
+ def __init__(self, repo_root: Path, window_days: int = 30):
50
+ self.repo_root = repo_root
51
+ self.window_days = window_days
52
+ self._cache: dict[str, Any] = {}
53
+ self._load_cache()
54
+
55
+ def _load_cache(self):
56
+ if CHURN_CACHE_FILE.exists():
57
+ try:
58
+ data = json.loads(CHURN_CACHE_FILE.read_text(encoding="utf-8"))
59
+ now = time.time()
60
+ self._cache = {
61
+ k: v for k, v in data.items()
62
+ if now - v.get("cached_at", 0) < CHURN_CACHE_MAX_AGE
63
+ }
64
+ except Exception:
65
+ self._cache = {}
66
+
67
+ def _save_cache(self, key: str, data: Any):
68
+ try:
69
+ CHURN_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
70
+ self._cache[key] = {"data": data, "cached_at": time.time()}
71
+ CHURN_CACHE_FILE.write_text(
72
+ json.dumps(self._cache, indent=2, default=str),
73
+ encoding="utf-8",
74
+ )
75
+ except Exception:
76
+ pass
77
+
78
+ def _run_git_log(self) -> list[dict[str, Any]]:
79
+ """Run git log and return per-file commit stats."""
80
+ cache_key = f"churn_{self.repo_root}_{self.window_days}"
81
+ if cache_key in self._cache:
82
+ return self._cache[cache_key]["data"]
83
+
84
+ since = f"--since={self.window_days}.days"
85
+ try:
86
+ result = subprocess.run(
87
+ ["git", "log", "--name-only", "--pretty=format:%H|%an|%ai", since],
88
+ capture_output=True, text=True, timeout=30,
89
+ cwd=self.repo_root,
90
+ )
91
+ if result.returncode != 0:
92
+ return []
93
+ self._save_cache(cache_key, result.stdout)
94
+ return self._parse_git_log(result.stdout)
95
+ except Exception:
96
+ return []
97
+
98
+ def _parse_git_log(self, output: str) -> list[dict[str, Any]]:
99
+ """Parse git log --name-only output into structured records."""
100
+ records: list[dict[str, Any]] = []
101
+ current_commit: dict[str, Any] | None = None
102
+
103
+ for line in output.splitlines():
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ if "|" in line and len(line.split("|")) == 3:
108
+ parts = line.split("|")
109
+ current_commit = {
110
+ "hash": parts[0],
111
+ "author": parts[1],
112
+ "date": parts[2],
113
+ "files": [],
114
+ }
115
+ records.append(current_commit)
116
+ elif current_commit is not None and line:
117
+ current_commit["files"].append(line)
118
+
119
+ return records
120
+
121
+ def analyze(self) -> ChurnReport:
122
+ """Run churn analysis on the repository."""
123
+ commits = self._run_git_log()
124
+ if not commits:
125
+ return ChurnReport(total_files_analyzed=0)
126
+
127
+ # Per-file stats
128
+ file_stats: dict[str, dict[str, Any]] = {}
129
+ for commit in commits:
130
+ for filepath in commit.get("files", []):
131
+ if filepath not in file_stats:
132
+ file_stats[filepath] = {
133
+ "commit_count": 0,
134
+ "authors": set(),
135
+ "first_commit": commit["date"],
136
+ "last_commit": commit["date"],
137
+ }
138
+ stat = file_stats[filepath]
139
+ stat["commit_count"] += 1
140
+ stat["authors"].add(commit["author"])
141
+ if commit["date"] < stat["first_commit"]:
142
+ stat["first_commit"] = commit["date"]
143
+ if commit["date"] > stat["last_commit"]:
144
+ stat["last_commit"] = commit["date"]
145
+
146
+ # Compute churn scores
147
+ if not file_stats:
148
+ return ChurnReport(total_files_analyzed=0)
149
+
150
+ max_commits = max(s["commit_count"] for s in file_stats.values())
151
+ if max_commits == 0:
152
+ return ChurnReport(total_files_analyzed=len(file_stats))
153
+
154
+ high_churn: list[FileChurn] = []
155
+ for filepath, stat in file_stats.items():
156
+ # Normalize churn score: commits / max_commits in repo
157
+ raw_score = stat["commit_count"] / max_commits
158
+
159
+ # Boost score for files with many unique authors (many people touching = unstable)
160
+ author_boost = min(len(stat["authors"]) / 5.0, 0.3)
161
+ churn_score = min(raw_score + author_boost, 1.0)
162
+
163
+ flag_reason = None
164
+ if churn_score > 0.7:
165
+ flag_reason = f"Very high modification frequency ({stat['commit_count']} changes by {len(stat['authors'])} author(s) in {self.window_days} days)"
166
+ elif churn_score > 0.5:
167
+ flag_reason = f"Elevated modification frequency ({stat['commit_count']} changes in {self.window_days} days)"
168
+
169
+ fc = FileChurn(
170
+ path=filepath,
171
+ commit_count=stat["commit_count"],
172
+ author_count=len(stat["authors"]),
173
+ first_commit=stat["first_commit"],
174
+ last_commit=stat["last_commit"],
175
+ churn_score=round(churn_score, 3),
176
+ flag_reason=flag_reason,
177
+ )
178
+ if flag_reason:
179
+ high_churn.append(fc)
180
+
181
+ # Sort by churn score descending
182
+ high_churn.sort(key=lambda x: x.churn_score, reverse=True)
183
+
184
+ return ChurnReport(
185
+ total_files_analyzed=len(file_stats),
186
+ high_churn_files=high_churn,
187
+ total_commits_in_window=len(commits),
188
+ generated_at=datetime.now().isoformat(),
189
+ )