@profullstack/g1tz 0.2.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/src/pulse.ts ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Pulse: what moved in this repository over a period, read from git.
3
+ *
4
+ * The same idea as GitHub's Insights > Pulse tab, worked out locally so it is
5
+ * there offline and for repositories that are not on GitHub at all: commits
6
+ * and authors on the current branch and across every branch, the net change
7
+ * to the tree since the period began, the branches that saw commits and the
8
+ * tags that were created. GitHub's own side (pull requests, issues, stars and
9
+ * releases) is an optional, separate read in github.ts; views and clones come
10
+ * from a gh-pulse report when one exists (traffic.ts).
11
+ *
12
+ * Everything here is read-only, which is why the file list can afford
13
+ * display-grade path handling: a munged path on this screen cannot touch the
14
+ * working tree, unlike one in the Files pane.
15
+ */
16
+ import { git, type GitError } from "./git.ts";
17
+
18
+ export const RANGE_KEYS = ["day", "week", "month", "quarter", "year", "all"] as const;
19
+ export type RangeKey = (typeof RANGE_KEYS)[number];
20
+
21
+ const DAY_MS = 86_400_000;
22
+ export const RANGE_MS: Record<RangeKey, number> = {
23
+ day: DAY_MS,
24
+ week: 7 * DAY_MS,
25
+ month: 30 * DAY_MS,
26
+ quarter: 91 * DAY_MS,
27
+ year: 365 * DAY_MS,
28
+ all: Number.POSITIVE_INFINITY,
29
+ };
30
+ export const RANGE_LABEL: Record<RangeKey, string> = {
31
+ day: "last 24 hours",
32
+ week: "last week",
33
+ month: "last month",
34
+ quarter: "last quarter",
35
+ year: "last year",
36
+ all: "all time",
37
+ };
38
+ export const RANGE_HOTKEY: Record<RangeKey, string> = { day: "d", week: "w", month: "m", quarter: "q", year: "y", all: "a" };
39
+
40
+ const RANGE_ALIASES: Record<string, RangeKey> = {
41
+ "24h": "day", "1d": "day", today: "day",
42
+ "7d": "week", "1w": "week",
43
+ "30d": "month", "1m": "month",
44
+ "90d": "quarter", "3m": "quarter",
45
+ "365d": "year", "1y": "year", "12m": "year",
46
+ ever: "all", forever: "all",
47
+ };
48
+
49
+ export function parseRangeKey(text: string): RangeKey | null {
50
+ const key = text.trim().toLowerCase();
51
+ if ((RANGE_KEYS as readonly string[]).includes(key)) return key as RangeKey;
52
+ return RANGE_ALIASES[key] ?? null;
53
+ }
54
+
55
+ export function rangeForHotkey(key: string): RangeKey | null {
56
+ return RANGE_KEYS.find((k) => RANGE_HOTKEY[k] === key) ?? null;
57
+ }
58
+
59
+ /** The start of a range, or null for all time. */
60
+ export function sinceFor(range: RangeKey, now: Date): Date | null {
61
+ return range === "all" ? null : new Date(now.getTime() - RANGE_MS[range]);
62
+ }
63
+
64
+ export interface PulseCommit {
65
+ hash: string;
66
+ short: string;
67
+ author: string;
68
+ email: string;
69
+ /** Committer date, ISO 8601. --since selects on it, so a bucket never falls outside the range. */
70
+ at: string;
71
+ subject: string;
72
+ merge: boolean;
73
+ }
74
+
75
+ export interface PulseAuthor {
76
+ name: string;
77
+ commits: number;
78
+ }
79
+
80
+ export interface PulseFile {
81
+ path: string;
82
+ /** Original path for a rename. */
83
+ from?: string;
84
+ added: number;
85
+ deleted: number;
86
+ binary: boolean;
87
+ }
88
+
89
+ export interface PulseRef {
90
+ name: string;
91
+ at: string;
92
+ }
93
+
94
+ export type BucketUnit = "hour" | "day" | "week" | "month";
95
+
96
+ export interface Bucket {
97
+ label: string;
98
+ /** ISO start of the bucket. */
99
+ start: string;
100
+ count: number;
101
+ }
102
+
103
+ export interface Pulse {
104
+ range: RangeKey;
105
+ /** ISO start of the period, or null for all time. */
106
+ since: string | null;
107
+ until: string;
108
+ branch: string;
109
+ /** Every commit reachable from HEAD in the period, merges included, newest first. */
110
+ commits: PulseCommit[];
111
+ commitsTruncated: boolean;
112
+ /** Commits on every local branch in the period, merges excluded. */
113
+ allBranchCommits: number;
114
+ /** Merges excluded, busiest first. */
115
+ authors: PulseAuthor[];
116
+ filesChanged: number;
117
+ additions: number;
118
+ deletions: number;
119
+ /** The net change per file since the period began, biggest first. */
120
+ files: PulseFile[];
121
+ unit: BucketUnit;
122
+ buckets: Bucket[];
123
+ /** Local branches that received a commit in the period. */
124
+ branches: PulseRef[];
125
+ /** Tags created in the period. */
126
+ tags: PulseRef[];
127
+ errors: GitError[];
128
+ }
129
+
130
+ // ---------------------------------------------------------------- parsers
131
+
132
+ const LOG_FORMAT = "%H%x1f%h%x1f%an%x1f%ae%x1f%cI%x1f%P%x1f%s";
133
+
134
+ /** `git log --format=<fields>%x00`: one NUL-terminated record per commit, fields separated by 0x1f. */
135
+ export function parsePulseLog(out: string): PulseCommit[] {
136
+ const commits: PulseCommit[] = [];
137
+ for (const raw of out.split("\0")) {
138
+ // A newline between records is separator noise, not part of the hash.
139
+ const record = raw.replace(/^\n/, "");
140
+ if (record === "") continue;
141
+ const [hash, short, author, email, at, parents, subject] = record.split("\x1f");
142
+ if (!hash) continue;
143
+ commits.push({
144
+ hash,
145
+ short: short ?? "",
146
+ author: author ?? "",
147
+ email: email ?? "",
148
+ at: at ?? "",
149
+ subject: subject ?? "",
150
+ merge: (parents ?? "").split(" ").filter(Boolean).length > 1,
151
+ });
152
+ }
153
+ return commits;
154
+ }
155
+
156
+ /**
157
+ * `git diff --numstat -z`: `added TAB deleted TAB path NUL`. A rename is
158
+ * `added TAB deleted TAB NUL old NUL new NUL`, and a binary file has `-` for
159
+ * both counts. Paths are raw, so one with a newline in it still parses.
160
+ */
161
+ export function parseNumstat(out: string): PulseFile[] {
162
+ const files: PulseFile[] = [];
163
+ const tokens = out.split("\0");
164
+ for (let i = 0; i < tokens.length; i++) {
165
+ const token = tokens[i] as string;
166
+ if (token === "") continue;
167
+ const m = /^(\d+|-)\t(\d+|-)\t([^]*)$/.exec(token);
168
+ if (!m) continue;
169
+ const binary = m[1] === "-";
170
+ const added = binary ? 0 : Number(m[1]);
171
+ const deleted = binary ? 0 : Number(m[2]);
172
+ if (m[3] === "") {
173
+ // A rename: the two paths follow as their own records.
174
+ const from = tokens[++i] ?? "";
175
+ const path = tokens[++i] ?? "";
176
+ files.push({ path, from, added, deleted, binary });
177
+ } else {
178
+ files.push({ path: m[3] as string, added, deleted, binary });
179
+ }
180
+ }
181
+ return files;
182
+ }
183
+
184
+ /** `git diff --shortstat`: " 3 files changed, 12 insertions(+), 4 deletions(-)", any part of which may be missing. */
185
+ export function parseShortstat(out: string): { filesChanged: number; additions: number; deletions: number } {
186
+ const files = /(\d+) files? changed/.exec(out);
187
+ const ins = /(\d+) insertions?\(\+\)/.exec(out);
188
+ const del = /(\d+) deletions?\(-\)/.exec(out);
189
+ return {
190
+ filesChanged: files ? Number(files[1]) : 0,
191
+ additions: ins ? Number(ins[1]) : 0,
192
+ deletions: del ? Number(del[1]) : 0,
193
+ };
194
+ }
195
+
196
+ /** `git for-each-ref --format=%(refname:short)%1f%(<date>:iso-strict)`, one ref per line. */
197
+ export function parseRefDates(out: string): PulseRef[] {
198
+ const refs: PulseRef[] = [];
199
+ for (const line of out.split("\n")) {
200
+ if (line.trim() === "") continue;
201
+ const [name, at] = line.split("\x1f");
202
+ if (name) refs.push({ name, at: at ?? "" });
203
+ }
204
+ return refs;
205
+ }
206
+
207
+ export function refsSince(refs: PulseRef[], since: Date | null): PulseRef[] {
208
+ if (!since) return refs;
209
+ return refs.filter((r) => Date.parse(r.at) >= since.getTime());
210
+ }
211
+
212
+ /** Merges excluded, as GitHub counts them; busiest first, then by name. */
213
+ export function countAuthors(commits: readonly PulseCommit[]): PulseAuthor[] {
214
+ const counts = new Map<string, number>();
215
+ for (const c of commits) {
216
+ if (c.merge) continue;
217
+ counts.set(c.author, (counts.get(c.author) ?? 0) + 1);
218
+ }
219
+ return [...counts]
220
+ .map(([name, n]) => ({ name, commits: n }))
221
+ .sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name));
222
+ }
223
+
224
+ // ---------------------------------------------------------------- buckets
225
+
226
+ export function unitFor(range: RangeKey): BucketUnit {
227
+ switch (range) {
228
+ case "day": return "hour";
229
+ case "week": case "month": return "day";
230
+ case "quarter": case "year": return "week";
231
+ default: return "month";
232
+ }
233
+ }
234
+
235
+ /** The start of the bucket holding `time`, in UTC. Weeks start on Monday. */
236
+ function floorTo(time: number, unit: BucketUnit): Date {
237
+ const t = new Date(time);
238
+ t.setUTCMinutes(0, 0, 0);
239
+ if (unit === "hour") return t;
240
+ t.setUTCHours(0);
241
+ if (unit === "day") return t;
242
+ if (unit === "week") {
243
+ t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7));
244
+ return t;
245
+ }
246
+ t.setUTCDate(1);
247
+ return t;
248
+ }
249
+
250
+ function next(t: Date, unit: BucketUnit): Date {
251
+ const n = new Date(t.getTime());
252
+ if (unit === "hour") n.setUTCHours(n.getUTCHours() + 1);
253
+ else if (unit === "day") n.setUTCDate(n.getUTCDate() + 1);
254
+ else if (unit === "week") n.setUTCDate(n.getUTCDate() + 7);
255
+ else n.setUTCMonth(n.getUTCMonth() + 1);
256
+ return n;
257
+ }
258
+
259
+ function labelFor(t: Date, unit: BucketUnit): string {
260
+ const iso = t.toISOString();
261
+ if (unit === "hour") return `${iso.slice(11, 13)}:00`;
262
+ if (unit === "month") return iso.slice(0, 7);
263
+ return iso.slice(5, 10);
264
+ }
265
+
266
+ /** More buckets than this and the range is not one anybody can read; the newest ones win. */
267
+ export const BUCKET_CAP = 2000;
268
+
269
+ /**
270
+ * Contiguous buckets from the start of the period (or the first commit, for
271
+ * all time) to now, with the commits counted into them. Every bucket is
272
+ * present even when empty, so a quiet week shows as a gap rather than
273
+ * vanishing.
274
+ */
275
+ export function bucketCommits(commits: readonly PulseCommit[], since: Date | null, until: Date, unit: BucketUnit): Bucket[] {
276
+ const times: number[] = [];
277
+ let earliest = Number.POSITIVE_INFINITY;
278
+ for (const c of commits) {
279
+ const t = Date.parse(c.at);
280
+ if (!Number.isFinite(t)) continue;
281
+ times.push(t);
282
+ if (t < earliest) earliest = t;
283
+ }
284
+ const from = since ? since.getTime() : times.length ? earliest : until.getTime();
285
+ const buckets: Bucket[] = [];
286
+ const starts: number[] = [];
287
+ for (let t = floorTo(from, unit); t.getTime() <= until.getTime() && buckets.length < BUCKET_CAP; t = next(t, unit)) {
288
+ buckets.push({ label: labelFor(t, unit), start: t.toISOString(), count: 0 });
289
+ starts.push(t.getTime());
290
+ }
291
+ for (const time of times) {
292
+ // The last bucket starting at or before this commit; a commit from the
293
+ // future (a wrong clock) lands in the newest one.
294
+ let lo = 0;
295
+ let hi = starts.length - 1;
296
+ let idx = -1;
297
+ while (lo <= hi) {
298
+ const mid = (lo + hi) >> 1;
299
+ if ((starts[mid] as number) <= time) { idx = mid; lo = mid + 1; } else hi = mid - 1;
300
+ }
301
+ if (idx >= 0) (buckets[idx] as Bucket).count += 1;
302
+ }
303
+ return buckets;
304
+ }
305
+
306
+ /** At most `max` buckets, merging neighbours; a group takes the label of its first. */
307
+ export function foldBuckets(buckets: readonly Bucket[], max: number): Bucket[] {
308
+ if (max < 1 || buckets.length <= max) return [...buckets];
309
+ const per = Math.ceil(buckets.length / max);
310
+ const out: Bucket[] = [];
311
+ for (let i = 0; i < buckets.length; i += per) {
312
+ const group = buckets.slice(i, i + per);
313
+ const first = group[0] as Bucket;
314
+ out.push({ label: first.label, start: first.start, count: group.reduce((t, b) => t + b.count, 0) });
315
+ }
316
+ return out;
317
+ }
318
+
319
+ // ---------------------------------------------------------------- reading
320
+
321
+ /** The hash of the empty tree, so the diff for all time (or from a root commit) has something to diff against. */
322
+ export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
323
+ export const COMMIT_CAP = 50_000;
324
+ export const FILE_CAP = 500;
325
+
326
+ const churn = (f: PulseFile): number => f.added + f.deleted;
327
+
328
+ export function readPulse(root: string, range: RangeKey, now: Date = new Date()): Pulse {
329
+ const since = sinceFor(range, now);
330
+ const sinceArgs = since ? [`--since=${since.toISOString()}`] : [];
331
+ const errors: GitError[] = [];
332
+ const run = (args: string[]): string => {
333
+ const out = git(root, args);
334
+ if (out === null) {
335
+ errors.push({ command: `git ${args[0]}`, message: "failed" });
336
+ return "";
337
+ }
338
+ return out;
339
+ };
340
+
341
+ const branch = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])?.trim() ?? "";
342
+ // An unborn HEAD (a repository with no commits yet) fails every read below;
343
+ // the pulse is then simply empty rather than a list of errors.
344
+ const hasHead = git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null;
345
+
346
+ // "--" after HEAD everywhere: a file named HEAD in the root would otherwise
347
+ // make the revision ambiguous. --no-show-signature: with log.showSignature
348
+ // set, git writes the verification text on stdout ahead of every record.
349
+ const commits = hasHead
350
+ ? parsePulseLog(run(["log", "--no-show-signature", `--format=${LOG_FORMAT}%x00`, `--max-count=${COMMIT_CAP}`, ...sinceArgs, "HEAD", "--"]))
351
+ : [];
352
+ // Not gated on HEAD: an orphan branch being born has no HEAD commit while the other branches still moved.
353
+ const allBranchCommits = Number(run(["rev-list", "--branches", "--no-merges", "--count", ...sinceArgs]).trim()) || 0;
354
+
355
+ // The tree at the start of the period is the first parent of the oldest
356
+ // first-parent commit in range: what the branch pointed at before any of
357
+ // this landed, whether it arrived by merge, rebase or fast-forward. All
358
+ // time, and a root commit inside the period, diff from the empty tree. A
359
+ // shallow clone's grafted commit has no parent either, but that is not a
360
+ // root: the tree before the period is simply not in this clone.
361
+ let boundary: string | null = null;
362
+ if (hasHead) {
363
+ if (!since) boundary = EMPTY_TREE;
364
+ else {
365
+ const oldest = run(["rev-list", "--first-parent", ...sinceArgs, "HEAD", "--"]).trim().split("\n").filter(Boolean).at(-1);
366
+ if (oldest) {
367
+ const parent = git(root, ["rev-parse", "--verify", "--quiet", `${oldest}^`])?.trim();
368
+ if (parent) boundary = parent;
369
+ else if (git(root, ["rev-parse", "--is-shallow-repository"])?.trim() === "true") {
370
+ errors.push({ command: "git diff", message: "shallow clone: the tree before the period is not available" });
371
+ } else boundary = EMPTY_TREE;
372
+ }
373
+ }
374
+ }
375
+ let filesChanged = 0;
376
+ let additions = 0;
377
+ let deletions = 0;
378
+ let files: PulseFile[] = [];
379
+ if (boundary) {
380
+ ({ filesChanged, additions, deletions } = parseShortstat(run(["diff", "--shortstat", "-M", boundary, "HEAD", "--"])));
381
+ files = parseNumstat(run(["diff", "--numstat", "-z", "-M", boundary, "HEAD", "--"]))
382
+ .sort((a, b) => churn(b) - churn(a) || a.path.localeCompare(b.path))
383
+ .slice(0, FILE_CAP);
384
+ }
385
+
386
+ const branches = refsSince(parseRefDates(run([
387
+ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%1f%(committerdate:iso-strict)", "refs/heads",
388
+ ])), since);
389
+ const tags = refsSince(parseRefDates(run([
390
+ "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%1f%(creatordate:iso-strict)", "refs/tags",
391
+ ])), since);
392
+
393
+ const unit = unitFor(range);
394
+ return {
395
+ range,
396
+ since: since ? since.toISOString() : null,
397
+ until: now.toISOString(),
398
+ branch,
399
+ commits,
400
+ commitsTruncated: commits.length >= COMMIT_CAP,
401
+ allBranchCommits,
402
+ authors: countAuthors(commits),
403
+ filesChanged,
404
+ additions,
405
+ deletions,
406
+ files,
407
+ unit,
408
+ buckets: bucketCommits(commits, since, now, unit),
409
+ branches,
410
+ tags,
411
+ errors,
412
+ };
413
+ }
package/src/traffic.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Traffic for this repository, from a gh-pulse report.
3
+ *
4
+ * GitHub only serves a repository's views and clones through its traffic API,
5
+ * and only the last fourteen days of them. gh-pulse (profullstack/cli-tools)
6
+ * reads that every day for every repository the login can see and writes a
7
+ * report under ~/.local/share/gh-pulse; when one is there, the Pulse screen
8
+ * draws this repository's series from it. Nothing is fetched here: it is a
9
+ * file read, and a missing file is not an error.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export interface TrafficDay {
16
+ day: string;
17
+ count: number;
18
+ uniques: number;
19
+ }
20
+
21
+ export interface TrafficReferrer {
22
+ referrer: string;
23
+ count: number;
24
+ uniques: number;
25
+ }
26
+
27
+ export interface TrafficPath {
28
+ path: string;
29
+ count: number;
30
+ uniques: number;
31
+ }
32
+
33
+ export interface Traffic {
34
+ repo: string;
35
+ url: string;
36
+ /** When the report was written. */
37
+ reportAt: string;
38
+ stars: number;
39
+ forks: number;
40
+ views14d: TrafficDay[];
41
+ clones14d: TrafficDay[];
42
+ referrers: TrafficReferrer[];
43
+ paths: TrafficPath[];
44
+ }
45
+
46
+ export function ghPulseDataDir(env: NodeJS.ProcessEnv = process.env): string {
47
+ return env["GH_PULSE_DATA"] ?? join(homedir(), ".local", "share", "gh-pulse");
48
+ }
49
+
50
+ export function ghPulseReportPath(dir: string = ghPulseDataDir()): string {
51
+ return join(dir, "out", "latest.json");
52
+ }
53
+
54
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
55
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
56
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object";
57
+
58
+ function days(v: unknown): TrafficDay[] {
59
+ if (!Array.isArray(v)) return [];
60
+ return v.filter(isRecord).map((d) => ({ day: str(d["day"]), count: num(d["count"]), uniques: num(d["uniques"]) }));
61
+ }
62
+
63
+ /**
64
+ * The repository's row out of a gh-pulse report. The report only carries the
65
+ * repositories that moved, so a quiet one is null even when the report is
66
+ * fresh, and every field is read defensively: the report is another tool's
67
+ * output, not this one's.
68
+ */
69
+ export function trafficFromReport(report: unknown, repo: string): Traffic | null {
70
+ if (!isRecord(report) || !Array.isArray(report["movers"])) return null;
71
+ const want = repo.toLowerCase();
72
+ const mover = report["movers"].find((m) => isRecord(m) && str(m["repo"]).toLowerCase() === want);
73
+ if (!isRecord(mover)) return null;
74
+ const name = str(mover["repo"]);
75
+ return {
76
+ repo: name,
77
+ url: str(mover["url"]) || `https://github.com/${name}`,
78
+ reportAt: str(report["at"]),
79
+ stars: num(mover["stars"]),
80
+ forks: num(mover["forks"]),
81
+ views14d: days(mover["views14d"]),
82
+ clones14d: days(mover["clones14d"]),
83
+ referrers: Array.isArray(mover["referrers"])
84
+ ? mover["referrers"].filter(isRecord).map((r) => ({ referrer: str(r["referrer"]), count: num(r["count"]), uniques: num(r["uniques"]) }))
85
+ : [],
86
+ paths: Array.isArray(mover["paths"])
87
+ ? mover["paths"].filter(isRecord).map((p) => ({ path: str(p["path"]), count: num(p["count"]), uniques: num(p["uniques"]) }))
88
+ : [],
89
+ };
90
+ }
91
+
92
+ export function readTraffic(repo: string, dir: string = ghPulseDataDir()): Traffic | null {
93
+ const file = ghPulseReportPath(dir);
94
+ if (!existsSync(file)) return null;
95
+ try {
96
+ return trafficFromReport(JSON.parse(readFileSync(file, "utf8")), repo);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }