@yaroslavhaidash/gitstats-cli 0.2.0 → 0.3.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/README.md +2 -2
- package/dist/cli.js +110 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Counts commits and lines in the git repos on your computer and sends **only the numbers** (per repo, per week) to your [gitstats](https://gitstats-three-zeta.vercel.app) profile. No file contents, no diffs, no GitHub tokens, no permissions on GitHub.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx
|
|
6
|
+
npx @yaroslavhaidash/gitstats-cli@latest link
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
That pairs this computer (you confirm in the browser), scans your home folder for repos, uploads the last year, and installs a daily background sync (launchd on macOS, Task Scheduler on Windows, systemd user timer on Linux). Nothing else to remember.
|
|
@@ -14,4 +14,4 @@ What leaves the machine, per repo: an HMAC-SHA256 of the normalised remote URL k
|
|
|
14
14
|
|
|
15
15
|
You are trusting the operator: `npx github:…` runs the committed `dist/` from this repo. Read `src/cli.ts` (~400 lines) or watch the payload with a proxy.
|
|
16
16
|
|
|
17
|
-
Commands: `sync` · `status` · `add <path>` · `roots add <dir>` · `emails add <email>` · `names on|off` · `unlink` (also revokes server-side). Config lives in `~/.gitstats/config.json` (mode 600).
|
|
17
|
+
Commands: `sync` · `status` · `add <path>` · `roots add <dir>` · `emails add <email>` · `names on|off` · `update` · `unlink` (also revokes server-side). It keeps itself up to date: every sync checks npm at most once a day and installs a newer version before finishing, `sync --no-update` skips that. Config lives in `~/.gitstats/config.json` (mode 600).
|
package/dist/cli.js
CHANGED
|
@@ -3,22 +3,23 @@
|
|
|
3
3
|
* gitstats CLI — counts commits and lines in the git repos on this machine and sends ONLY the
|
|
4
4
|
* numbers (per repo, per week) to your gitstats profile. No file contents, no diffs, no GitHub tokens.
|
|
5
5
|
*
|
|
6
|
-
* npx
|
|
6
|
+
* npx @yaroslavhaidash/gitstats-cli@latest link
|
|
7
7
|
* pair this computer, scan for repos, sync, install a daily sync
|
|
8
|
-
* gitstats sync fetch each repo's default branch, recount the last year, upload (idempotent;
|
|
8
|
+
* gitstats sync fetch each repo's default branch, recount the last year, upload (idempotent;
|
|
9
|
+
* --no-fetch skips the fetch, --no-update skips the daily version check)
|
|
9
10
|
* gitstats status show what is linked and when it last ran
|
|
10
11
|
* gitstats add <path> track a repo outside the scanned folders
|
|
11
12
|
* gitstats roots add <dir> scan another folder (e.g. one outside your home directory)
|
|
12
13
|
* gitstats emails add <e> attribute commits made with another email to you
|
|
13
14
|
* gitstats names on|off also send repo names (off by default; your own page then labels private repos by hash)
|
|
14
15
|
* gitstats pause | resume stop / restart the background sync without unlinking
|
|
15
|
-
* gitstats update fetch the latest published version
|
|
16
|
+
* gitstats update fetch the latest published version now (sync does this on its own, once a day)
|
|
16
17
|
* gitstats unlink revoke this computer and remove the schedule and local config
|
|
17
18
|
*/
|
|
18
19
|
import { spawnSync } from "node:child_process";
|
|
19
20
|
import { createHmac } from "node:crypto";
|
|
20
21
|
import { createInterface } from "node:readline/promises";
|
|
21
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
|
22
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync, cpSync } from "node:fs";
|
|
22
23
|
import { homedir, hostname, platform, tmpdir } from "node:os";
|
|
23
24
|
import { basename, dirname, join, resolve } from "node:path";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -29,12 +30,27 @@ const DIR = join(HOME, ".gitstats");
|
|
|
29
30
|
const CONFIG = join(DIR, "config.json");
|
|
30
31
|
const SELF = join(DIR, "cli");
|
|
31
32
|
const DAYS = 365;
|
|
33
|
+
const PENDING_DAYS = 30;
|
|
34
|
+
const SYNC_LOG = join(DIR, "sync.log");
|
|
35
|
+
const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
const REGISTRY = `https://registry.npmjs.org/${PKG.replace("/", "%2F")}/latest`;
|
|
32
37
|
const SKIP_DIRS = new Set(["node_modules", "Library", "Applications", ".Trash", "vendor", "target", "build", "dist", ".venv", "venv", "__pycache__", "Pods", "DerivedData", "go", ".cargo", ".rustup", ".npm", ".cache", ".local", "snap", "AppData"]);
|
|
33
38
|
const args = process.argv.slice(2);
|
|
34
39
|
const cmd = args[0] ?? "help";
|
|
35
40
|
function log(msg) {
|
|
36
41
|
console.log(msg);
|
|
37
42
|
}
|
|
43
|
+
/** Background runs already redirect their output here; a manual run appends directly so a failed
|
|
44
|
+
* update check leaves the same trail either way. */
|
|
45
|
+
function logFile(msg) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(DIR, { recursive: true });
|
|
48
|
+
appendFileSync(SYNC_LOG, `${new Date().toISOString()} ${msg}\n`);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* the log is best effort; it must never break a sync */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
38
54
|
function loadConfig() {
|
|
39
55
|
if (!existsSync(CONFIG))
|
|
40
56
|
return null;
|
|
@@ -127,6 +143,30 @@ function defaultRef(repo) {
|
|
|
127
143
|
}
|
|
128
144
|
return "HEAD";
|
|
129
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Remote branches whose work has not landed on the default branch yet. Only `refs/remotes/origin`
|
|
148
|
+
* counts, and only tips touched in the last 30 days: local branches left behind by squash-merged
|
|
149
|
+
* worktrees are the same commits over again, and they inflated pending by 14x.
|
|
150
|
+
*/
|
|
151
|
+
function pendingRefs(repo, ref) {
|
|
152
|
+
const out = git(repo, "for-each-ref", "--format=%(refname) %(committerdate:unix)", "refs/remotes/origin");
|
|
153
|
+
if (out === null)
|
|
154
|
+
return [];
|
|
155
|
+
const cutoff = Date.now() / 1000 - PENDING_DAYS * 86_400;
|
|
156
|
+
const refs = [];
|
|
157
|
+
for (const line of out.split("\n")) {
|
|
158
|
+
const [name, when] = line.split(" ");
|
|
159
|
+
if (!name || !when)
|
|
160
|
+
continue;
|
|
161
|
+
const short = name.slice("refs/remotes/".length);
|
|
162
|
+
if (short === ref || short === "origin/HEAD")
|
|
163
|
+
continue;
|
|
164
|
+
if (Number(when) < cutoff)
|
|
165
|
+
continue;
|
|
166
|
+
refs.push(name);
|
|
167
|
+
}
|
|
168
|
+
return refs;
|
|
169
|
+
}
|
|
130
170
|
// ---------- counting ----------
|
|
131
171
|
const LANG = {
|
|
132
172
|
ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
|
|
@@ -199,8 +239,8 @@ function countRepo(repo, emails, since, salt, sendNames, fetch) {
|
|
|
199
239
|
const out = git(repo, "log", ref, ...common, ...authors);
|
|
200
240
|
if (out === null)
|
|
201
241
|
return null;
|
|
202
|
-
|
|
203
|
-
const pendingOut = git(repo, "log",
|
|
242
|
+
const unmerged = pendingRefs(repo, ref);
|
|
243
|
+
const pendingOut = unmerged.length > 0 ? git(repo, "log", ...unmerged, "--not", ref, ...common, ...authors) : null;
|
|
204
244
|
const merged = tally(out, all);
|
|
205
245
|
const pending = pendingOut === null ? EMPTY_TALLY() : tally(pendingOut, all);
|
|
206
246
|
if (merged.weeks.size === 0 && pending.weeks.size === 0)
|
|
@@ -269,7 +309,7 @@ function count(c, since) {
|
|
|
269
309
|
}
|
|
270
310
|
async function upload(c, reports) {
|
|
271
311
|
const payload = reports.map(({ remoteHash, name, language, weeks, days, pending }) => ({ remoteHash, name, language, weeks, days, pending }));
|
|
272
|
-
const { status, body } = await post(c.server, "/api/ingest", { repos: payload }, c.token);
|
|
312
|
+
const { status, body } = await post(c.server, "/api/ingest", { cliVersion: runningVersion(), repos: payload }, c.token);
|
|
273
313
|
if (status !== 200 || !body) {
|
|
274
314
|
c.lastSync = { at: new Date().toISOString(), repos: 0, weeks: 0, error: `server answered ${status}` };
|
|
275
315
|
saveConfig(c);
|
|
@@ -277,9 +317,14 @@ async function upload(c, reports) {
|
|
|
277
317
|
}
|
|
278
318
|
c.lastSync = { at: new Date().toISOString(), repos: body.repos, weeks: body.weeks };
|
|
279
319
|
saveConfig(c);
|
|
320
|
+
if (body.outdated)
|
|
321
|
+
log(`this computer runs ${runningVersion()}; the board expects ${body.minVersion} or newer — run \`gitstats update\``);
|
|
280
322
|
return body;
|
|
281
323
|
}
|
|
282
324
|
async function sync(c, quiet = false) {
|
|
325
|
+
const restarted = await selfUpdate(c);
|
|
326
|
+
if (restarted !== null)
|
|
327
|
+
process.exit(restarted);
|
|
283
328
|
const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
284
329
|
const { scanned, reports } = count(c, since);
|
|
285
330
|
const body = await upload(c, reports);
|
|
@@ -311,9 +356,9 @@ function writeShim() {
|
|
|
311
356
|
}
|
|
312
357
|
return script;
|
|
313
358
|
}
|
|
314
|
-
function
|
|
359
|
+
function readVersion(pkgRoot) {
|
|
315
360
|
try {
|
|
316
|
-
const pkg = JSON.parse(readFileSync(join(
|
|
361
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
317
362
|
const v = typeof pkg === "object" && pkg !== null ? pkg.version : null;
|
|
318
363
|
return typeof v === "string" ? v : "unknown";
|
|
319
364
|
}
|
|
@@ -321,6 +366,24 @@ function installedVersion() {
|
|
|
321
366
|
return "unknown";
|
|
322
367
|
}
|
|
323
368
|
}
|
|
369
|
+
function installedVersion() {
|
|
370
|
+
return readVersion(SELF);
|
|
371
|
+
}
|
|
372
|
+
/** The copy that is executing right now — an npx run is not the installed one. */
|
|
373
|
+
function runningVersion() {
|
|
374
|
+
return readVersion(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
375
|
+
}
|
|
376
|
+
/** Numeric semver compare; a pre-release suffix is ignored, this package never ships one. */
|
|
377
|
+
function isNewer(candidate, current) {
|
|
378
|
+
const parts = (v) => v.split(/[-+]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
379
|
+
const a = parts(candidate);
|
|
380
|
+
const b = parts(current);
|
|
381
|
+
for (let i = 0; i < 3; i++) {
|
|
382
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
383
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
324
387
|
/**
|
|
325
388
|
* Replace ~/.gitstats/cli with the latest published package. The schedule points at an absolute
|
|
326
389
|
* path inside it and the config lives beside it, so neither is touched.
|
|
@@ -363,6 +426,41 @@ function update() {
|
|
|
363
426
|
rmSync(tmp, { recursive: true, force: true });
|
|
364
427
|
}
|
|
365
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Asks the registry for a newer version at most once a day, installs it and restarts the sync under
|
|
431
|
+
* it. Nothing here may fail a sync: offline, a registry error or a bad download is one log line and
|
|
432
|
+
* the current version carries on. `--no-update` skips the check entirely.
|
|
433
|
+
* Returns the exit code of the restarted sync, or null when this process should carry on itself.
|
|
434
|
+
*/
|
|
435
|
+
async function selfUpdate(c) {
|
|
436
|
+
if (args.includes("--no-update"))
|
|
437
|
+
return null;
|
|
438
|
+
const last = c.lastUpdateCheck ? Date.parse(c.lastUpdateCheck) : 0;
|
|
439
|
+
if (Number.isFinite(last) && Date.now() - last < UPDATE_EVERY_MS)
|
|
440
|
+
return null;
|
|
441
|
+
// Stamped before the fetch, so a registry that is down is retried tomorrow and not every sync.
|
|
442
|
+
c.lastUpdateCheck = new Date().toISOString();
|
|
443
|
+
saveConfig(c);
|
|
444
|
+
const current = runningVersion();
|
|
445
|
+
try {
|
|
446
|
+
const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(5000) });
|
|
447
|
+
if (!res.ok)
|
|
448
|
+
throw new Error(`registry answered ${res.status}`);
|
|
449
|
+
const meta = await res.json();
|
|
450
|
+
const latest = typeof meta === "object" && meta !== null ? meta.version : null;
|
|
451
|
+
if (typeof latest !== "string")
|
|
452
|
+
throw new Error("registry sent no version");
|
|
453
|
+
if (!isNewer(latest, current))
|
|
454
|
+
return null;
|
|
455
|
+
update();
|
|
456
|
+
const fresh = spawnSync(process.execPath, [join(SELF, "dist", "cli.js"), "sync", "--quiet"], { stdio: "inherit" });
|
|
457
|
+
return fresh.status ?? 1;
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
logFile(`update check failed (${e instanceof Error ? e.message : String(e)}); staying on ${current}`);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
366
464
|
function installSchedule() {
|
|
367
465
|
const script = installSelf();
|
|
368
466
|
const node = process.execPath;
|
|
@@ -493,14 +591,14 @@ async function link() {
|
|
|
493
591
|
log(`\n scheduled: ${how}`);
|
|
494
592
|
log(` config: ${CONFIG}`);
|
|
495
593
|
log(`\n done. It re-syncs on its own. To run commands by hand, either use`);
|
|
496
|
-
log(` npx
|
|
594
|
+
log(` npx @yaroslavhaidash/gitstats-cli@latest <command>`);
|
|
497
595
|
log(` or add ${BIN} to your PATH and use \`gitstats <command>\`.`);
|
|
498
596
|
log(` Commands and how to stop: ${server}/docs\n`);
|
|
499
597
|
}
|
|
500
598
|
function requireConfig() {
|
|
501
599
|
const c = loadConfig();
|
|
502
600
|
if (!c)
|
|
503
|
-
throw new Error("not linked yet; run: npx
|
|
601
|
+
throw new Error("not linked yet; run: npx @yaroslavhaidash/gitstats-cli@latest link");
|
|
504
602
|
return c;
|
|
505
603
|
}
|
|
506
604
|
async function main() {
|
|
@@ -586,7 +684,7 @@ async function main() {
|
|
|
586
684
|
return;
|
|
587
685
|
}
|
|
588
686
|
default:
|
|
589
|
-
log("usage: gitstats <link [--root <dir>]... [--yes] | sync | status | add <path> | roots add <dir> | emails add <email> | names on|off | pause | resume | update | unlink>");
|
|
687
|
+
log("usage: gitstats <link [--root <dir>]... [--yes] | sync [--no-fetch] [--no-update] | status | add <path> | roots add <dir> | emails add <email> | names on|off | pause | resume | update | unlink>");
|
|
590
688
|
}
|
|
591
689
|
}
|
|
592
690
|
main().catch((e) => {
|