locmeter 0.1.1 → 0.1.3
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/README.md +3 -0
- package/bin/locmeter.js +187 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,11 +44,14 @@ Common options:
|
|
|
44
44
|
- default `--to`: today
|
|
45
45
|
- default `--from`: one year before `--to`
|
|
46
46
|
- default author identity: auto-detected from your current `gh` login
|
|
47
|
+
- default repo root: current directory, then common roots like `~/Developer`, `~/Code`, `~/Projects`
|
|
48
|
+
- default search depth: `3`
|
|
47
49
|
- `--from YYYY-MM-DD`
|
|
48
50
|
- `--to YYYY-MM-DD`
|
|
49
51
|
- `--days N`
|
|
50
52
|
- `--bucket day|week|month`
|
|
51
53
|
- `--root /path/to/repos`
|
|
54
|
+
- `--search-depth N`
|
|
52
55
|
- `--author-email you@example.com`
|
|
53
56
|
- `--author-name yourname`
|
|
54
57
|
- `--output chart.png`
|
package/bin/locmeter.js
CHANGED
|
@@ -101,10 +101,14 @@ const MONTHS = {
|
|
|
101
101
|
12: "DEC"
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
function logStep(message) {
|
|
105
|
+
process.stderr.write(`${message}\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
104
108
|
function parseArgs(argv) {
|
|
105
109
|
const args = {
|
|
106
110
|
bucket: "week",
|
|
107
|
-
|
|
111
|
+
searchDepth: 3,
|
|
108
112
|
authorEmail: [],
|
|
109
113
|
authorName: [],
|
|
110
114
|
output: "github-lines-changed.png",
|
|
@@ -126,6 +130,7 @@ function parseArgs(argv) {
|
|
|
126
130
|
else if (arg === "--to") args.toDate = next();
|
|
127
131
|
else if (arg === "--bucket") args.bucket = next();
|
|
128
132
|
else if (arg === "--root") args.root = next();
|
|
133
|
+
else if (arg === "--search-depth") args.searchDepth = Number(next());
|
|
129
134
|
else if (arg === "--author-email") args.authorEmail.push(next());
|
|
130
135
|
else if (arg === "--author-name") args.authorName.push(next());
|
|
131
136
|
else if (arg === "--output") args.output = next();
|
|
@@ -144,6 +149,9 @@ function parseArgs(argv) {
|
|
|
144
149
|
if (args.days !== undefined && (!Number.isInteger(args.days) || args.days <= 0)) {
|
|
145
150
|
throw new Error("--days must be a positive integer");
|
|
146
151
|
}
|
|
152
|
+
if (!Number.isInteger(args.searchDepth) || args.searchDepth < 0) {
|
|
153
|
+
throw new Error("--search-depth must be a non-negative integer");
|
|
154
|
+
}
|
|
147
155
|
|
|
148
156
|
return args;
|
|
149
157
|
}
|
|
@@ -158,6 +166,7 @@ function printHelp() {
|
|
|
158
166
|
" --to today",
|
|
159
167
|
" --from one year before --to",
|
|
160
168
|
" --author-email/--author-name auto-detected from current gh login",
|
|
169
|
+
" --search-depth 3",
|
|
161
170
|
"",
|
|
162
171
|
"Options:",
|
|
163
172
|
" --days N",
|
|
@@ -165,6 +174,7 @@ function printHelp() {
|
|
|
165
174
|
" --to YYYY-MM-DD",
|
|
166
175
|
" --bucket day|week|month",
|
|
167
176
|
" --root /path/to/repos",
|
|
177
|
+
" --search-depth N",
|
|
168
178
|
" --author-email you@example.com",
|
|
169
179
|
" --author-name yourname",
|
|
170
180
|
" --output chart.png",
|
|
@@ -227,6 +237,14 @@ async function runText(command, args, cwd) {
|
|
|
227
237
|
return stdout;
|
|
228
238
|
}
|
|
229
239
|
|
|
240
|
+
async function tryRunText(command, args, cwd) {
|
|
241
|
+
try {
|
|
242
|
+
return await runText(command, args, cwd);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
230
248
|
async function getLogin() {
|
|
231
249
|
const user = await runJson("gh", ["api", "user"]);
|
|
232
250
|
return user.login;
|
|
@@ -266,6 +284,139 @@ function resolveRepoPaths(repoNames, root) {
|
|
|
266
284
|
return { resolved, missing };
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
function normalizeGitHubRemote(remote) {
|
|
288
|
+
return remote
|
|
289
|
+
.trim()
|
|
290
|
+
.replace(/^git\+/, "")
|
|
291
|
+
.replace(/^git@github\.com:/, "https://github.com/")
|
|
292
|
+
.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/")
|
|
293
|
+
.replace(/\.git$/, "")
|
|
294
|
+
.toLowerCase();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function remoteToRepoKey(remote) {
|
|
298
|
+
const normalized = normalizeGitHubRemote(remote);
|
|
299
|
+
const marker = "github.com/";
|
|
300
|
+
const idx = normalized.indexOf(marker);
|
|
301
|
+
if (idx === -1) return null;
|
|
302
|
+
return normalized.slice(idx + marker.length);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function scanForGitRepos(root, maxDepth) {
|
|
306
|
+
const found = [];
|
|
307
|
+
const skip = new Set(["node_modules", ".git", ".next", "dist", "build"]);
|
|
308
|
+
|
|
309
|
+
function walk(current, depth) {
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (entries.some((entry) => entry.name === ".git")) {
|
|
318
|
+
found.push(current);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (depth >= maxDepth) return;
|
|
322
|
+
|
|
323
|
+
for (const entry of entries) {
|
|
324
|
+
if (!entry.isDirectory()) continue;
|
|
325
|
+
if (skip.has(entry.name)) continue;
|
|
326
|
+
walk(path.join(current, entry.name), depth + 1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
walk(root, 0);
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function gitRemoteUrls(repoPath) {
|
|
335
|
+
const output = await tryRunText("git", ["remote", "-v"], repoPath);
|
|
336
|
+
const urls = new Set();
|
|
337
|
+
for (const line of output.split("\n")) {
|
|
338
|
+
const parts = line.trim().split(/\s+/);
|
|
339
|
+
if (parts.length >= 2) urls.add(parts[1]);
|
|
340
|
+
}
|
|
341
|
+
return [...urls];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function candidateRoots(explicitRoot) {
|
|
345
|
+
if (explicitRoot) return [path.resolve(explicitRoot)];
|
|
346
|
+
const home = os.homedir();
|
|
347
|
+
const values = [
|
|
348
|
+
process.cwd(),
|
|
349
|
+
path.join(home, "Developer"),
|
|
350
|
+
path.join(home, "Code"),
|
|
351
|
+
path.join(home, "Projects"),
|
|
352
|
+
home
|
|
353
|
+
];
|
|
354
|
+
return [...new Set(values.map((value) => path.resolve(value)))];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function resolveRepoPathsFromCandidates(repoNames, explicitRoot, searchDepth) {
|
|
358
|
+
const candidates = candidateRoots(explicitRoot);
|
|
359
|
+
let best = {
|
|
360
|
+
resolved: [],
|
|
361
|
+
missing: repoNames,
|
|
362
|
+
root: candidates[0],
|
|
363
|
+
searchedRoots: candidates,
|
|
364
|
+
discoveredRepos: []
|
|
365
|
+
};
|
|
366
|
+
const repoKeys = new Set(repoNames.map((value) => value.toLowerCase()));
|
|
367
|
+
|
|
368
|
+
for (const root of candidates) {
|
|
369
|
+
const result = resolveRepoPaths(repoNames, root);
|
|
370
|
+
if (result.resolved.length > best.resolved.length) {
|
|
371
|
+
best = { ...result, root, searchedRoots: candidates, discoveredRepos: result.resolved.map(([, p]) => p) };
|
|
372
|
+
}
|
|
373
|
+
if (result.resolved.length === repoNames.length) return { ...result, root, searchedRoots: candidates, discoveredRepos: result.resolved.map(([, p]) => p) };
|
|
374
|
+
|
|
375
|
+
const discovered = scanForGitRepos(root, searchDepth);
|
|
376
|
+
const matched = [];
|
|
377
|
+
for (const repoPath of discovered) {
|
|
378
|
+
const remoteUrls = await gitRemoteUrls(repoPath);
|
|
379
|
+
const remoteKeys = remoteUrls.map(remoteToRepoKey).filter(Boolean);
|
|
380
|
+
const base = path.basename(repoPath).toLowerCase();
|
|
381
|
+
|
|
382
|
+
for (const nameWithOwner of repoNames) {
|
|
383
|
+
const repoKey = nameWithOwner.toLowerCase();
|
|
384
|
+
const repoName = repoKey.split("/")[1];
|
|
385
|
+
if (remoteKeys.includes(repoKey) || base === repoName) {
|
|
386
|
+
matched.push([nameWithOwner, repoPath]);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const deduped = new Map(matched.map(([name, repoPath]) => [name, [name, repoPath]]));
|
|
393
|
+
const resolved = [...deduped.values()];
|
|
394
|
+
const missing = repoNames.filter((name) => !deduped.has(name));
|
|
395
|
+
if (resolved.length > best.resolved.length) {
|
|
396
|
+
best = { resolved, missing, root, searchedRoots: candidates, discoveredRepos: discovered };
|
|
397
|
+
}
|
|
398
|
+
if (resolved.length === repoKeys.size) {
|
|
399
|
+
return { resolved, missing, root, searchedRoots: candidates, discoveredRepos: discovered };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return best;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function noReposError(result, explicitRoot, searchDepth) {
|
|
406
|
+
const searched = result.searchedRoots.map((root) => `- ${root}`).join("\n");
|
|
407
|
+
const rootHint = explicitRoot ? `Try increasing --search-depth from ${searchDepth} or point --root at the directory containing your repo clones.` : "Try running from your main projects folder or pass --root to the directory that contains your repo clones.";
|
|
408
|
+
return [
|
|
409
|
+
"Could not match any contributed GitHub repos locally.",
|
|
410
|
+
`Searched these roots (depth ${searchDepth}):`,
|
|
411
|
+
searched,
|
|
412
|
+
"",
|
|
413
|
+
rootHint,
|
|
414
|
+
"Examples:",
|
|
415
|
+
"- npx locmeter --root ~/Developer",
|
|
416
|
+
"- npx locmeter --root ~/Projects --search-depth 5"
|
|
417
|
+
].join("\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
269
420
|
async function autodetectAuthorIdentities(repoPaths, login) {
|
|
270
421
|
const pairs = new Map();
|
|
271
422
|
const loginLower = login.toLowerCase();
|
|
@@ -297,6 +448,15 @@ async function autodetectAuthorIdentities(repoPaths, login) {
|
|
|
297
448
|
return { emails, names };
|
|
298
449
|
}
|
|
299
450
|
|
|
451
|
+
async function gitConfigIdentity() {
|
|
452
|
+
const email = (await tryRunText("git", ["config", "--global", "user.email"])).trim();
|
|
453
|
+
const name = (await tryRunText("git", ["config", "--global", "user.name"])).trim();
|
|
454
|
+
return {
|
|
455
|
+
emails: email ? [email] : [],
|
|
456
|
+
names: name ? [name] : []
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
300
460
|
function escapeRegex(value) {
|
|
301
461
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
302
462
|
}
|
|
@@ -650,27 +810,45 @@ function renderChart(series, login, startDate, endDate, bucket, outputPath) {
|
|
|
650
810
|
async function main() {
|
|
651
811
|
const args = parseArgs(process.argv.slice(2));
|
|
652
812
|
const { startDate, endDate } = computeDates(args);
|
|
653
|
-
const root = path.resolve(args.root);
|
|
654
813
|
const output = path.resolve(args.output);
|
|
655
814
|
const jsonOutput = path.resolve(args.jsonOutput);
|
|
656
815
|
|
|
816
|
+
logStep("Resolving GitHub identity...");
|
|
657
817
|
const login = await getLogin();
|
|
818
|
+
logStep(`Fetching contributed repositories for ${login}...`);
|
|
658
819
|
const repoNames = await getRepositories(login);
|
|
659
|
-
const { resolved: repoPaths, missing } =
|
|
820
|
+
const { resolved: repoPaths, missing, root, searchedRoots } = await resolveRepoPathsFromCandidates(
|
|
821
|
+
repoNames,
|
|
822
|
+
args.root,
|
|
823
|
+
args.searchDepth
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
if (!repoPaths.length) {
|
|
827
|
+
throw new Error(noReposError({ searchedRoots }, args.root, args.searchDepth));
|
|
828
|
+
}
|
|
660
829
|
|
|
661
830
|
let authorEmails = [...new Set(args.authorEmail)];
|
|
662
831
|
let authorNames = [...new Set(args.authorName)];
|
|
663
832
|
|
|
664
833
|
if (!authorEmails.length && !authorNames.length) {
|
|
834
|
+
logStep(`Detecting author identity from ${repoPaths.length} local repos under ${root}...`);
|
|
665
835
|
const detected = await autodetectAuthorIdentities(repoPaths, login);
|
|
666
836
|
authorEmails = detected.emails;
|
|
667
837
|
authorNames = detected.names;
|
|
668
838
|
}
|
|
669
839
|
|
|
840
|
+
if (!authorEmails.length && !authorNames.length) {
|
|
841
|
+
logStep("Falling back to global git identity...");
|
|
842
|
+
const detected = await gitConfigIdentity();
|
|
843
|
+
authorEmails = detected.emails;
|
|
844
|
+
authorNames = [...new Set([login, ...detected.names])];
|
|
845
|
+
}
|
|
846
|
+
|
|
670
847
|
if (!authorEmails.length && !authorNames.length) {
|
|
671
848
|
throw new Error("could not auto-detect your author identity; pass --author-email or --author-name");
|
|
672
849
|
}
|
|
673
850
|
|
|
851
|
+
logStep(`Fetching commits from ${repoPaths.length} repos...`);
|
|
674
852
|
const { series, rawDaily } = await aggregate(
|
|
675
853
|
repoPaths,
|
|
676
854
|
startDate,
|
|
@@ -680,6 +858,7 @@ async function main() {
|
|
|
680
858
|
authorNames
|
|
681
859
|
);
|
|
682
860
|
|
|
861
|
+
logStep(`Crunching numbers for ${args.bucket} buckets from ${dateIso(startDate)} to ${dateIso(endDate)}...`);
|
|
683
862
|
renderChart(series, login, startDate, endDate, args.bucket, output);
|
|
684
863
|
|
|
685
864
|
const values = Object.values(series);
|
|
@@ -693,6 +872,8 @@ async function main() {
|
|
|
693
872
|
bucket: args.bucket,
|
|
694
873
|
author_emails: authorEmails,
|
|
695
874
|
author_names: authorNames,
|
|
875
|
+
root_used: root,
|
|
876
|
+
searched_roots: searchedRoots,
|
|
696
877
|
local_repositories_used: repoPaths.map(([name]) => name),
|
|
697
878
|
missing_repositories: missing,
|
|
698
879
|
total_lines_changed: values.reduce((sum, value) => sum + value, 0),
|
|
@@ -705,6 +886,9 @@ async function main() {
|
|
|
705
886
|
)
|
|
706
887
|
);
|
|
707
888
|
|
|
889
|
+
logStep("Created output files:");
|
|
890
|
+
logStep(` PNG: ${output}`);
|
|
891
|
+
logStep(` JSON: ${jsonOutput}`);
|
|
708
892
|
process.stdout.write(`${output}\n${jsonOutput}\n`);
|
|
709
893
|
}
|
|
710
894
|
|