overcode 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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
overcode/tui_helpers.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure helper functions for TUI rendering.
|
|
3
|
+
|
|
4
|
+
These functions are extracted for testability - they perform
|
|
5
|
+
formatting and calculations without requiring Textual or other
|
|
6
|
+
UI dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
import statistics
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from .status_constants import (
|
|
15
|
+
get_status_symbol as _get_status_symbol,
|
|
16
|
+
get_status_color as _get_status_color,
|
|
17
|
+
get_agent_timeline_char as _get_agent_timeline_char,
|
|
18
|
+
get_presence_timeline_char as _get_presence_timeline_char,
|
|
19
|
+
get_presence_color as _get_presence_color,
|
|
20
|
+
get_daemon_status_style as _get_daemon_status_style,
|
|
21
|
+
STATUS_RUNNING,
|
|
22
|
+
STATUS_TERMINATED,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_interval(seconds: int) -> str:
|
|
27
|
+
"""Format integer interval to human readable (s/m/h) without decimals.
|
|
28
|
+
|
|
29
|
+
Use for displaying fixed intervals like polling rates: "@30s", "@1m"
|
|
30
|
+
For durations with precision (e.g., work times), use format_duration().
|
|
31
|
+
|
|
32
|
+
Examples: 30 -> "30s", 60 -> "1m", 3600 -> "1h"
|
|
33
|
+
"""
|
|
34
|
+
if seconds < 60:
|
|
35
|
+
return f"{seconds}s"
|
|
36
|
+
elif seconds < 3600:
|
|
37
|
+
return f"{seconds // 60}m"
|
|
38
|
+
else:
|
|
39
|
+
return f"{seconds // 3600}h"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_ago(dt: Optional[datetime], now: Optional[datetime] = None) -> str:
|
|
43
|
+
"""Format datetime as time ago string.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
dt: The datetime to format
|
|
47
|
+
now: Reference time (defaults to datetime.now())
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
String like "30s ago", "5m ago", "2.5h ago", or "never"
|
|
51
|
+
"""
|
|
52
|
+
if not dt:
|
|
53
|
+
return "never"
|
|
54
|
+
if now is None:
|
|
55
|
+
now = datetime.now()
|
|
56
|
+
delta = (now - dt).total_seconds()
|
|
57
|
+
if delta < 60:
|
|
58
|
+
return f"{int(delta)}s ago"
|
|
59
|
+
elif delta < 3600:
|
|
60
|
+
return f"{int(delta // 60)}m ago"
|
|
61
|
+
else:
|
|
62
|
+
return f"{delta / 3600:.1f}h ago"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_duration(seconds: float) -> str:
|
|
66
|
+
"""Format duration in seconds to human readable (s/m/h/d).
|
|
67
|
+
|
|
68
|
+
Shows one decimal place for all units except seconds.
|
|
69
|
+
Examples: 45s, 6.3m, 2.5h, 1.2d
|
|
70
|
+
"""
|
|
71
|
+
if seconds < 60:
|
|
72
|
+
return f"{int(seconds)}s"
|
|
73
|
+
elif seconds < 3600:
|
|
74
|
+
minutes = seconds / 60
|
|
75
|
+
return f"{minutes:.1f}m"
|
|
76
|
+
elif seconds < 86400: # Less than 1 day
|
|
77
|
+
return f"{seconds/3600:.1f}h"
|
|
78
|
+
else:
|
|
79
|
+
return f"{seconds/86400:.1f}d"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_tokens(tokens: int) -> str:
|
|
83
|
+
"""Format token count to human readable (K/M).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
tokens: Number of tokens
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Formatted string like "1.2K", "3.5M", or "500" for small counts
|
|
90
|
+
"""
|
|
91
|
+
if tokens >= 1_000_000:
|
|
92
|
+
return f"{tokens / 1_000_000:.1f}M"
|
|
93
|
+
elif tokens >= 1_000:
|
|
94
|
+
return f"{tokens / 1_000:.1f}K"
|
|
95
|
+
else:
|
|
96
|
+
return str(tokens)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def calculate_uptime(start_time: str, now: Optional[datetime] = None) -> str:
|
|
100
|
+
"""Calculate uptime from ISO format start_time.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
start_time: ISO format datetime string
|
|
104
|
+
now: Reference time (defaults to datetime.now())
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
String like "30m", "4.5h", "2.5d", or "0m" on error
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
if now is None:
|
|
111
|
+
now = datetime.now()
|
|
112
|
+
start = datetime.fromisoformat(start_time)
|
|
113
|
+
delta = now - start
|
|
114
|
+
hours = delta.total_seconds() / 3600
|
|
115
|
+
if hours < 1:
|
|
116
|
+
minutes = delta.total_seconds() / 60
|
|
117
|
+
return f"{int(minutes)}m"
|
|
118
|
+
elif hours < 24:
|
|
119
|
+
return f"{hours:.1f}h"
|
|
120
|
+
else:
|
|
121
|
+
days = hours / 24
|
|
122
|
+
return f"{days:.1f}d"
|
|
123
|
+
except (ValueError, AttributeError, TypeError):
|
|
124
|
+
return "0m"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def calculate_percentiles(times: List[float]) -> Tuple[float, float, float]:
|
|
128
|
+
"""Calculate mean, 5th, and 95th percentile of operation times.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
times: List of operation times in seconds
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Tuple of (mean, p5, p95)
|
|
135
|
+
"""
|
|
136
|
+
if not times:
|
|
137
|
+
return 0.0, 0.0, 0.0
|
|
138
|
+
|
|
139
|
+
mean_time = statistics.mean(times)
|
|
140
|
+
|
|
141
|
+
if len(times) < 2:
|
|
142
|
+
return mean_time, mean_time, mean_time
|
|
143
|
+
|
|
144
|
+
sorted_times = sorted(times)
|
|
145
|
+
p5_idx = int(len(sorted_times) * 0.05)
|
|
146
|
+
p95_idx = int(len(sorted_times) * 0.95)
|
|
147
|
+
p5 = sorted_times[p5_idx]
|
|
148
|
+
p95 = sorted_times[p95_idx]
|
|
149
|
+
|
|
150
|
+
return mean_time, p5, p95
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def presence_state_to_char(state: int) -> str:
|
|
154
|
+
"""Convert presence state to timeline character.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
state: 1=locked/sleep, 2=inactive, 3=active
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Block character for timeline visualization
|
|
161
|
+
"""
|
|
162
|
+
return _get_presence_timeline_char(state)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def agent_status_to_char(status: str) -> str:
|
|
166
|
+
"""Convert agent status to timeline character.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
status: One of running, no_instructions, waiting_supervisor, waiting_user
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Block character for timeline visualization
|
|
173
|
+
"""
|
|
174
|
+
return _get_agent_timeline_char(status)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def status_to_color(status: str) -> str:
|
|
178
|
+
"""Map agent status to display color name.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
status: Agent status string
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Color name for Rich styling
|
|
185
|
+
"""
|
|
186
|
+
return _get_status_color(status)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_standing_orders_indicator(session) -> str:
|
|
190
|
+
"""Get standing orders display indicator.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
session: Session object with standing_instructions and standing_orders_complete
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Emoji indicator: "➖" (none), "📋" (active), "✓" (complete)
|
|
197
|
+
"""
|
|
198
|
+
if not session.standing_instructions:
|
|
199
|
+
return "➖"
|
|
200
|
+
elif session.standing_orders_complete:
|
|
201
|
+
return "✓"
|
|
202
|
+
else:
|
|
203
|
+
return "📋"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[float, float]:
|
|
207
|
+
"""Get current green and non-green times including ongoing state.
|
|
208
|
+
|
|
209
|
+
Adds the time elapsed since the last daemon accumulation to the accumulated times.
|
|
210
|
+
This provides real-time updates between daemon polling cycles.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
stats: SessionStats object with green_time_seconds, non_green_time_seconds,
|
|
214
|
+
last_time_accumulation, and current_state
|
|
215
|
+
now: Reference time (defaults to datetime.now())
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (green_time, non_green_time) in seconds
|
|
219
|
+
"""
|
|
220
|
+
if now is None:
|
|
221
|
+
now = datetime.now()
|
|
222
|
+
|
|
223
|
+
green_time = stats.green_time_seconds
|
|
224
|
+
non_green_time = stats.non_green_time_seconds
|
|
225
|
+
|
|
226
|
+
# Add elapsed time since the daemon last accumulated times
|
|
227
|
+
# Use last_time_accumulation (when daemon last updated), NOT state_since (when state started)
|
|
228
|
+
# This prevents double-counting: daemon already accumulated time up to last_time_accumulation
|
|
229
|
+
time_anchor = stats.last_time_accumulation or stats.state_since
|
|
230
|
+
if time_anchor:
|
|
231
|
+
try:
|
|
232
|
+
anchor_time = datetime.fromisoformat(time_anchor)
|
|
233
|
+
current_elapsed = (now - anchor_time).total_seconds()
|
|
234
|
+
|
|
235
|
+
# Only add positive elapsed time
|
|
236
|
+
if current_elapsed > 0:
|
|
237
|
+
if stats.current_state == STATUS_RUNNING:
|
|
238
|
+
green_time += current_elapsed
|
|
239
|
+
elif stats.current_state != STATUS_TERMINATED:
|
|
240
|
+
# Only count non-green time for non-terminated states
|
|
241
|
+
non_green_time += current_elapsed
|
|
242
|
+
# else: terminated state - time is frozen, don't accumulate
|
|
243
|
+
except (ValueError, AttributeError, TypeError):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
return green_time, non_green_time
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_timeline_slots(
|
|
250
|
+
history: list,
|
|
251
|
+
width: int,
|
|
252
|
+
hours: float,
|
|
253
|
+
now: Optional[datetime] = None
|
|
254
|
+
) -> dict:
|
|
255
|
+
"""Build a dictionary mapping slot indices to states from history data.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
history: List of (timestamp, state) tuples
|
|
259
|
+
width: Number of slots in the timeline
|
|
260
|
+
hours: Number of hours the timeline covers
|
|
261
|
+
now: Reference time (defaults to datetime.now())
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dict mapping slot index to state value
|
|
265
|
+
"""
|
|
266
|
+
if now is None:
|
|
267
|
+
now = datetime.now()
|
|
268
|
+
|
|
269
|
+
if not history:
|
|
270
|
+
return {}
|
|
271
|
+
|
|
272
|
+
start_time = now - timedelta(hours=hours)
|
|
273
|
+
slot_duration_sec = (hours * 3600) / width
|
|
274
|
+
slot_states = {}
|
|
275
|
+
|
|
276
|
+
for ts, state in history:
|
|
277
|
+
if ts < start_time:
|
|
278
|
+
continue
|
|
279
|
+
elapsed = (ts - start_time).total_seconds()
|
|
280
|
+
slot_idx = int(elapsed / slot_duration_sec)
|
|
281
|
+
if 0 <= slot_idx < width:
|
|
282
|
+
slot_states[slot_idx] = state
|
|
283
|
+
|
|
284
|
+
return slot_states
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_timeline_string(
|
|
288
|
+
slot_states: dict,
|
|
289
|
+
width: int,
|
|
290
|
+
state_to_char: callable
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Build a timeline string from slot states.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
slot_states: Dict mapping slot index to state
|
|
296
|
+
width: Number of characters in timeline
|
|
297
|
+
state_to_char: Function to convert state to display character
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
String of width characters representing the timeline
|
|
301
|
+
"""
|
|
302
|
+
timeline = []
|
|
303
|
+
for i in range(width):
|
|
304
|
+
if i in slot_states:
|
|
305
|
+
timeline.append(state_to_char(slot_states[i]))
|
|
306
|
+
else:
|
|
307
|
+
timeline.append("─")
|
|
308
|
+
return "".join(timeline)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_status_symbol(status: str) -> Tuple[str, str]:
|
|
312
|
+
"""Get status emoji and base style for agent status.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
status: Agent status string
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Tuple of (emoji, color) for the status
|
|
319
|
+
"""
|
|
320
|
+
return _get_status_symbol(status)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def get_presence_color(state: int) -> str:
|
|
324
|
+
"""Get color for presence state.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
state: Presence state (1=locked/sleep, 2=inactive, 3=active)
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Color name for Rich styling
|
|
331
|
+
"""
|
|
332
|
+
return _get_presence_color(state)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_agent_timeline_color(status: str) -> str:
|
|
336
|
+
"""Get color for agent status in timeline.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
status: Agent status string
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Color name for Rich styling
|
|
343
|
+
"""
|
|
344
|
+
return _get_status_color(status)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def style_pane_line(line: str) -> Tuple[str, str]:
|
|
348
|
+
"""Determine styling for a pane content line.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
line: The line content to style
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Tuple of (prefix_style, content_style) color names
|
|
355
|
+
"""
|
|
356
|
+
if line.startswith('✓') or 'success' in line.lower():
|
|
357
|
+
return ("bold green", "green")
|
|
358
|
+
elif line.startswith('✗') or 'error' in line.lower() or 'fail' in line.lower():
|
|
359
|
+
return ("bold red", "red")
|
|
360
|
+
elif line.startswith('>') or line.startswith('$') or line.startswith('❯'):
|
|
361
|
+
return ("bold cyan", "bold white")
|
|
362
|
+
else:
|
|
363
|
+
return ("cyan", "white") # Punchier bar color
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def truncate_name(name: str, max_len: int = 14) -> str:
|
|
367
|
+
"""Truncate and pad name for display.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
name: Name to truncate
|
|
371
|
+
max_len: Maximum length (default 14 for timeline view)
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Name truncated and left-justified to max_len
|
|
375
|
+
"""
|
|
376
|
+
return name[:max_len].ljust(max_len)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_daemon_status_style(status: str) -> Tuple[str, str]:
|
|
380
|
+
"""Get symbol and style for daemon status.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
status: Daemon status string
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Tuple of (symbol, style) for display
|
|
387
|
+
"""
|
|
388
|
+
return _get_daemon_status_style(status)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def calculate_safe_break_duration(sessions: list, now: Optional[datetime] = None) -> Optional[float]:
|
|
392
|
+
"""Calculate how long you can be AFK before 50%+ of agents need attention.
|
|
393
|
+
|
|
394
|
+
For each running agent:
|
|
395
|
+
- Get their median work time (p50 autonomous operation time)
|
|
396
|
+
- Subtract time already spent in current running state
|
|
397
|
+
- That gives expected time until they need attention
|
|
398
|
+
|
|
399
|
+
Returns the duration (in seconds) until 50%+ of agents will turn red,
|
|
400
|
+
or None if no running agents or insufficient data.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
sessions: List of SessionDaemonState objects
|
|
404
|
+
now: Reference time (defaults to datetime.now())
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Safe break duration in seconds, or None if cannot calculate
|
|
408
|
+
"""
|
|
409
|
+
if now is None:
|
|
410
|
+
now = datetime.now()
|
|
411
|
+
|
|
412
|
+
# Get running agents with valid median work times
|
|
413
|
+
time_until_attention = []
|
|
414
|
+
for s in sessions:
|
|
415
|
+
# Only consider running agents
|
|
416
|
+
if s.current_status != "running":
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# Need median work time data
|
|
420
|
+
if s.median_work_time <= 0:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# Calculate time in current state
|
|
424
|
+
time_in_state = 0.0
|
|
425
|
+
if s.status_since:
|
|
426
|
+
try:
|
|
427
|
+
state_start = datetime.fromisoformat(s.status_since)
|
|
428
|
+
time_in_state = (now - state_start).total_seconds()
|
|
429
|
+
except (ValueError, TypeError):
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
# Expected time until needing attention
|
|
433
|
+
remaining = s.median_work_time - time_in_state
|
|
434
|
+
# If already past median, they could need attention any moment (0 remaining)
|
|
435
|
+
time_until_attention.append(max(0, remaining))
|
|
436
|
+
|
|
437
|
+
if not time_until_attention:
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
# Sort by time until attention
|
|
441
|
+
time_until_attention.sort()
|
|
442
|
+
|
|
443
|
+
# Find when 50%+ will need attention
|
|
444
|
+
# If we have N agents, we need to find when ceil(N/2) have turned red
|
|
445
|
+
half_point = (len(time_until_attention) + 1) // 2
|
|
446
|
+
return time_until_attention[half_point - 1]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def get_git_diff_stats(directory: str) -> Optional[Tuple[int, int, int]]:
|
|
450
|
+
"""Get git diff stats for a directory.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
directory: Path to the git repository
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Tuple of (files_changed, insertions, deletions) or None if not a git repo
|
|
457
|
+
"""
|
|
458
|
+
try:
|
|
459
|
+
result = subprocess.run(
|
|
460
|
+
["git", "diff", "--stat", "HEAD"],
|
|
461
|
+
cwd=directory,
|
|
462
|
+
capture_output=True,
|
|
463
|
+
text=True,
|
|
464
|
+
timeout=2,
|
|
465
|
+
)
|
|
466
|
+
if result.returncode != 0:
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
# Parse the last line which looks like:
|
|
470
|
+
# "3 files changed, 10 insertions(+), 5 deletions(-)"
|
|
471
|
+
# or just "1 file changed, 2 insertions(+)"
|
|
472
|
+
lines = result.stdout.strip().split('\n')
|
|
473
|
+
if not lines or not lines[-1]:
|
|
474
|
+
return (0, 0, 0) # No changes
|
|
475
|
+
|
|
476
|
+
summary = lines[-1]
|
|
477
|
+
files = 0
|
|
478
|
+
insertions = 0
|
|
479
|
+
deletions = 0
|
|
480
|
+
|
|
481
|
+
import re
|
|
482
|
+
files_match = re.search(r'(\d+) files? changed', summary)
|
|
483
|
+
ins_match = re.search(r'(\d+) insertions?', summary)
|
|
484
|
+
del_match = re.search(r'(\d+) deletions?', summary)
|
|
485
|
+
|
|
486
|
+
if files_match:
|
|
487
|
+
files = int(files_match.group(1))
|
|
488
|
+
if ins_match:
|
|
489
|
+
insertions = int(ins_match.group(1))
|
|
490
|
+
if del_match:
|
|
491
|
+
deletions = int(del_match.group(1))
|
|
492
|
+
|
|
493
|
+
return (files, insertions, deletions)
|
|
494
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
495
|
+
return None
|