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 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
- root: process.cwd(),
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 } = resolveRepoPaths(repoNames, root);
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locmeter",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Render a PNG chart of lines changed over time from your GitHub contribution repos.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,