locmeter 0.1.2 → 0.1.4
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 +2 -0
- package/bin/locmeter.js +126 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,11 +45,13 @@ Common options:
|
|
|
45
45
|
- default `--from`: one year before `--to`
|
|
46
46
|
- default author identity: auto-detected from your current `gh` login
|
|
47
47
|
- default repo root: current directory, then common roots like `~/Developer`, `~/Code`, `~/Projects`
|
|
48
|
+
- default search depth: `3`
|
|
48
49
|
- `--from YYYY-MM-DD`
|
|
49
50
|
- `--to YYYY-MM-DD`
|
|
50
51
|
- `--days N`
|
|
51
52
|
- `--bucket day|week|month`
|
|
52
53
|
- `--root /path/to/repos`
|
|
54
|
+
- `--search-depth N`
|
|
53
55
|
- `--author-email you@example.com`
|
|
54
56
|
- `--author-name yourname`
|
|
55
57
|
- `--output chart.png`
|
package/bin/locmeter.js
CHANGED
|
@@ -108,6 +108,7 @@ function logStep(message) {
|
|
|
108
108
|
function parseArgs(argv) {
|
|
109
109
|
const args = {
|
|
110
110
|
bucket: "week",
|
|
111
|
+
searchDepth: 3,
|
|
111
112
|
authorEmail: [],
|
|
112
113
|
authorName: [],
|
|
113
114
|
output: "github-lines-changed.png",
|
|
@@ -129,6 +130,7 @@ function parseArgs(argv) {
|
|
|
129
130
|
else if (arg === "--to") args.toDate = next();
|
|
130
131
|
else if (arg === "--bucket") args.bucket = next();
|
|
131
132
|
else if (arg === "--root") args.root = next();
|
|
133
|
+
else if (arg === "--search-depth") args.searchDepth = Number(next());
|
|
132
134
|
else if (arg === "--author-email") args.authorEmail.push(next());
|
|
133
135
|
else if (arg === "--author-name") args.authorName.push(next());
|
|
134
136
|
else if (arg === "--output") args.output = next();
|
|
@@ -147,6 +149,9 @@ function parseArgs(argv) {
|
|
|
147
149
|
if (args.days !== undefined && (!Number.isInteger(args.days) || args.days <= 0)) {
|
|
148
150
|
throw new Error("--days must be a positive integer");
|
|
149
151
|
}
|
|
152
|
+
if (!Number.isInteger(args.searchDepth) || args.searchDepth < 0) {
|
|
153
|
+
throw new Error("--search-depth must be a non-negative integer");
|
|
154
|
+
}
|
|
150
155
|
|
|
151
156
|
return args;
|
|
152
157
|
}
|
|
@@ -161,6 +166,7 @@ function printHelp() {
|
|
|
161
166
|
" --to today",
|
|
162
167
|
" --from one year before --to",
|
|
163
168
|
" --author-email/--author-name auto-detected from current gh login",
|
|
169
|
+
" --search-depth 3",
|
|
164
170
|
"",
|
|
165
171
|
"Options:",
|
|
166
172
|
" --days N",
|
|
@@ -168,6 +174,7 @@ function printHelp() {
|
|
|
168
174
|
" --to YYYY-MM-DD",
|
|
169
175
|
" --bucket day|week|month",
|
|
170
176
|
" --root /path/to/repos",
|
|
177
|
+
" --search-depth N",
|
|
171
178
|
" --author-email you@example.com",
|
|
172
179
|
" --author-name yourname",
|
|
173
180
|
" --output chart.png",
|
|
@@ -277,6 +284,63 @@ function resolveRepoPaths(repoNames, root) {
|
|
|
277
284
|
return { resolved, missing };
|
|
278
285
|
}
|
|
279
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
|
+
|
|
280
344
|
function candidateRoots(explicitRoot) {
|
|
281
345
|
if (explicitRoot) return [path.resolve(explicitRoot)];
|
|
282
346
|
const home = os.homedir();
|
|
@@ -290,19 +354,69 @@ function candidateRoots(explicitRoot) {
|
|
|
290
354
|
return [...new Set(values.map((value) => path.resolve(value)))];
|
|
291
355
|
}
|
|
292
356
|
|
|
293
|
-
function resolveRepoPathsFromCandidates(repoNames, explicitRoot) {
|
|
357
|
+
async function resolveRepoPathsFromCandidates(repoNames, explicitRoot, searchDepth) {
|
|
294
358
|
const candidates = candidateRoots(explicitRoot);
|
|
295
|
-
let best = {
|
|
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
|
+
|
|
296
368
|
for (const root of candidates) {
|
|
297
369
|
const result = resolveRepoPaths(repoNames, root);
|
|
298
370
|
if (result.resolved.length > best.resolved.length) {
|
|
299
|
-
best = { ...result, root };
|
|
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 };
|
|
300
400
|
}
|
|
301
|
-
if (result.resolved.length === repoNames.length) return { ...result, root };
|
|
302
401
|
}
|
|
303
402
|
return best;
|
|
304
403
|
}
|
|
305
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
|
+
|
|
306
420
|
async function autodetectAuthorIdentities(repoPaths, login) {
|
|
307
421
|
const pairs = new Map();
|
|
308
422
|
const loginLower = login.toLowerCase();
|
|
@@ -609,7 +723,7 @@ function formatCompact(value) {
|
|
|
609
723
|
}
|
|
610
724
|
|
|
611
725
|
function formatGrouped(value) {
|
|
612
|
-
return
|
|
726
|
+
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
|
613
727
|
}
|
|
614
728
|
|
|
615
729
|
function xLabel(date, bucket) {
|
|
@@ -703,12 +817,14 @@ async function main() {
|
|
|
703
817
|
const login = await getLogin();
|
|
704
818
|
logStep(`Fetching contributed repositories for ${login}...`);
|
|
705
819
|
const repoNames = await getRepositories(login);
|
|
706
|
-
const { resolved: repoPaths, missing, root } = resolveRepoPathsFromCandidates(
|
|
820
|
+
const { resolved: repoPaths, missing, root, searchedRoots } = await resolveRepoPathsFromCandidates(
|
|
821
|
+
repoNames,
|
|
822
|
+
args.root,
|
|
823
|
+
args.searchDepth
|
|
824
|
+
);
|
|
707
825
|
|
|
708
826
|
if (!repoPaths.length) {
|
|
709
|
-
throw new Error(
|
|
710
|
-
`could not find any locally cloned contributed repos under ${root}; pass --root to the directory that contains your repo clones`
|
|
711
|
-
);
|
|
827
|
+
throw new Error(noReposError({ searchedRoots }, args.root, args.searchDepth));
|
|
712
828
|
}
|
|
713
829
|
|
|
714
830
|
let authorEmails = [...new Set(args.authorEmail)];
|
|
@@ -757,6 +873,7 @@ async function main() {
|
|
|
757
873
|
author_emails: authorEmails,
|
|
758
874
|
author_names: authorNames,
|
|
759
875
|
root_used: root,
|
|
876
|
+
searched_roots: searchedRoots,
|
|
760
877
|
local_repositories_used: repoPaths.map(([name]) => name),
|
|
761
878
|
missing_repositories: missing,
|
|
762
879
|
total_lines_changed: values.reduce((sum, value) => sum + value, 0),
|