@f0rbit/overview 0.1.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.
Files changed (68) hide show
  1. package/README.md +242 -0
  2. package/bunfig.toml +7 -0
  3. package/package.json +42 -0
  4. package/packages/core/__tests__/concurrency.test.ts +111 -0
  5. package/packages/core/__tests__/helpers.ts +60 -0
  6. package/packages/core/__tests__/integration/git-status.test.ts +62 -0
  7. package/packages/core/__tests__/integration/scanner.test.ts +140 -0
  8. package/packages/core/__tests__/ocn.test.ts +164 -0
  9. package/packages/core/package.json +13 -0
  10. package/packages/core/src/cache.ts +31 -0
  11. package/packages/core/src/concurrency.ts +44 -0
  12. package/packages/core/src/devpad.ts +61 -0
  13. package/packages/core/src/git-graph.ts +54 -0
  14. package/packages/core/src/git-stats.ts +201 -0
  15. package/packages/core/src/git-status.ts +316 -0
  16. package/packages/core/src/github.ts +286 -0
  17. package/packages/core/src/index.ts +58 -0
  18. package/packages/core/src/ocn.ts +74 -0
  19. package/packages/core/src/scanner.ts +118 -0
  20. package/packages/core/src/types.ts +199 -0
  21. package/packages/core/src/watcher.ts +128 -0
  22. package/packages/core/src/worktree.ts +80 -0
  23. package/packages/core/tsconfig.json +5 -0
  24. package/packages/render/bunfig.toml +8 -0
  25. package/packages/render/jsx-runtime.d.ts +3 -0
  26. package/packages/render/package.json +18 -0
  27. package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +780 -0
  28. package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +304 -0
  29. package/packages/render/src/components/git-graph.tsx +127 -0
  30. package/packages/render/src/components/help-overlay.tsx +108 -0
  31. package/packages/render/src/components/index.ts +7 -0
  32. package/packages/render/src/components/repo-list.tsx +127 -0
  33. package/packages/render/src/components/stats-panel.tsx +116 -0
  34. package/packages/render/src/components/status-badge.tsx +70 -0
  35. package/packages/render/src/components/status-bar.tsx +56 -0
  36. package/packages/render/src/components/widget-container.tsx +286 -0
  37. package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +326 -0
  38. package/packages/render/src/components/widgets/branch-list.tsx +93 -0
  39. package/packages/render/src/components/widgets/commit-activity.tsx +112 -0
  40. package/packages/render/src/components/widgets/devpad-milestones.tsx +88 -0
  41. package/packages/render/src/components/widgets/devpad-tasks.tsx +81 -0
  42. package/packages/render/src/components/widgets/file-changes.tsx +78 -0
  43. package/packages/render/src/components/widgets/git-status.tsx +125 -0
  44. package/packages/render/src/components/widgets/github-ci.tsx +98 -0
  45. package/packages/render/src/components/widgets/github-issues.tsx +101 -0
  46. package/packages/render/src/components/widgets/github-prs.tsx +119 -0
  47. package/packages/render/src/components/widgets/github-release.tsx +73 -0
  48. package/packages/render/src/components/widgets/index.ts +12 -0
  49. package/packages/render/src/components/widgets/recent-commits.tsx +64 -0
  50. package/packages/render/src/components/widgets/registry.ts +23 -0
  51. package/packages/render/src/components/widgets/repo-meta.tsx +80 -0
  52. package/packages/render/src/config/index.ts +104 -0
  53. package/packages/render/src/lib/__tests__/fetch-context.test.ts +200 -0
  54. package/packages/render/src/lib/__tests__/widget-grid.test.ts +665 -0
  55. package/packages/render/src/lib/actions.ts +68 -0
  56. package/packages/render/src/lib/fetch-context.ts +102 -0
  57. package/packages/render/src/lib/filter.ts +94 -0
  58. package/packages/render/src/lib/format.ts +36 -0
  59. package/packages/render/src/lib/use-devpad.ts +167 -0
  60. package/packages/render/src/lib/use-github.ts +75 -0
  61. package/packages/render/src/lib/widget-grid.ts +204 -0
  62. package/packages/render/src/lib/widget-state.ts +96 -0
  63. package/packages/render/src/overview.tsx +16 -0
  64. package/packages/render/src/screens/index.ts +1 -0
  65. package/packages/render/src/screens/main-screen.tsx +410 -0
  66. package/packages/render/src/theme/index.ts +37 -0
  67. package/packages/render/tsconfig.json +9 -0
  68. package/tsconfig.json +23 -0
@@ -0,0 +1,164 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { readOcnStates } from "../src/ocn";
6
+
7
+ describe("readOcnStates", () => {
8
+ let temp_dir: string;
9
+ const original_env = process.env.OCN_STATE_DIR;
10
+
11
+ beforeEach(async () => {
12
+ temp_dir = await mkdtemp(join(tmpdir(), "ocn-test-"));
13
+ process.env.OCN_STATE_DIR = temp_dir;
14
+ });
15
+
16
+ afterEach(async () => {
17
+ if (original_env !== undefined) {
18
+ process.env.OCN_STATE_DIR = original_env;
19
+ } else {
20
+ delete process.env.OCN_STATE_DIR;
21
+ }
22
+ await rm(temp_dir, { recursive: true, force: true });
23
+ });
24
+
25
+ test("returns empty map when state dir is empty", async () => {
26
+ const result = await readOcnStates();
27
+ expect(result.ok).toBe(true);
28
+ if (result.ok) {
29
+ expect(result.value.size).toBe(0);
30
+ }
31
+ });
32
+
33
+ test("returns empty map when state dir does not exist", async () => {
34
+ process.env.OCN_STATE_DIR = join(temp_dir, "nonexistent");
35
+ const result = await readOcnStates();
36
+ expect(result.ok).toBe(true);
37
+ if (result.ok) {
38
+ expect(result.value.size).toBe(0);
39
+ }
40
+ });
41
+
42
+ test("reads valid state file with alive PID", async () => {
43
+ const pid = process.pid; // current process is always alive
44
+ const state = {
45
+ pid,
46
+ directory: "/Users/tom/dev/test-repo",
47
+ project: "test-repo",
48
+ status: "busy",
49
+ last_transition: "2026-01-01T00:00:00Z",
50
+ session_id: "ses_test123",
51
+ };
52
+ await writeFile(join(temp_dir, `${pid}.json`), JSON.stringify(state));
53
+
54
+ const result = await readOcnStates();
55
+ expect(result.ok).toBe(true);
56
+ if (result.ok) {
57
+ expect(result.value.size).toBe(1);
58
+ const entry = result.value.get("/Users/tom/dev/test-repo");
59
+ expect(entry).toBeDefined();
60
+ expect(entry!.pid).toBe(pid);
61
+ expect(entry!.status).toBe("busy");
62
+ expect(entry!.session_id).toBe("ses_test123");
63
+ }
64
+ });
65
+
66
+ test("filters out stale PIDs", async () => {
67
+ const state = {
68
+ pid: 99999999, // very unlikely to be a running process
69
+ directory: "/Users/tom/dev/stale-repo",
70
+ project: "stale-repo",
71
+ status: "busy",
72
+ last_transition: "2026-01-01T00:00:00Z",
73
+ session_id: "ses_stale",
74
+ };
75
+ await writeFile(join(temp_dir, "99999999.json"), JSON.stringify(state));
76
+
77
+ const result = await readOcnStates();
78
+ expect(result.ok).toBe(true);
79
+ if (result.ok) {
80
+ expect(result.value.size).toBe(0);
81
+ }
82
+ });
83
+
84
+ test("skips malformed JSON files", async () => {
85
+ await writeFile(join(temp_dir, "bad.json"), "not json {{{");
86
+ // Also write a valid one with alive PID
87
+ const pid = process.pid;
88
+ const state = {
89
+ pid,
90
+ directory: "/Users/tom/dev/good-repo",
91
+ project: "good-repo",
92
+ status: "idle",
93
+ last_transition: "2026-01-01T00:00:00Z",
94
+ session_id: "ses_good",
95
+ };
96
+ await writeFile(join(temp_dir, `${pid}.json`), JSON.stringify(state));
97
+
98
+ const result = await readOcnStates();
99
+ expect(result.ok).toBe(true);
100
+ if (result.ok) {
101
+ expect(result.value.size).toBe(1);
102
+ expect(result.value.has("/Users/tom/dev/good-repo")).toBe(true);
103
+ }
104
+ });
105
+
106
+ test("skips files with invalid status values", async () => {
107
+ const pid = process.pid;
108
+ const state = {
109
+ pid,
110
+ directory: "/Users/tom/dev/invalid",
111
+ project: "invalid",
112
+ status: "unknown_status",
113
+ last_transition: "2026-01-01T00:00:00Z",
114
+ session_id: "ses_invalid",
115
+ };
116
+ await writeFile(join(temp_dir, `${pid}.json`), JSON.stringify(state));
117
+
118
+ const result = await readOcnStates();
119
+ expect(result.ok).toBe(true);
120
+ if (result.ok) {
121
+ expect(result.value.size).toBe(0);
122
+ }
123
+ });
124
+
125
+ test("skips non-json files", async () => {
126
+ await writeFile(join(temp_dir, "readme.txt"), "not a state file");
127
+ const result = await readOcnStates();
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.value.size).toBe(0);
131
+ }
132
+ });
133
+
134
+ test("reads multiple state files", async () => {
135
+ const pid = process.pid;
136
+ const state1 = {
137
+ pid,
138
+ directory: "/Users/tom/dev/repo-a",
139
+ project: "repo-a",
140
+ status: "busy",
141
+ last_transition: "2026-01-01T00:00:00Z",
142
+ session_id: "ses_a",
143
+ };
144
+ const state2 = {
145
+ pid,
146
+ directory: "/Users/tom/dev/repo-b",
147
+ project: "repo-b",
148
+ status: "prompting",
149
+ last_transition: "2026-01-01T00:00:00Z",
150
+ session_id: "ses_b",
151
+ };
152
+ // Use different filenames (same pid but different entries)
153
+ await writeFile(join(temp_dir, `${pid}.json`), JSON.stringify(state1));
154
+ await writeFile(join(temp_dir, `${pid}_2.json`), JSON.stringify(state2));
155
+
156
+ const result = await readOcnStates();
157
+ expect(result.ok).toBe(true);
158
+ if (result.ok) {
159
+ expect(result.value.size).toBe(2);
160
+ expect(result.value.get("/Users/tom/dev/repo-a")?.status).toBe("busy");
161
+ expect(result.value.get("/Users/tom/dev/repo-b")?.status).toBe("prompting");
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@overview/core",
3
+ "version": "0.1.0",
4
+ "main": "src/index.ts",
5
+ "types": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit"
8
+ },
9
+ "dependencies": {
10
+ "@f0rbit/corpus": "link:@f0rbit/corpus",
11
+ "@devpad/api": "link:@devpad/api"
12
+ }
13
+ }
@@ -0,0 +1,31 @@
1
+ export interface CacheEntry<T> {
2
+ data: T;
3
+ fetched_at: number;
4
+ ttl_ms: number;
5
+ }
6
+
7
+ export class DataCache<T> {
8
+ private entries = new Map<string, CacheEntry<T>>();
9
+
10
+ get(key: string): T | null {
11
+ const entry = this.entries.get(key);
12
+ if (!entry) return null;
13
+ if (Date.now() - entry.fetched_at > entry.ttl_ms) {
14
+ this.entries.delete(key);
15
+ return null;
16
+ }
17
+ return entry.data;
18
+ }
19
+
20
+ set(key: string, data: T, ttl_ms: number): void {
21
+ this.entries.set(key, { data, fetched_at: Date.now(), ttl_ms });
22
+ }
23
+
24
+ invalidate(key: string): void {
25
+ this.entries.delete(key);
26
+ }
27
+
28
+ clear(): void {
29
+ this.entries.clear();
30
+ }
31
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Concurrency pool — limits the number of concurrent async operations.
3
+ *
4
+ * Use when spawning many parallel subprocesses (e.g., scanning 50+ repos at startup)
5
+ * to avoid overwhelming the system.
6
+ */
7
+ export function createPool(concurrency: number) {
8
+ let active = 0;
9
+ const queue: Array<() => void> = [];
10
+
11
+ function release() {
12
+ active--;
13
+ const next = queue.shift();
14
+ if (next) {
15
+ active++;
16
+ next();
17
+ }
18
+ }
19
+
20
+ return {
21
+ /** Run an async function within the concurrency limit */
22
+ async run<T>(fn: () => Promise<T>): Promise<T> {
23
+ if (active < concurrency) {
24
+ active++;
25
+ try {
26
+ return await fn();
27
+ } finally {
28
+ release();
29
+ }
30
+ }
31
+ return new Promise<T>((resolve, reject) => {
32
+ queue.push(() => {
33
+ fn().then(resolve, reject).finally(release);
34
+ });
35
+ });
36
+ },
37
+
38
+ /** Current number of active tasks */
39
+ get active_count() { return active; },
40
+
41
+ /** Current number of queued tasks */
42
+ get queue_length() { return queue.length; },
43
+ };
44
+ }
@@ -0,0 +1,61 @@
1
+ export interface DevpadProject {
2
+ id: string;
3
+ project_id: string;
4
+ name: string;
5
+ description: string | null;
6
+ status: string;
7
+ repo_url: string | null;
8
+ }
9
+
10
+ export interface DevpadTask {
11
+ id: string;
12
+ title: string;
13
+ description: string | null;
14
+ priority: "LOW" | "MEDIUM" | "HIGH";
15
+ progress: "UNSTARTED" | "IN_PROGRESS" | "COMPLETED";
16
+ project_id: string | null;
17
+ tags: string[];
18
+ }
19
+
20
+ export interface DevpadMilestone {
21
+ id: string;
22
+ name: string;
23
+ target_version: string | null;
24
+ target_time: string | null;
25
+ finished_at: string | null;
26
+ goals_total: number;
27
+ goals_completed: number;
28
+ }
29
+
30
+ export interface DevpadRepoData {
31
+ project: DevpadProject | null;
32
+ tasks: DevpadTask[];
33
+ milestones: DevpadMilestone[];
34
+ }
35
+
36
+ export function normalizeGitUrl(url: string): string {
37
+ let normalized = url
38
+ .trim()
39
+ .replace(/\.git$/, "")
40
+ .replace(/\/$/, "");
41
+ const ssh_match = normalized.match(/^git@([^:]+):(.+)$/);
42
+ if (ssh_match) {
43
+ normalized = `https://${ssh_match[1]}/${ssh_match[2]}`;
44
+ }
45
+ return normalized.toLowerCase();
46
+ }
47
+
48
+ export function matchRepoToProject(
49
+ remote_url: string | null,
50
+ repo_name: string,
51
+ projects: DevpadProject[],
52
+ ): DevpadProject | null {
53
+ if (remote_url) {
54
+ const normalized = normalizeGitUrl(remote_url);
55
+ const match = projects.find(
56
+ (p) => p.repo_url && normalizeGitUrl(p.repo_url) === normalized,
57
+ );
58
+ if (match) return match;
59
+ }
60
+ return projects.find((p) => p.project_id === repo_name) ?? null;
61
+ }
@@ -0,0 +1,54 @@
1
+ import { ok, err, type Result } from "@f0rbit/corpus";
2
+ import type { GitGraphOutput } from "./types";
3
+
4
+ export type GitGraphError = { kind: "graph_failed"; path: string; cause: string };
5
+
6
+ const DEFAULT_LIMIT = 40;
7
+
8
+ // Strip ANSI escape codes in case git config forces color
9
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
10
+ const stripAnsi = (s: string): string => s.replace(ANSI_RE, "");
11
+
12
+ const stripTrailingEmpty = (lines: string[]): string[] => {
13
+ let end = lines.length;
14
+ while (end > 0 && lines[end - 1]!.trim() === "") end--;
15
+ return lines.slice(0, end);
16
+ };
17
+
18
+ export async function captureGraph(
19
+ repoPath: string,
20
+ options?: { limit?: number },
21
+ ): Promise<Result<GitGraphOutput, GitGraphError>> {
22
+ const limit = options?.limit ?? DEFAULT_LIMIT;
23
+
24
+ const proc = Bun.spawn(
25
+ ["git", "log", "--graph", "--all", "--decorate", "--oneline", `-n`, `${limit}`, "--color=never"],
26
+ {
27
+ cwd: repoPath,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ },
31
+ );
32
+
33
+ const [stdout, stderr, exit_code] = await Promise.all([
34
+ new Response(proc.stdout).text(),
35
+ new Response(proc.stderr).text(),
36
+ proc.exited,
37
+ ]);
38
+
39
+ if (exit_code !== 0) {
40
+ // empty repo (no commits) exits non-zero — treat as empty
41
+ if (stderr.includes("does not have any commits")) {
42
+ return ok({ lines: [], total_lines: 0, repo_path: repoPath });
43
+ }
44
+ return err({ kind: "graph_failed", path: repoPath, cause: stderr.trim() });
45
+ }
46
+
47
+ const lines = stripTrailingEmpty(stdout.split("\n").map(stripAnsi));
48
+
49
+ return ok({
50
+ lines,
51
+ total_lines: lines.length,
52
+ repo_path: repoPath,
53
+ });
54
+ }
@@ -0,0 +1,201 @@
1
+ // Heavyweight on-demand git stats
2
+ import { ok, err, type Result } from "@f0rbit/corpus";
3
+ import type { RecentCommit } from "./types";
4
+
5
+ export type GitStatsError =
6
+ | { kind: "not_a_repo"; path: string }
7
+ | { kind: "stats_failed"; path: string; cause: string };
8
+
9
+ export interface ExtendedStats {
10
+ contributor_count: number;
11
+ contributors: string[];
12
+ repo_size_bytes: number;
13
+ tags: string[];
14
+ recent_commits: RecentCommit[];
15
+ total_commits: number;
16
+ }
17
+
18
+ async function git(args: string[], cwd: string): Promise<Result<string, GitStatsError>> {
19
+ try {
20
+ const proc = Bun.spawn(["git", ...args], {
21
+ cwd,
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ });
25
+
26
+ const [stdout, stderr, exit_code] = await Promise.all([
27
+ new Response(proc.stdout).text(),
28
+ new Response(proc.stderr).text(),
29
+ proc.exited,
30
+ ]);
31
+
32
+ if (exit_code !== 0) {
33
+ if (stderr.includes("not a git repository")) {
34
+ return err({ kind: "not_a_repo", path: cwd });
35
+ }
36
+ return err({ kind: "stats_failed", path: cwd, cause: stderr.trim() });
37
+ }
38
+
39
+ return ok(stdout);
40
+ } catch (e) {
41
+ return err({
42
+ kind: "stats_failed",
43
+ path: cwd,
44
+ cause: e instanceof Error ? e.message : String(e),
45
+ });
46
+ }
47
+ }
48
+
49
+ export function parseSize(size_str: string): number {
50
+ const trimmed = size_str.trim();
51
+ if (trimmed === "0 bytes" || trimmed === "0") return 0;
52
+
53
+ const match = trimmed.match(/^([\d.]+)\s*(\w+)?$/);
54
+ if (!match) return 0;
55
+
56
+ const value = parseFloat(match[1] ?? "0");
57
+ const unit = (match[2] ?? "bytes").toLowerCase();
58
+
59
+ const multipliers: Record<string, number> = {
60
+ bytes: 1,
61
+ kib: 1024,
62
+ mib: 1024 * 1024,
63
+ gib: 1024 * 1024 * 1024,
64
+ };
65
+
66
+ return Math.round(value * (multipliers[unit] ?? 1));
67
+ }
68
+
69
+ function parseContributors(output: string): { contributors: string[]; contributor_count: number } {
70
+ const contributors = output
71
+ .split("\n")
72
+ .map((line) => line.trim())
73
+ .filter((line) => line.length > 0)
74
+ .map((line) => line.replace(/^\d+\t/, ""));
75
+
76
+ return { contributors, contributor_count: contributors.length };
77
+ }
78
+
79
+ function parseRepoSize(output: string): number {
80
+ const lines = output.split("\n");
81
+ let total = 0;
82
+
83
+ for (const line of lines) {
84
+ if (line.startsWith("size:") || line.startsWith("size-pack:")) {
85
+ const size_str = line.replace(/^size(?:-pack)?:/, "").trim();
86
+ total += parseSize(size_str);
87
+ }
88
+ }
89
+
90
+ return total;
91
+ }
92
+
93
+ function parseTags(output: string): string[] {
94
+ return output
95
+ .split("\n")
96
+ .map((t) => t.trim())
97
+ .filter((t) => t.length > 0);
98
+ }
99
+
100
+ function parseRecentCommits(output: string): RecentCommit[] {
101
+ return output
102
+ .split("\n")
103
+ .map((line) => line.trim())
104
+ .filter((line) => line.length > 0)
105
+ .map((line) => {
106
+ const [hash, message, author, time_str] = line.split(":");
107
+ return {
108
+ hash: hash ?? "",
109
+ message: message ?? "",
110
+ author: author ?? "",
111
+ time: parseInt(time_str ?? "0", 10),
112
+ };
113
+ });
114
+ }
115
+
116
+ function parseTotalCommits(output: string): number {
117
+ const n = parseInt(output.trim(), 10);
118
+ return isNaN(n) ? 0 : n;
119
+ }
120
+
121
+ export interface CommitActivity {
122
+ daily_counts: number[];
123
+ total_this_week: number;
124
+ total_last_week: number;
125
+ }
126
+
127
+ function bucketIntoDays(timestamps: number[]): number[] {
128
+ const now = new Date();
129
+ const today_start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
130
+ const counts = new Array<number>(14).fill(0);
131
+
132
+ for (const ts of timestamps) {
133
+ const ms = ts * 1000;
134
+ const days_ago = Math.floor((today_start - ms) / (24 * 60 * 60 * 1000));
135
+ const index = 13 - days_ago;
136
+ if (index >= 0 && index < 14) counts[index] = (counts[index] ?? 0) + 1;
137
+ }
138
+
139
+ return counts;
140
+ }
141
+
142
+ export async function collectCommitActivity(
143
+ repo_path: string,
144
+ ): Promise<Result<CommitActivity, GitStatsError>> {
145
+ const log_r = await git(["log", "--format=%at", "--since=14 days ago", "--all"], repo_path);
146
+
147
+ if (!log_r.ok) return err(log_r.error);
148
+
149
+ const timestamps = log_r.value
150
+ .split("\n")
151
+ .map((l) => l.trim())
152
+ .filter((l) => l.length > 0)
153
+ .map((l) => parseInt(l, 10))
154
+ .filter((n) => !isNaN(n));
155
+
156
+ const daily_counts = bucketIntoDays(timestamps);
157
+ const total_last_week = daily_counts.slice(0, 7).reduce((a, b) => a + b, 0);
158
+ const total_this_week = daily_counts.slice(7).reduce((a, b) => a + b, 0);
159
+
160
+ return ok({ daily_counts, total_this_week, total_last_week });
161
+ }
162
+
163
+ export async function collectStats(
164
+ repoPath: string,
165
+ ): Promise<Result<ExtendedStats, GitStatsError>> {
166
+ const [shortlog_r, count_objects_r, tags_r, log_r, rev_list_r] = await Promise.all([
167
+ git(["shortlog", "-sn", "--all"], repoPath),
168
+ git(["count-objects", "-vH"], repoPath),
169
+ git(["tag", "--list", "--sort=-version:refname"], repoPath),
170
+ git(["log", "-5", "--format=%h:%s:%an:%at"], repoPath),
171
+ git(["rev-list", "--count", "HEAD"], repoPath),
172
+ ]);
173
+
174
+ // not_a_repo is fatal — check any result for it
175
+ for (const r of [shortlog_r, count_objects_r, tags_r, log_r, rev_list_r]) {
176
+ if (!r.ok && r.error.kind === "not_a_repo") return err(r.error);
177
+ }
178
+
179
+ const { contributors, contributor_count } = shortlog_r.ok
180
+ ? parseContributors(shortlog_r.value)
181
+ : { contributors: [], contributor_count: 0 };
182
+
183
+ const repo_size_bytes = count_objects_r.ok
184
+ ? parseRepoSize(count_objects_r.value)
185
+ : 0;
186
+
187
+ const tags = tags_r.ok ? parseTags(tags_r.value) : [];
188
+
189
+ const recent_commits = log_r.ok ? parseRecentCommits(log_r.value) : [];
190
+
191
+ const total_commits = rev_list_r.ok ? parseTotalCommits(rev_list_r.value) : 0;
192
+
193
+ return ok({
194
+ contributor_count,
195
+ contributors,
196
+ repo_size_bytes,
197
+ tags,
198
+ recent_commits,
199
+ total_commits,
200
+ });
201
+ }