git-who-cli 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Larsen Cundric
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # git-who
2
+
3
+ > Find who knows a file (or codebase) best. `git blame` + `git log` = actionable ownership insights.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g git-who-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ # Who knows this repo best?
15
+ git who
16
+
17
+ # Who knows a specific file best?
18
+ git who src/index.js
19
+
20
+ # Who knows a directory best?
21
+ git who src/
22
+
23
+ # Show top 10 contributors
24
+ git who -n 10
25
+ ```
26
+
27
+ ### Example output
28
+
29
+ ```
30
+ git who src/utils.js ──────────────────────────────
31
+
32
+ Last modified by Alice 2d ago (Wed, Mar 12, 2025)
33
+
34
+ Top contributors:
35
+
36
+ ★ Alice █████████████████████ 63% lines 47 commits 2d ago
37
+ 2 Bob ██████████ 31% lines 23 commits 1w ago
38
+ 3 Charlie ██ 6% lines 4 commits 3mo ago
39
+
40
+ → Ask Alice — they own 63% of lines and made 47 commits
41
+ or Bob (31% lines, 23 commits)
42
+ ```
43
+
44
+ ### Directory / repo-wide view
45
+
46
+ ```
47
+ git who . ──────────────────────────────────────────
48
+
49
+ Last modified by Alice 2d ago (Wed, Mar 12, 2025)
50
+
51
+ Top contributors:
52
+
53
+ ★ Alice ██████████████████ 54% commits (127) 2d ago
54
+ 2 Bob ████████████ 36% commits (85) 3d ago
55
+ 3 Charlie ███ 10% commits (23) 2w ago
56
+
57
+ → Ask Alice — 54% of commits (127 total)
58
+ or Bob (36%, 85 commits)
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - **File mode**: Uses `git blame` to find line ownership + `git log` for commit history
64
+ - **Directory mode**: Uses `git shortlog` for aggregate contributor stats
65
+ - **Smart ranking**: 60% line ownership + 40% commit frequency (file mode)
66
+ - **Beautiful output**: Color-coded bars, ranks, and "who to ask" suggestions
67
+ - **Fast**: Single-pass blame parsing, no external dependencies beyond git
68
+
69
+ ## Requirements
70
+
71
+ - Node.js >= 18
72
+ - Git installed and accessible on your PATH
73
+
74
+ ## License
75
+
76
+ MIT
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "git-who-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Find who knows a file best. git blame + git log = actionable ownership insights.",
6
+ "bin": {
7
+ "git-who": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "README.md"
12
+ ],
13
+ "keywords": ["git", "blame", "ownership", "cli", "contributor"],
14
+ "author": "Larsen Cundric",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/larsencundric/git-who-cli.git"
19
+ },
20
+ "homepage": "https://github.com/larsencundric/git-who-cli#readme",
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "dependencies": {
25
+ "chalk": "5.6.2",
26
+ "commander": "14.0.3"
27
+ }
28
+ }
package/src/cli.js ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import chalk from 'chalk';
6
+ import { program } from 'commander';
7
+ import { git, gitLines, symbols, header, fatal, isInsideGitRepo, getRepoRoot, relativeTime, formatDate } from './utils.js';
8
+
9
+ program
10
+ .name('git-who')
11
+ .description('Show who knows a file or directory best — top contributors ranked by ownership and activity')
12
+ .argument('[path]', 'file or directory to inspect (default: current directory)', '.')
13
+ .option('-n, --top <number>', 'number of contributors to show', '5')
14
+ .addHelpText('after', `
15
+ Examples:
16
+ git who Who knows this repo best?
17
+ git who src/index.js Top contributors for a specific file
18
+ git who src/ Top contributors for a directory
19
+ git who -n 10 . Show top 10 contributors repo-wide`)
20
+ .version('1.0.0')
21
+ .action(run);
22
+
23
+ program.parse();
24
+
25
+ function runFile(file, top) {
26
+ // Check file exists in repo
27
+ try {
28
+ git(['ls-files', '--error-unmatch', file]);
29
+ } catch {
30
+ fatal(`File not tracked by git: ${file}`);
31
+ }
32
+
33
+ // Get blame data
34
+ let blameLines;
35
+ try {
36
+ blameLines = gitLines(['blame', '--line-porcelain', file]);
37
+ } catch {
38
+ fatal(`Cannot blame ${file} — is it a binary or empty file?`);
39
+ }
40
+
41
+ const authorLines = {};
42
+ let currentAuthor = null;
43
+ let totalLines = 0;
44
+
45
+ for (const line of blameLines) {
46
+ if (line.startsWith('author ')) {
47
+ currentAuthor = line.slice(7);
48
+ if (currentAuthor === 'Not Committed Yet') currentAuthor = null;
49
+ } else if (line.startsWith('\t')) {
50
+ if (currentAuthor) {
51
+ authorLines[currentAuthor] = (authorLines[currentAuthor] || 0) + 1;
52
+ totalLines++;
53
+ }
54
+ }
55
+ }
56
+
57
+ const logOutput = gitLines([
58
+ 'log', '--format=%an%x00%aI%x00%s', '--follow', '--', file,
59
+ ]);
60
+
61
+ const authorCommits = {};
62
+ const authorLastDate = {};
63
+ let lastModifiedBy = null;
64
+ let lastModifiedDate = null;
65
+
66
+ for (const line of logOutput) {
67
+ const [name, date, ...msgParts] = line.split('\0');
68
+ if (!authorCommits[name]) authorCommits[name] = [];
69
+ authorCommits[name].push({ date });
70
+
71
+ if (!lastModifiedBy) {
72
+ lastModifiedBy = name;
73
+ lastModifiedDate = date;
74
+ }
75
+
76
+ if (!authorLastDate[name] || date > authorLastDate[name]) {
77
+ authorLastDate[name] = date;
78
+ }
79
+ }
80
+
81
+ // Build ranked list
82
+ const authors = Object.keys({ ...authorLines, ...authorCommits });
83
+ const ranked = authors.map((name) => {
84
+ const lines = authorLines[name] || 0;
85
+ const commits = (authorCommits[name] || []).length;
86
+ const pct = totalLines > 0 ? (lines / totalLines) * 100 : 0;
87
+ const lastDate = authorLastDate[name] || null;
88
+ return { name, lines, commits, pct, lastDate };
89
+ });
90
+
91
+ const maxLines = Math.max(...ranked.map((r) => r.lines), 1);
92
+ const maxCommits = Math.max(...ranked.map((r) => r.commits), 1);
93
+ ranked.forEach((r) => {
94
+ r.score = 0.6 * (r.lines / maxLines) + 0.4 * (r.commits / maxCommits);
95
+ });
96
+ ranked.sort((a, b) => b.score - a.score);
97
+
98
+ header(`git who ${chalk.white(file)}`);
99
+
100
+ if (lastModifiedBy) {
101
+ console.log(
102
+ ` ${chalk.dim('Last modified by')} ${chalk.yellow(lastModifiedBy)} ${chalk.dim(relativeTime(lastModifiedDate))} ${chalk.dim(`(${formatDate(lastModifiedDate)})`)}`
103
+ );
104
+ console.log();
105
+ }
106
+
107
+ console.log(chalk.bold(' Top contributors:\n'));
108
+
109
+ const display = ranked.slice(0, top);
110
+
111
+ if (display.length === 0) {
112
+ console.log(chalk.dim(' No contributors found.\n'));
113
+ return;
114
+ }
115
+
116
+ const maxNameLen = Math.max(...display.map((r) => r.name.length));
117
+
118
+ display.forEach((r, i) => {
119
+ const rank = i === 0 ? chalk.yellow('★') : chalk.dim(`${i + 1}`);
120
+ const name = r.name.padEnd(maxNameLen);
121
+ const barLen = Math.max(1, Math.round(r.pct / 3));
122
+ const bar = '█'.repeat(barLen);
123
+ const pctStr = `${r.pct.toFixed(0)}%`.padStart(4);
124
+ const color = i === 0 ? chalk.yellow : i === 1 ? chalk.white : chalk.dim;
125
+
126
+ console.log(
127
+ ` ${rank} ${color(name)} ${chalk.green(bar)} ${chalk.dim(pctStr)} lines ${chalk.dim(`${r.commits} commits`)} ${r.lastDate ? chalk.dim(relativeTime(r.lastDate)) : ''}`
128
+ );
129
+ });
130
+
131
+ console.log();
132
+ if (ranked.length > 0) {
133
+ const best = ranked[0];
134
+ console.log(
135
+ ` ${chalk.cyan(symbols.arrow)} ${chalk.dim('Ask')} ${chalk.bold.cyan(best.name)} ${chalk.dim('— they own')} ${chalk.white(`${best.pct.toFixed(0)}%`)} ${chalk.dim('of lines and made')} ${chalk.white(best.commits)} ${chalk.dim('commits')}`
136
+ );
137
+ if (ranked.length > 1 && ranked[1].score > 0.5) {
138
+ console.log(
139
+ ` ${chalk.dim(' or')} ${chalk.cyan(ranked[1].name)} ${chalk.dim(`(${ranked[1].pct.toFixed(0)}% lines, ${ranked[1].commits} commits)`)}`
140
+ );
141
+ }
142
+ }
143
+ console.log();
144
+ }
145
+
146
+ function runDirectory(dir, top) {
147
+ const displayPath = dir === '.' ? getRepoRoot().split('/').pop() : dir;
148
+
149
+ // Get contributor stats via shortlog
150
+ const shortlogLines = gitLines(['shortlog', '-sne', '--no-merges', 'HEAD', '--', dir]);
151
+
152
+ const authorCommits = {};
153
+ for (const line of shortlogLines) {
154
+ const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+?)>\s*$/);
155
+ if (match) {
156
+ const [, count, name] = match;
157
+ authorCommits[name] = parseInt(count, 10);
158
+ }
159
+ }
160
+
161
+ // Get last activity per author
162
+ const logLines = gitLines(['log', '--format=%an%x00%aI', '--no-merges', '--', dir]);
163
+ const authorLastDate = {};
164
+ let lastModifiedBy = null;
165
+ let lastModifiedDate = null;
166
+
167
+ for (const line of logLines) {
168
+ const [name, date] = line.split('\0');
169
+ if (!lastModifiedBy) {
170
+ lastModifiedBy = name;
171
+ lastModifiedDate = date;
172
+ }
173
+ if (!authorLastDate[name]) {
174
+ authorLastDate[name] = date;
175
+ }
176
+ }
177
+
178
+ // Get total commits for percentage
179
+ const totalCommits = Object.values(authorCommits).reduce((a, b) => a + b, 0);
180
+
181
+ // Build ranked list
182
+ const ranked = Object.entries(authorCommits).map(([name, commits]) => ({
183
+ name,
184
+ commits,
185
+ pct: totalCommits > 0 ? (commits / totalCommits) * 100 : 0,
186
+ lastDate: authorLastDate[name] || null,
187
+ }));
188
+ ranked.sort((a, b) => b.commits - a.commits);
189
+
190
+ header(`git who ${chalk.white(displayPath + '/')}`);
191
+
192
+ if (lastModifiedBy) {
193
+ console.log(
194
+ ` ${chalk.dim('Last modified by')} ${chalk.yellow(lastModifiedBy)} ${chalk.dim(relativeTime(lastModifiedDate))} ${chalk.dim(`(${formatDate(lastModifiedDate)})`)}`
195
+ );
196
+ console.log();
197
+ }
198
+
199
+ console.log(chalk.bold(' Top contributors:\n'));
200
+
201
+ const display = ranked.slice(0, top);
202
+ const maxNameLen = Math.max(...display.map((r) => r.name.length), 1);
203
+
204
+ display.forEach((r, i) => {
205
+ const rank = i === 0 ? chalk.yellow('★') : chalk.dim(`${i + 1}`);
206
+ const name = r.name.padEnd(maxNameLen);
207
+ const barLen = Math.max(1, Math.round(r.pct / 3));
208
+ const bar = '█'.repeat(barLen);
209
+ const pctStr = `${r.pct.toFixed(0)}%`.padStart(4);
210
+ const color = i === 0 ? chalk.yellow : i === 1 ? chalk.white : chalk.dim;
211
+
212
+ console.log(
213
+ ` ${rank} ${color(name)} ${chalk.green(bar)} ${chalk.dim(pctStr)} commits (${chalk.white(r.commits)}) ${r.lastDate ? chalk.dim(relativeTime(r.lastDate)) : ''}`
214
+ );
215
+ });
216
+
217
+ console.log();
218
+ if (ranked.length > 0) {
219
+ const best = ranked[0];
220
+ console.log(
221
+ ` ${chalk.cyan(symbols.arrow)} ${chalk.dim('Ask')} ${chalk.bold.cyan(best.name)} ${chalk.dim('—')} ${chalk.white(`${best.pct.toFixed(0)}%`)} ${chalk.dim('of commits')} ${chalk.dim(`(${best.commits} total)`)}`
222
+ );
223
+ if (ranked.length > 1 && ranked[1].pct > 15) {
224
+ console.log(
225
+ ` ${chalk.dim(' or')} ${chalk.cyan(ranked[1].name)} ${chalk.dim(`(${ranked[1].pct.toFixed(0)}%, ${ranked[1].commits} commits)`)}`
226
+ );
227
+ }
228
+ }
229
+ console.log();
230
+ }
231
+
232
+ function run(pathArg, opts) {
233
+ if (!isInsideGitRepo()) fatal('Not inside a git repository');
234
+
235
+ const top = parseInt(opts.top, 10);
236
+ if (!Number.isFinite(top) || top < 1) fatal(`Invalid --top value: "${opts.top}" (must be a positive integer)`);
237
+ const resolvedPath = resolve(pathArg);
238
+
239
+ let isDir = false;
240
+ try {
241
+ isDir = statSync(resolvedPath).isDirectory();
242
+ } catch {
243
+ // Path might not exist on disk but could be a git-tracked file
244
+ // Try as a file
245
+ }
246
+
247
+ if (isDir) {
248
+ runDirectory(pathArg, top);
249
+ } else {
250
+ runFile(pathArg, top);
251
+ }
252
+ }
package/src/utils.js ADDED
@@ -0,0 +1,84 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import chalk from 'chalk';
3
+
4
+ export function git(args, opts = {}) {
5
+ try {
6
+ return execFileSync('git', args, {
7
+ encoding: 'utf-8',
8
+ maxBuffer: 50 * 1024 * 1024,
9
+ stdio: ['pipe', 'pipe', 'pipe'],
10
+ ...opts,
11
+ }).trim();
12
+ } catch (e) {
13
+ if (opts.allowFail) return '';
14
+ throw e;
15
+ }
16
+ }
17
+
18
+ export function gitLines(args, opts = {}) {
19
+ const out = git(args, opts);
20
+ return out ? out.split('\n') : [];
21
+ }
22
+
23
+ export function isInsideGitRepo() {
24
+ try {
25
+ git(['rev-parse', '--is-inside-work-tree']);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function getRepoRoot() {
33
+ return git(['rev-parse', '--show-toplevel']);
34
+ }
35
+
36
+ export const symbols = {
37
+ bullet: '●',
38
+ arrow: '→',
39
+ check: '✓',
40
+ cross: '✗',
41
+ warning: '⚠',
42
+ line: '─',
43
+ };
44
+
45
+ export function header(text) {
46
+ // Strip ANSI codes to get the visible length for alignment
47
+ const visibleLength = text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').length;
48
+ const line = symbols.line.repeat(Math.max(0, 50 - visibleLength));
49
+ console.log(`\n${chalk.bold.cyan(text)} ${chalk.dim(line)}\n`);
50
+ }
51
+
52
+ export function fatal(msg) {
53
+ console.error(`${chalk.red(symbols.cross)} ${msg}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ export function relativeTime(dateStr) {
58
+ const date = new Date(dateStr);
59
+ if (isNaN(date.getTime())) return 'unknown';
60
+ const now = new Date();
61
+ const diffMs = now - date;
62
+ const diffMins = Math.floor(diffMs / 60000);
63
+ const diffHours = Math.floor(diffMs / 3600000);
64
+ const diffDays = Math.floor(diffMs / 86400000);
65
+ const diffWeeks = Math.floor(diffDays / 7);
66
+ const diffMonths = Math.floor(diffDays / 30);
67
+
68
+ if (diffMins < 1) return 'just now';
69
+ if (diffMins < 60) return `${diffMins}m ago`;
70
+ if (diffHours < 24) return `${diffHours}h ago`;
71
+ if (diffDays < 7) return `${diffDays}d ago`;
72
+ if (diffWeeks < 5) return `${diffWeeks}w ago`;
73
+ return `${diffMonths}mo ago`;
74
+ }
75
+
76
+ export function formatDate(dateStr) {
77
+ const d = new Date(dateStr);
78
+ return d.toLocaleDateString('en-US', {
79
+ weekday: 'short',
80
+ month: 'short',
81
+ day: 'numeric',
82
+ year: 'numeric',
83
+ });
84
+ }