gitfamiliar 0.1.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 +21 -0
- package/README.md +168 -0
- package/dist/bin/gitfamiliar.js +514 -0
- package/dist/bin/gitfamiliar.js.map +1 -0
- package/dist/chunk-DW2PHZVZ.js +867 -0
- package/dist/chunk-DW2PHZVZ.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/templates/gitfamiliarignore.default +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GitFamiliar Contributors
|
|
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,168 @@
|
|
|
1
|
+
# GitFamiliar
|
|
2
|
+
|
|
3
|
+
**Visualize your code familiarity from Git history.**
|
|
4
|
+
|
|
5
|
+
Existing tools (git-fame, GitHub Contributors, etc.) measure _how much_ you've written. GitFamiliar estimates _how well_ you understand the codebase. Built for engineers joining a new project, it lets you and your team objectively track onboarding progress.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
GitFamiliar — my-project (Binary mode)
|
|
9
|
+
|
|
10
|
+
Overall: 58/172 files (34%)
|
|
11
|
+
|
|
12
|
+
src/
|
|
13
|
+
auth/ ████████░░ 80% (4/5 files)
|
|
14
|
+
api/ ███░░░░░░░ 30% (6/20 files)
|
|
15
|
+
components/ █░░░░░░░░░ 12% (3/25 files)
|
|
16
|
+
utils/ ██████████ 100% (8/8 files)
|
|
17
|
+
tests/ ░░░░░░░░░░ 0% (0/14 files)
|
|
18
|
+
config/ ██████░░░░ 60% (3/5 files)
|
|
19
|
+
|
|
20
|
+
Written: 42 files | Reviewed: 23 files | Both: 7 files
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx gitfamiliar
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install globally:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g gitfamiliar
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Run inside any Git repository:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Binary mode (default) — which files have you touched?
|
|
41
|
+
gitfamiliar
|
|
42
|
+
|
|
43
|
+
# Authorship mode — how much of the current code did you write? (git blame)
|
|
44
|
+
gitfamiliar --mode authorship
|
|
45
|
+
|
|
46
|
+
# Review Coverage — which files have you reviewed via PR?
|
|
47
|
+
gitfamiliar --mode review-coverage
|
|
48
|
+
|
|
49
|
+
# Weighted mode — combined score from blame, commits, and reviews
|
|
50
|
+
gitfamiliar --mode weighted
|
|
51
|
+
|
|
52
|
+
# HTML treemap report (opens in browser)
|
|
53
|
+
gitfamiliar --html
|
|
54
|
+
|
|
55
|
+
# Check a specific user
|
|
56
|
+
gitfamiliar --user kota
|
|
57
|
+
|
|
58
|
+
# Filter by how you touched the code
|
|
59
|
+
gitfamiliar --filter written # only files you committed to
|
|
60
|
+
gitfamiliar --filter reviewed # only files you reviewed
|
|
61
|
+
|
|
62
|
+
# Expiration policies
|
|
63
|
+
gitfamiliar --expiration time:180d # expire after 180 days
|
|
64
|
+
gitfamiliar --expiration change:50% # expire if 50%+ changed
|
|
65
|
+
gitfamiliar --expiration combined:365d:50%
|
|
66
|
+
|
|
67
|
+
# Custom weights for weighted mode
|
|
68
|
+
gitfamiliar --mode weighted --weights "0.5,0.35,0.15"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Scoring Modes
|
|
72
|
+
|
|
73
|
+
### Binary (default)
|
|
74
|
+
|
|
75
|
+
Files are either "read" or "unread". A file counts as read if you've committed to it or approved a PR containing it.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
score = read files / total files
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Best for: **New team members** tracking onboarding progress.
|
|
82
|
+
|
|
83
|
+
### Authorship
|
|
84
|
+
|
|
85
|
+
Your share of the current codebase, based on `git blame`.
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
score = your blame lines / total lines
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Best for: **Tech leads** assessing bus factor and code ownership distribution.
|
|
92
|
+
|
|
93
|
+
### Review Coverage
|
|
94
|
+
|
|
95
|
+
Files you've reviewed through PR approvals or comments (excluding your own commits).
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
score = reviewed files / total files
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Best for: **Senior engineers** tracking review breadth. Requires a GitHub token.
|
|
102
|
+
|
|
103
|
+
### Weighted
|
|
104
|
+
|
|
105
|
+
Combines blame, commit frequency, and review signals with configurable weights and time decay.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
score = 0.5 × blame_score + 0.35 × commit_score + 0.15 × review_score
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Commit contributions use sigmoid normalization and exponential recency decay (half-life: 180 days). Review scores account for PR size (attention dilution).
|
|
112
|
+
|
|
113
|
+
Best for: **Power users** who want the most nuanced picture.
|
|
114
|
+
|
|
115
|
+
## HTML Report
|
|
116
|
+
|
|
117
|
+
Use `--html` to generate an interactive treemap visualization:
|
|
118
|
+
|
|
119
|
+
- **Area** = lines of code (file/folder volume)
|
|
120
|
+
- **Color** = familiarity score (red → yellow → green)
|
|
121
|
+
- Click folders to drill down
|
|
122
|
+
- Toggle between All / Written / Reviewed views
|
|
123
|
+
- Hover for detailed scores
|
|
124
|
+
|
|
125
|
+
## File Filtering
|
|
126
|
+
|
|
127
|
+
GitFamiliar ignores lock files, build outputs, and generated code by default. Customize by creating `.gitfamiliarignore` in your repo root (same syntax as `.gitignore`):
|
|
128
|
+
|
|
129
|
+
```gitignore
|
|
130
|
+
# Example: also ignore vendor code
|
|
131
|
+
vendor/
|
|
132
|
+
third_party/
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Default exclusions: `package-lock.json`, `yarn.lock`, `*.min.js`, `*.map`, `dist/`, `build/`, etc.
|
|
136
|
+
|
|
137
|
+
## Expiration Policies
|
|
138
|
+
|
|
139
|
+
Control whether "read" status expires over time:
|
|
140
|
+
|
|
141
|
+
| Policy | Flag | Behavior |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| Never (default) | `--expiration never` | Once read, always read |
|
|
144
|
+
| Time-based | `--expiration time:180d` | Expires 180 days after last touch |
|
|
145
|
+
| Change-based | `--expiration change:50%` | Expires if 50%+ of the file changed since your last touch |
|
|
146
|
+
| Combined | `--expiration combined:365d:50%` | Expires if either condition is met |
|
|
147
|
+
|
|
148
|
+
## GitHub Integration
|
|
149
|
+
|
|
150
|
+
For review-related features (Review Coverage mode, reviewed files in Binary mode), set a GitHub token:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Option 1: environment variable
|
|
154
|
+
export GITHUB_TOKEN=ghp_xxx
|
|
155
|
+
|
|
156
|
+
# Option 2: GitHub CLI (auto-detected)
|
|
157
|
+
gh auth login
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
|
|
162
|
+
- Node.js >= 18
|
|
163
|
+
- Git
|
|
164
|
+
- GitHub token (optional, for review features)
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
computeFamiliarity,
|
|
4
|
+
parseExpirationConfig
|
|
5
|
+
} from "../chunk-DW2PHZVZ.js";
|
|
6
|
+
|
|
7
|
+
// src/cli/index.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/core/types.ts
|
|
11
|
+
var DEFAULT_WEIGHTS = {
|
|
12
|
+
blame: 0.5,
|
|
13
|
+
commit: 0.35,
|
|
14
|
+
review: 0.15
|
|
15
|
+
};
|
|
16
|
+
var DEFAULT_EXPIRATION = {
|
|
17
|
+
policy: "never"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/cli/options.ts
|
|
21
|
+
function parseOptions(raw, repoPath) {
|
|
22
|
+
const mode = validateMode(raw.mode || "binary");
|
|
23
|
+
const filter = validateFilter(raw.filter || "all");
|
|
24
|
+
let weights = DEFAULT_WEIGHTS;
|
|
25
|
+
if (raw.weights) {
|
|
26
|
+
weights = parseWeights(raw.weights);
|
|
27
|
+
}
|
|
28
|
+
const expiration = raw.expiration ? parseExpirationConfig(raw.expiration) : DEFAULT_EXPIRATION;
|
|
29
|
+
return {
|
|
30
|
+
mode,
|
|
31
|
+
user: raw.user,
|
|
32
|
+
filter,
|
|
33
|
+
expiration,
|
|
34
|
+
html: raw.html || false,
|
|
35
|
+
weights,
|
|
36
|
+
repoPath
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function validateMode(mode) {
|
|
40
|
+
const valid = ["binary", "authorship", "review-coverage", "weighted"];
|
|
41
|
+
if (!valid.includes(mode)) {
|
|
42
|
+
throw new Error(`Invalid mode: "${mode}". Valid modes: ${valid.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
return mode;
|
|
45
|
+
}
|
|
46
|
+
function validateFilter(filter) {
|
|
47
|
+
const valid = ["all", "written", "reviewed"];
|
|
48
|
+
if (!valid.includes(filter)) {
|
|
49
|
+
throw new Error(`Invalid filter: "${filter}". Valid filters: ${valid.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
return filter;
|
|
52
|
+
}
|
|
53
|
+
function parseWeights(s) {
|
|
54
|
+
const parts = s.split(",").map(Number);
|
|
55
|
+
if (parts.length !== 3 || parts.some(isNaN)) {
|
|
56
|
+
throw new Error(`Invalid weights: "${s}". Expected format: "0.5,0.35,0.15"`);
|
|
57
|
+
}
|
|
58
|
+
const sum = parts[0] + parts[1] + parts[2];
|
|
59
|
+
if (Math.abs(sum - 1) > 0.01) {
|
|
60
|
+
throw new Error(`Weights must sum to 1.0, got ${sum}`);
|
|
61
|
+
}
|
|
62
|
+
return { blame: parts[0], commit: parts[1], review: parts[2] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/cli/output/terminal.ts
|
|
66
|
+
import chalk from "chalk";
|
|
67
|
+
var BAR_WIDTH = 10;
|
|
68
|
+
var FILLED_CHAR = "\u2588";
|
|
69
|
+
var EMPTY_CHAR = "\u2591";
|
|
70
|
+
function makeBar(score) {
|
|
71
|
+
const filled = Math.round(score * BAR_WIDTH);
|
|
72
|
+
const empty = BAR_WIDTH - filled;
|
|
73
|
+
const bar = FILLED_CHAR.repeat(filled) + EMPTY_CHAR.repeat(empty);
|
|
74
|
+
if (score >= 0.8) return chalk.green(bar);
|
|
75
|
+
if (score >= 0.5) return chalk.yellow(bar);
|
|
76
|
+
if (score > 0) return chalk.red(bar);
|
|
77
|
+
return chalk.gray(bar);
|
|
78
|
+
}
|
|
79
|
+
function formatPercent(score) {
|
|
80
|
+
return `${Math.round(score * 100)}%`;
|
|
81
|
+
}
|
|
82
|
+
function getModeLabel(mode) {
|
|
83
|
+
switch (mode) {
|
|
84
|
+
case "binary":
|
|
85
|
+
return "Binary mode";
|
|
86
|
+
case "authorship":
|
|
87
|
+
return "Authorship mode";
|
|
88
|
+
case "review-coverage":
|
|
89
|
+
return "Review Coverage mode";
|
|
90
|
+
case "weighted":
|
|
91
|
+
return "Weighted mode";
|
|
92
|
+
default:
|
|
93
|
+
return mode;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function renderFolder(node, indent, mode, maxDepth) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
const prefix = " ".repeat(indent);
|
|
99
|
+
const sorted = [...node.children].sort((a, b) => {
|
|
100
|
+
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
101
|
+
return a.path.localeCompare(b.path);
|
|
102
|
+
});
|
|
103
|
+
for (const child of sorted) {
|
|
104
|
+
if (child.type === "folder") {
|
|
105
|
+
const folder = child;
|
|
106
|
+
const name = folder.path.split("/").pop() + "/";
|
|
107
|
+
const bar = makeBar(folder.score);
|
|
108
|
+
const pct = formatPercent(folder.score);
|
|
109
|
+
if (mode === "binary") {
|
|
110
|
+
const readCount = folder.readCount || 0;
|
|
111
|
+
lines.push(
|
|
112
|
+
`${prefix}${chalk.bold(name.padEnd(16))} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(
|
|
116
|
+
`${prefix}${chalk.bold(name.padEnd(16))} ${bar} ${pct.padStart(4)}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (indent < maxDepth) {
|
|
120
|
+
lines.push(...renderFolder(folder, indent + 1, mode, maxDepth));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
function renderTerminal(result) {
|
|
127
|
+
const { tree, repoName, mode } = result;
|
|
128
|
+
console.log("");
|
|
129
|
+
console.log(chalk.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`));
|
|
130
|
+
console.log("");
|
|
131
|
+
if (mode === "binary") {
|
|
132
|
+
const readCount = tree.readCount || 0;
|
|
133
|
+
const pct = formatPercent(tree.score);
|
|
134
|
+
console.log(`Overall: ${readCount}/${tree.fileCount} files (${pct})`);
|
|
135
|
+
} else {
|
|
136
|
+
const pct = formatPercent(tree.score);
|
|
137
|
+
console.log(`Overall: ${pct}`);
|
|
138
|
+
}
|
|
139
|
+
console.log("");
|
|
140
|
+
const folderLines = renderFolder(tree, 1, mode, 2);
|
|
141
|
+
for (const line of folderLines) {
|
|
142
|
+
console.log(line);
|
|
143
|
+
}
|
|
144
|
+
console.log("");
|
|
145
|
+
if (mode === "binary") {
|
|
146
|
+
const { writtenCount, reviewedCount, bothCount } = result;
|
|
147
|
+
console.log(
|
|
148
|
+
`Written: ${writtenCount} files | Reviewed: ${reviewedCount} files | Both: ${bothCount} files`
|
|
149
|
+
);
|
|
150
|
+
console.log("");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/cli/output/html.ts
|
|
155
|
+
import { writeFileSync } from "fs";
|
|
156
|
+
import { join } from "path";
|
|
157
|
+
|
|
158
|
+
// src/utils/open-browser.ts
|
|
159
|
+
async function openBrowser(filePath) {
|
|
160
|
+
try {
|
|
161
|
+
const open = await import("open");
|
|
162
|
+
await open.default(filePath);
|
|
163
|
+
} catch {
|
|
164
|
+
console.log(`Could not open browser automatically. Open this file manually:`);
|
|
165
|
+
console.log(` ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/cli/output/html.ts
|
|
170
|
+
function generateTreemapHTML(result) {
|
|
171
|
+
const dataJson = JSON.stringify(result.tree);
|
|
172
|
+
const mode = result.mode;
|
|
173
|
+
const repoName = result.repoName;
|
|
174
|
+
return `<!DOCTYPE html>
|
|
175
|
+
<html lang="en">
|
|
176
|
+
<head>
|
|
177
|
+
<meta charset="UTF-8">
|
|
178
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
179
|
+
<title>GitFamiliar \u2014 ${repoName}</title>
|
|
180
|
+
<style>
|
|
181
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
182
|
+
body {
|
|
183
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
184
|
+
background: #1a1a2e;
|
|
185
|
+
color: #e0e0e0;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
}
|
|
188
|
+
#header {
|
|
189
|
+
padding: 16px 24px;
|
|
190
|
+
background: #16213e;
|
|
191
|
+
border-bottom: 1px solid #0f3460;
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
justify-content: space-between;
|
|
195
|
+
}
|
|
196
|
+
#header h1 { font-size: 18px; color: #e94560; }
|
|
197
|
+
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
198
|
+
#breadcrumb {
|
|
199
|
+
padding: 8px 24px;
|
|
200
|
+
background: #16213e;
|
|
201
|
+
font-size: 13px;
|
|
202
|
+
border-bottom: 1px solid #0f3460;
|
|
203
|
+
}
|
|
204
|
+
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
205
|
+
#breadcrumb span:hover { text-decoration: underline; }
|
|
206
|
+
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
207
|
+
#controls {
|
|
208
|
+
padding: 8px 24px;
|
|
209
|
+
background: #16213e;
|
|
210
|
+
border-bottom: 1px solid #0f3460;
|
|
211
|
+
display: flex;
|
|
212
|
+
gap: 12px;
|
|
213
|
+
align-items: center;
|
|
214
|
+
}
|
|
215
|
+
#controls button {
|
|
216
|
+
padding: 4px 12px;
|
|
217
|
+
border: 1px solid #0f3460;
|
|
218
|
+
background: #1a1a2e;
|
|
219
|
+
color: #e0e0e0;
|
|
220
|
+
border-radius: 4px;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
font-size: 12px;
|
|
223
|
+
}
|
|
224
|
+
#controls button.active {
|
|
225
|
+
background: #e94560;
|
|
226
|
+
border-color: #e94560;
|
|
227
|
+
color: white;
|
|
228
|
+
}
|
|
229
|
+
#treemap { width: 100%; }
|
|
230
|
+
#tooltip {
|
|
231
|
+
position: absolute;
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
background: rgba(22, 33, 62, 0.95);
|
|
234
|
+
border: 1px solid #0f3460;
|
|
235
|
+
border-radius: 6px;
|
|
236
|
+
padding: 10px 14px;
|
|
237
|
+
font-size: 13px;
|
|
238
|
+
line-height: 1.6;
|
|
239
|
+
display: none;
|
|
240
|
+
z-index: 100;
|
|
241
|
+
max-width: 300px;
|
|
242
|
+
}
|
|
243
|
+
#legend {
|
|
244
|
+
position: absolute;
|
|
245
|
+
bottom: 16px;
|
|
246
|
+
right: 16px;
|
|
247
|
+
background: rgba(22, 33, 62, 0.9);
|
|
248
|
+
border: 1px solid #0f3460;
|
|
249
|
+
border-radius: 6px;
|
|
250
|
+
padding: 10px;
|
|
251
|
+
font-size: 12px;
|
|
252
|
+
}
|
|
253
|
+
#legend .gradient-bar {
|
|
254
|
+
width: 120px;
|
|
255
|
+
height: 12px;
|
|
256
|
+
background: linear-gradient(to right, #e94560, #f5a623, #27ae60);
|
|
257
|
+
border-radius: 3px;
|
|
258
|
+
margin: 4px 0;
|
|
259
|
+
}
|
|
260
|
+
#legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
|
|
261
|
+
</style>
|
|
262
|
+
</head>
|
|
263
|
+
<body>
|
|
264
|
+
<div id="header">
|
|
265
|
+
<h1>GitFamiliar \u2014 ${repoName}</h1>
|
|
266
|
+
<div class="info">${mode.charAt(0).toUpperCase() + mode.slice(1)} mode | ${result.totalFiles} files</div>
|
|
267
|
+
</div>
|
|
268
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
269
|
+
${mode === "binary" ? `
|
|
270
|
+
<div id="controls">
|
|
271
|
+
<span style="font-size:12px;color:#888;">Filter:</span>
|
|
272
|
+
<button class="active" onclick="setFilter('all')">All</button>
|
|
273
|
+
<button onclick="setFilter('written')">Written only</button>
|
|
274
|
+
<button onclick="setFilter('reviewed')">Reviewed only</button>
|
|
275
|
+
</div>` : ""}
|
|
276
|
+
<div id="treemap"></div>
|
|
277
|
+
<div id="tooltip"></div>
|
|
278
|
+
<div id="legend">
|
|
279
|
+
<div>Familiarity</div>
|
|
280
|
+
<div class="gradient-bar"></div>
|
|
281
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
285
|
+
<script>
|
|
286
|
+
const rawData = ${dataJson};
|
|
287
|
+
const mode = "${mode}";
|
|
288
|
+
let currentFilter = 'all';
|
|
289
|
+
let currentPath = '';
|
|
290
|
+
|
|
291
|
+
function scoreColor(score) {
|
|
292
|
+
if (score <= 0) return '#e94560';
|
|
293
|
+
if (score >= 1) return '#27ae60';
|
|
294
|
+
if (score < 0.5) {
|
|
295
|
+
const t = score / 0.5;
|
|
296
|
+
return d3.interpolateRgb('#e94560', '#f5a623')(t);
|
|
297
|
+
}
|
|
298
|
+
const t = (score - 0.5) / 0.5;
|
|
299
|
+
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getFileScore(file) {
|
|
303
|
+
if (mode !== 'binary') return file.score;
|
|
304
|
+
if (currentFilter === 'written') return file.isWritten ? 1 : 0;
|
|
305
|
+
if (currentFilter === 'reviewed') return file.isReviewed ? 1 : 0;
|
|
306
|
+
return file.score;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function findNode(node, path) {
|
|
310
|
+
if (node.path === path) return node;
|
|
311
|
+
if (node.children) {
|
|
312
|
+
for (const child of node.children) {
|
|
313
|
+
const found = findNode(child, path);
|
|
314
|
+
if (found) return found;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function flattenFiles(node) {
|
|
321
|
+
const files = [];
|
|
322
|
+
function walk(n) {
|
|
323
|
+
if (n.type === 'file') {
|
|
324
|
+
files.push(n);
|
|
325
|
+
} else if (n.children) {
|
|
326
|
+
n.children.forEach(walk);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
walk(node);
|
|
330
|
+
return files;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function render() {
|
|
334
|
+
const container = document.getElementById('treemap');
|
|
335
|
+
container.innerHTML = '';
|
|
336
|
+
|
|
337
|
+
const headerH = document.getElementById('header').offsetHeight;
|
|
338
|
+
const breadcrumbH = document.getElementById('breadcrumb').offsetHeight;
|
|
339
|
+
const controlsEl = document.getElementById('controls');
|
|
340
|
+
const controlsH = controlsEl ? controlsEl.offsetHeight : 0;
|
|
341
|
+
const width = window.innerWidth;
|
|
342
|
+
const height = window.innerHeight - headerH - breadcrumbH - controlsH;
|
|
343
|
+
|
|
344
|
+
const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
|
|
345
|
+
if (!targetNode) return;
|
|
346
|
+
|
|
347
|
+
const hierarchyData = {
|
|
348
|
+
name: targetNode.path || 'root',
|
|
349
|
+
children: (targetNode.children || []).map(function buildChild(c) {
|
|
350
|
+
if (c.type === 'file') {
|
|
351
|
+
return { name: c.path.split('/').pop(), data: c, value: Math.max(1, c.lines) };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
name: c.path.split('/').pop(),
|
|
355
|
+
data: c,
|
|
356
|
+
children: (c.children || []).map(buildChild),
|
|
357
|
+
};
|
|
358
|
+
}),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const root = d3.hierarchy(hierarchyData)
|
|
362
|
+
.sum(d => d.value || 0)
|
|
363
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
364
|
+
|
|
365
|
+
d3.treemap()
|
|
366
|
+
.size([width, height])
|
|
367
|
+
.padding(2)
|
|
368
|
+
.paddingTop(18)
|
|
369
|
+
.round(true)(root);
|
|
370
|
+
|
|
371
|
+
const svg = d3.select('#treemap')
|
|
372
|
+
.append('svg')
|
|
373
|
+
.attr('width', width)
|
|
374
|
+
.attr('height', height);
|
|
375
|
+
|
|
376
|
+
const tooltip = document.getElementById('tooltip');
|
|
377
|
+
|
|
378
|
+
// Draw groups (folders)
|
|
379
|
+
const groups = svg.selectAll('g')
|
|
380
|
+
.data(root.descendants().filter(d => d.depth > 0))
|
|
381
|
+
.join('g')
|
|
382
|
+
.attr('transform', d => \`translate(\${d.x0},\${d.y0})\`);
|
|
383
|
+
|
|
384
|
+
groups.append('rect')
|
|
385
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
386
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
387
|
+
.attr('fill', d => {
|
|
388
|
+
if (d.data.data) {
|
|
389
|
+
const score = d.children ? d.data.data.score : getFileScore(d.data.data);
|
|
390
|
+
return scoreColor(score);
|
|
391
|
+
}
|
|
392
|
+
return '#333';
|
|
393
|
+
})
|
|
394
|
+
.attr('opacity', d => d.children ? 0.3 : 0.85)
|
|
395
|
+
.attr('stroke', '#1a1a2e')
|
|
396
|
+
.attr('stroke-width', 1)
|
|
397
|
+
.attr('rx', 2)
|
|
398
|
+
.style('cursor', d => d.children ? 'pointer' : 'default')
|
|
399
|
+
.on('click', (event, d) => {
|
|
400
|
+
if (d.children && d.data.data && d.data.data.type === 'folder') {
|
|
401
|
+
zoomTo(d.data.data.path);
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
.on('mouseover', (event, d) => {
|
|
405
|
+
if (!d.data.data) return;
|
|
406
|
+
const data = d.data.data;
|
|
407
|
+
const name = data.path || d.data.name;
|
|
408
|
+
const score = d.children ? data.score : getFileScore(data);
|
|
409
|
+
let html = \`<strong>\${name}</strong><br>Score: \${Math.round(score * 100)}%<br>Lines: \${data.lines}\`;
|
|
410
|
+
if (data.type === 'folder') {
|
|
411
|
+
html += \`<br>Files: \${data.fileCount}\`;
|
|
412
|
+
}
|
|
413
|
+
if (data.blameScore !== undefined) {
|
|
414
|
+
html += \`<br>Blame: \${Math.round(data.blameScore * 100)}%\`;
|
|
415
|
+
}
|
|
416
|
+
if (data.commitScore !== undefined) {
|
|
417
|
+
html += \`<br>Commit: \${Math.round(data.commitScore * 100)}%\`;
|
|
418
|
+
}
|
|
419
|
+
if (data.reviewScore !== undefined) {
|
|
420
|
+
html += \`<br>Review: \${Math.round(data.reviewScore * 100)}%\`;
|
|
421
|
+
}
|
|
422
|
+
tooltip.innerHTML = html;
|
|
423
|
+
tooltip.style.display = 'block';
|
|
424
|
+
})
|
|
425
|
+
.on('mousemove', (event) => {
|
|
426
|
+
tooltip.style.left = (event.pageX + 12) + 'px';
|
|
427
|
+
tooltip.style.top = (event.pageY - 12) + 'px';
|
|
428
|
+
})
|
|
429
|
+
.on('mouseout', () => {
|
|
430
|
+
tooltip.style.display = 'none';
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Labels
|
|
434
|
+
groups.append('text')
|
|
435
|
+
.attr('x', 4)
|
|
436
|
+
.attr('y', 13)
|
|
437
|
+
.attr('fill', '#fff')
|
|
438
|
+
.attr('font-size', '11px')
|
|
439
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
440
|
+
.text(d => {
|
|
441
|
+
const w = (d.x1 - d.x0);
|
|
442
|
+
const name = d.data.name || '';
|
|
443
|
+
if (w < 40) return '';
|
|
444
|
+
if (w < name.length * 7) return name.slice(0, Math.floor(w / 7)) + '..';
|
|
445
|
+
return name;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function zoomTo(path) {
|
|
450
|
+
currentPath = path;
|
|
451
|
+
updateBreadcrumb();
|
|
452
|
+
render();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function updateBreadcrumb() {
|
|
456
|
+
const el = document.getElementById('breadcrumb');
|
|
457
|
+
const parts = currentPath ? currentPath.split('/') : [];
|
|
458
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
459
|
+
let accumulated = '';
|
|
460
|
+
for (const part of parts) {
|
|
461
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
462
|
+
const p = accumulated;
|
|
463
|
+
html += \`<span class="sep">/</span><span onclick="zoomTo('\${p}')">\${part}</span>\`;
|
|
464
|
+
}
|
|
465
|
+
el.innerHTML = html;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function setFilter(f) {
|
|
469
|
+
currentFilter = f;
|
|
470
|
+
document.querySelectorAll('#controls button').forEach(btn => {
|
|
471
|
+
btn.classList.toggle('active', btn.textContent.toLowerCase().includes(f));
|
|
472
|
+
});
|
|
473
|
+
render();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
window.addEventListener('resize', render);
|
|
477
|
+
render();
|
|
478
|
+
</script>
|
|
479
|
+
</body>
|
|
480
|
+
</html>`;
|
|
481
|
+
}
|
|
482
|
+
async function generateAndOpenHTML(result, repoPath) {
|
|
483
|
+
const html = generateTreemapHTML(result);
|
|
484
|
+
const outputPath = join(repoPath, "gitfamiliar-report.html");
|
|
485
|
+
writeFileSync(outputPath, html, "utf-8");
|
|
486
|
+
console.log(`Report generated: ${outputPath}`);
|
|
487
|
+
await openBrowser(outputPath);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/cli/index.ts
|
|
491
|
+
function createProgram() {
|
|
492
|
+
const program2 = new Command();
|
|
493
|
+
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.0").option("-m, --mode <mode>", "Scoring mode: binary, authorship, review-coverage, weighted", "binary").option("-u, --user <user>", "Git user name or email (defaults to git config)").option("-f, --filter <filter>", "Filter mode: all, written, reviewed", "all").option("-e, --expiration <policy>", "Expiration policy: never, time:180d, change:50%, combined:365d:50%", "never").option("--html", "Generate HTML treemap report", false).option("-w, --weights <weights>", 'Weights for weighted mode: blame,commit,review (e.g., "0.5,0.35,0.15")').action(async (rawOptions) => {
|
|
494
|
+
try {
|
|
495
|
+
const repoPath = process.cwd();
|
|
496
|
+
const options = parseOptions(rawOptions, repoPath);
|
|
497
|
+
const result = await computeFamiliarity(options);
|
|
498
|
+
if (options.html) {
|
|
499
|
+
await generateAndOpenHTML(result, repoPath);
|
|
500
|
+
} else {
|
|
501
|
+
renderTerminal(result);
|
|
502
|
+
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.error(`Error: ${error.message}`);
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
return program2;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// bin/gitfamiliar.ts
|
|
512
|
+
var program = createProgram();
|
|
513
|
+
program.parse();
|
|
514
|
+
//# sourceMappingURL=gitfamiliar.js.map
|