-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcommit-velocity.py
More file actions
173 lines (141 loc) · 5.43 KB
/
commit-velocity.py
File metadata and controls
173 lines (141 loc) · 5.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#!/usr/bin/env python3
"""commit-velocity.py — Project velocity as change per unit time.
For each non-merge commit, computes:
velocity = delta / capped_hours
where
delta = lines added + lines removed
capped_hours = min(hours_since_prev_commit, cap)
Intervals longer than the cap (default: 168 h / 1 week) are clamped,
so dormant periods don't dilute the metric.
Usage:
python3 commit-velocity.py [N] # last N commits (default 20)
python3 commit-velocity.py --all # full history
python3 commit-velocity.py --cap=48 # cap at 48 hours
python3 commit-velocity.py --author="Name"
python3 commit-velocity.py --commit=abc123 # single commit velocity
"""
import argparse
import statistics
import subprocess
import sys
def git(*args):
r = subprocess.run(["git"] + list(args),
capture_output=True, text=True)
return r.stdout.strip()
def get_commits(n, all_commits, author):
cmd = ["log", "--format=%H %at", "--no-merges"]
if author:
cmd += [f"--author={author}"]
if not all_commits:
cmd += [f"-n{n}"]
lines = git(*cmd).splitlines()
commits = []
for line in lines:
parts = line.split()
if len(parts) == 2:
commits.append((parts[0], int(parts[1])))
return commits
def diff_stat(parent, child):
lines = git("diff", "--numstat", parent, child).splitlines()
added = removed = 0
for line in lines:
parts = line.split()
if parts[0] == "-": # binary
continue
added += int(parts[0])
removed += int(parts[1])
return added, removed
def compute_velocity(commits, cap_hours):
cap_sec = cap_hours * 3600
rows = []
for i in range(len(commits) - 1):
sha, ts = commits[i]
prev_sha, prev_ts = commits[i + 1]
added, removed = diff_stat(prev_sha, sha)
delta = added + removed
gap_sec = ts - prev_ts
capped = min(gap_sec, cap_sec) if gap_sec > 0 else 0
raw_hours = gap_sec / 3600
capped_hours = capped / 3600
if capped > 0:
vel = delta / capped_hours
else:
vel = float("inf") if delta > 0 else 0.0
rows.append({
"sha": sha[:8],
"added": added,
"removed": removed,
"delta": delta,
"raw_hours": raw_hours,
"capped_hours": capped_hours,
"capped": gap_sec > cap_sec,
"velocity": vel,
})
return rows
def print_table(rows, cap_hours):
hdr = f"{'commit':<10} {'added':>7} {'removed':>7} {'delta':>7} " \
f"{'hours':>9} {'capped':>7} {'vel(l/h)':>10}"
sep = "-" * len(hdr)
print(hdr)
print(sep)
for r in rows:
cap_mark = f"[{cap_hours}]" if r["capped"] else f"{r['raw_hours']:.1f}"
vel_str = f"{r['velocity']:.1f}" if r["velocity"] != float("inf") else "inf"
print(f"{r['sha']:<10} {r['added']:>7} {r['removed']:>7} {r['delta']:>7} "
f"{cap_mark:>9} {('yes' if r['capped'] else ''):>7} {vel_str:>10}")
def print_summary(rows):
if not rows:
return
total_delta = sum(r["delta"] for r in rows)
total_capped = sum(r["capped_hours"] for r in rows)
n = len(rows)
velocities = [r["velocity"] for r in rows if r["velocity"] != float("inf")]
median_vel = statistics.median(velocities) if velocities else 0.0
print(f"\n--- Summary ({n} intervals) ---")
print(f" Total change (lines): {total_delta}")
print(f" Total time (capped, h): {total_capped:.1f}")
print(f" Median velocity: {median_vel:.1f} lines/hour")
if total_capped > 0:
print(f" Mean velocity: {total_delta / total_capped:.1f} lines/hour")
def single_commit(sha, cap_hours):
"""Velocity for a specific commit relative to its parent."""
full = git("rev-parse", sha)
if not full:
print(f"Commit {sha} not found.", file=sys.stderr)
sys.exit(1)
parent = git("rev-parse", f"{full}~1")
if not parent:
print(f"No parent for {sha} (initial commit?).", file=sys.stderr)
sys.exit(1)
ts = int(git("log", "-1", "--format=%at", full))
pts = int(git("log", "-1", "--format=%at", parent))
commits = [(full, ts), (parent, pts)]
rows = compute_velocity(commits, cap_hours)
print_table(rows, cap_hours)
return rows
def main():
parser = argparse.ArgumentParser(description="Commit velocity analysis")
parser.add_argument("n", nargs="?", type=int, default=20,
help="Number of recent commits (default: 20)")
parser.add_argument("--all", action="store_true",
help="Analyze all commits")
parser.add_argument("--cap", type=float, default=168,
help="Cap interval in hours (default: 168 = 1 week)")
parser.add_argument("--author", default="",
help="Filter by author name")
parser.add_argument("--commit", default="",
help="Show velocity for a single commit")
args = parser.parse_args()
if args.commit:
rows = single_commit(args.commit, args.cap)
print_summary(rows)
return
commits = get_commits(args.n, args.all, args.author)
if len(commits) < 2:
print("Need at least 2 non-merge commits.")
sys.exit(1)
rows = compute_velocity(commits, args.cap)
print_table(rows, args.cap)
print_summary(rows)
if __name__ == "__main__":
main()