locmeter 0.1.2 → 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
@@ -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 = { resolved: [], missing: repoNames, root: candidates[0] };
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();
@@ -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(repoNames, args.root);
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locmeter",
3
- "version": "0.1.2",
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,