@f0rbit/overview 0.1.0 → 0.2.1

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 (69) hide show
  1. package/bin/overview +10 -0
  2. package/dist/overview.js +10361 -0
  3. package/package.json +22 -15
  4. package/bunfig.toml +0 -7
  5. package/packages/core/__tests__/concurrency.test.ts +0 -111
  6. package/packages/core/__tests__/helpers.ts +0 -60
  7. package/packages/core/__tests__/integration/git-status.test.ts +0 -62
  8. package/packages/core/__tests__/integration/scanner.test.ts +0 -140
  9. package/packages/core/__tests__/ocn.test.ts +0 -164
  10. package/packages/core/package.json +0 -13
  11. package/packages/core/src/cache.ts +0 -31
  12. package/packages/core/src/concurrency.ts +0 -44
  13. package/packages/core/src/devpad.ts +0 -61
  14. package/packages/core/src/git-graph.ts +0 -54
  15. package/packages/core/src/git-stats.ts +0 -201
  16. package/packages/core/src/git-status.ts +0 -316
  17. package/packages/core/src/github.ts +0 -286
  18. package/packages/core/src/index.ts +0 -58
  19. package/packages/core/src/ocn.ts +0 -74
  20. package/packages/core/src/scanner.ts +0 -118
  21. package/packages/core/src/types.ts +0 -199
  22. package/packages/core/src/watcher.ts +0 -128
  23. package/packages/core/src/worktree.ts +0 -80
  24. package/packages/core/tsconfig.json +0 -5
  25. package/packages/render/bunfig.toml +0 -8
  26. package/packages/render/jsx-runtime.d.ts +0 -3
  27. package/packages/render/package.json +0 -18
  28. package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +0 -780
  29. package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +0 -304
  30. package/packages/render/src/components/git-graph.tsx +0 -127
  31. package/packages/render/src/components/help-overlay.tsx +0 -108
  32. package/packages/render/src/components/index.ts +0 -7
  33. package/packages/render/src/components/repo-list.tsx +0 -127
  34. package/packages/render/src/components/stats-panel.tsx +0 -116
  35. package/packages/render/src/components/status-badge.tsx +0 -70
  36. package/packages/render/src/components/status-bar.tsx +0 -56
  37. package/packages/render/src/components/widget-container.tsx +0 -286
  38. package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +0 -326
  39. package/packages/render/src/components/widgets/branch-list.tsx +0 -93
  40. package/packages/render/src/components/widgets/commit-activity.tsx +0 -112
  41. package/packages/render/src/components/widgets/devpad-milestones.tsx +0 -88
  42. package/packages/render/src/components/widgets/devpad-tasks.tsx +0 -81
  43. package/packages/render/src/components/widgets/file-changes.tsx +0 -78
  44. package/packages/render/src/components/widgets/git-status.tsx +0 -125
  45. package/packages/render/src/components/widgets/github-ci.tsx +0 -98
  46. package/packages/render/src/components/widgets/github-issues.tsx +0 -101
  47. package/packages/render/src/components/widgets/github-prs.tsx +0 -119
  48. package/packages/render/src/components/widgets/github-release.tsx +0 -73
  49. package/packages/render/src/components/widgets/index.ts +0 -12
  50. package/packages/render/src/components/widgets/recent-commits.tsx +0 -64
  51. package/packages/render/src/components/widgets/registry.ts +0 -23
  52. package/packages/render/src/components/widgets/repo-meta.tsx +0 -80
  53. package/packages/render/src/config/index.ts +0 -104
  54. package/packages/render/src/lib/__tests__/fetch-context.test.ts +0 -200
  55. package/packages/render/src/lib/__tests__/widget-grid.test.ts +0 -665
  56. package/packages/render/src/lib/actions.ts +0 -68
  57. package/packages/render/src/lib/fetch-context.ts +0 -102
  58. package/packages/render/src/lib/filter.ts +0 -94
  59. package/packages/render/src/lib/format.ts +0 -36
  60. package/packages/render/src/lib/use-devpad.ts +0 -167
  61. package/packages/render/src/lib/use-github.ts +0 -75
  62. package/packages/render/src/lib/widget-grid.ts +0 -204
  63. package/packages/render/src/lib/widget-state.ts +0 -96
  64. package/packages/render/src/overview.tsx +0 -16
  65. package/packages/render/src/screens/index.ts +0 -1
  66. package/packages/render/src/screens/main-screen.tsx +0 -410
  67. package/packages/render/src/theme/index.ts +0 -37
  68. package/packages/render/tsconfig.json +0 -9
  69. package/tsconfig.json +0 -23
@@ -1,44 +0,0 @@
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
- }
@@ -1,61 +0,0 @@
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
- }
@@ -1,54 +0,0 @@
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
- }
@@ -1,201 +0,0 @@
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
- }
@@ -1,316 +0,0 @@
1
- import { ok, err, type Result } from "@f0rbit/corpus";
2
- import { basename, relative } from "node:path";
3
- import type {
4
- RepoStatus,
5
- GitFileChange,
6
- BranchInfo,
7
- StashEntry,
8
- HealthStatus,
9
- } from "./types";
10
-
11
- export type GitStatusError =
12
- | { kind: "not_a_repo"; path: string }
13
- | { kind: "git_failed"; path: string; command: string; cause: string };
14
-
15
- async function git(
16
- args: string[],
17
- cwd: string,
18
- ): Promise<Result<string, GitStatusError>> {
19
- const proc = Bun.spawn(["git", ...args], {
20
- cwd,
21
- stdout: "pipe",
22
- stderr: "pipe",
23
- });
24
-
25
- await proc.exited;
26
-
27
- if (proc.exitCode !== 0) {
28
- const stderr = await new Response(proc.stderr).text();
29
- if (
30
- stderr.includes("not a git repository") ||
31
- stderr.includes("not a git repo")
32
- ) {
33
- return err({ kind: "not_a_repo", path: cwd });
34
- }
35
- return err({
36
- kind: "git_failed",
37
- path: cwd,
38
- command: `git ${args.join(" ")}`,
39
- cause: stderr.trim(),
40
- });
41
- }
42
-
43
- const stdout = await new Response(proc.stdout).text();
44
- return ok(stdout);
45
- }
46
-
47
- function parseFileStatus(line: string): GitFileChange | null {
48
- const first_char = line[0];
49
- if (!first_char) return null;
50
-
51
- if (first_char === "?") {
52
- const path = line.slice(2);
53
- return { path, status: "untracked", staged: false };
54
- }
55
-
56
- if (first_char === "u") {
57
- const parts = line.split("\t");
58
- const path = parts[1] ?? line.split(" ").pop() ?? "";
59
- return { path, status: "conflicted", staged: false };
60
- }
61
-
62
- if (first_char === "1") {
63
- const parts = line.split(" ");
64
- const xy = parts[1] ?? "..";
65
- const path = line.split("\t")[0]?.split(" ").pop() ?? parts.at(-1) ?? "";
66
- const staged = xy[0] !== ".";
67
- const status = parseXY(xy);
68
- return { path, status, staged };
69
- }
70
-
71
- if (first_char === "2") {
72
- const tab_parts = line.split("\t");
73
- const path = tab_parts[2] ?? tab_parts[1] ?? "";
74
- const parts = line.split(" ");
75
- const xy = parts[1] ?? "..";
76
- const staged = xy[0] !== ".";
77
- return { path, status: "renamed", staged };
78
- }
79
-
80
- return null;
81
- }
82
-
83
- function parseXY(
84
- xy: string,
85
- ): GitFileChange["status"] {
86
- const x = xy[0] ?? ".";
87
- const y = xy[1] ?? ".";
88
- const code = x !== "." ? x : y;
89
- switch (code) {
90
- case "A":
91
- return "added";
92
- case "D":
93
- return "deleted";
94
- case "R":
95
- return "renamed";
96
- case "C":
97
- return "copied";
98
- case "M":
99
- return "modified";
100
- default:
101
- return "modified";
102
- }
103
- }
104
-
105
- function parseStatusPorcelain(raw: string): {
106
- branch: string;
107
- ahead: number;
108
- behind: number;
109
- changes: GitFileChange[];
110
- } {
111
- const lines = raw.split("\n").filter((l) => l.length > 0);
112
- let branch = "HEAD";
113
- let ahead = 0;
114
- let behind = 0;
115
- const changes: GitFileChange[] = [];
116
-
117
- for (const line of lines) {
118
- if (line.startsWith("# branch.head ")) {
119
- branch = line.slice("# branch.head ".length);
120
- } else if (line.startsWith("# branch.ab ")) {
121
- const match = line.match(/\+(\d+) -(\d+)/);
122
- if (match) {
123
- ahead = Number.parseInt(match[1] ?? "0", 10);
124
- behind = Number.parseInt(match[2] ?? "0", 10);
125
- }
126
- } else if (line.startsWith("#")) {
127
- continue;
128
- } else {
129
- const change = parseFileStatus(line);
130
- if (change) changes.push(change);
131
- }
132
- }
133
-
134
- return { branch, ahead, behind, changes };
135
- }
136
-
137
- function parseStashList(raw: string): StashEntry[] {
138
- return raw
139
- .split("\n")
140
- .filter((l) => l.length > 0)
141
- .map((line) => {
142
- const colon_idx = line.indexOf(":");
143
- const ref = colon_idx >= 0 ? line.slice(0, colon_idx) : line;
144
- const message = colon_idx >= 0 ? line.slice(colon_idx + 1) : "";
145
- const index_match = ref.match(/\{(\d+)\}/);
146
- return {
147
- index: index_match ? Number.parseInt(index_match[1] ?? "0", 10) : 0,
148
- message: message.trim(),
149
- date: "",
150
- };
151
- });
152
- }
153
-
154
- function parseBranches(raw: string): {
155
- branches: BranchInfo[];
156
- local_count: number;
157
- remote_count: number;
158
- } {
159
- const names = raw
160
- .split("\n")
161
- .map((l) => l.trim())
162
- .filter((l) => l.length > 0);
163
-
164
- const branches: BranchInfo[] = [];
165
- let local_count = 0;
166
- let remote_count = 0;
167
-
168
- for (const name of names) {
169
- const is_remote = name.includes("/");
170
- if (is_remote) {
171
- remote_count++;
172
- } else {
173
- local_count++;
174
- }
175
- branches.push({
176
- name,
177
- is_current: false,
178
- upstream: null,
179
- ahead: 0,
180
- behind: 0,
181
- last_commit_time: 0,
182
- });
183
- }
184
-
185
- return { branches, local_count, remote_count };
186
- }
187
-
188
- function deriveHealth(
189
- ahead: number,
190
- behind: number,
191
- modified: number,
192
- staged: number,
193
- untracked: number,
194
- conflicts: number,
195
- ): HealthStatus {
196
- if (conflicts > 0) return "conflict";
197
- if (ahead > 0 && behind > 0) return "diverged";
198
- if (ahead > 0) return "ahead";
199
- if (behind > 0) return "behind";
200
- if (modified + staged + untracked > 0) return "dirty";
201
- return "clean";
202
- }
203
-
204
- export async function collectStatus(
205
- repoPath: string,
206
- scanRoot: string,
207
- ): Promise<Result<RepoStatus, GitStatusError>> {
208
- const [status_result, log_result, stash_result, branches_result, remote_result] =
209
- await Promise.all([
210
- git(["status", "--porcelain=v2", "--branch"], repoPath),
211
- git(["log", "-1", "--format=%H:%s:%at"], repoPath),
212
- git(["stash", "list", "--format=%gd:%gs"], repoPath),
213
- git(["branch", "-a", "--format=%(refname:short)"], repoPath),
214
- git(["remote", "get-url", "origin"], repoPath),
215
- ]);
216
-
217
- if (!status_result.ok) return status_result;
218
- if (!log_result.ok) return log_result;
219
- if (!stash_result.ok) return stash_result;
220
- if (!branches_result.ok) return branches_result;
221
-
222
- const { branch, ahead, behind, changes } = parseStatusPorcelain(
223
- status_result.value,
224
- );
225
-
226
- const log_parts = log_result.value.trim().split(":");
227
- const head_commit = log_parts[0] ?? "";
228
- const head_message = log_parts.slice(1, -1).join(":") ;
229
- const head_time = Number.parseInt(log_parts.at(-1) ?? "0", 10);
230
-
231
- const stashes = parseStashList(stash_result.value);
232
- const { branches, local_count, remote_count } = parseBranches(
233
- branches_result.value,
234
- );
235
-
236
- const current_branch =
237
- branch === "(detached)" || branch === "HEAD" ? "HEAD (detached)" : branch;
238
-
239
- const current_idx = branches.findIndex(
240
- (b) => b.name === current_branch || b.name === branch,
241
- );
242
- if (current_idx >= 0 && branches[current_idx]) {
243
- branches[current_idx].is_current = true;
244
- }
245
-
246
- const remote_url = remote_result.ok ? remote_result.value.trim() || null : null;
247
-
248
- const modified_count = changes.filter(
249
- (c) => !c.staged && c.status !== "untracked" && c.status !== "conflicted",
250
- ).length;
251
- const staged_count = changes.filter((c) => c.staged).length;
252
- const untracked_count = changes.filter(
253
- (c) => c.status === "untracked",
254
- ).length;
255
- const conflict_count = changes.filter(
256
- (c) => c.status === "conflicted",
257
- ).length;
258
-
259
- const is_clean =
260
- modified_count === 0 &&
261
- staged_count === 0 &&
262
- untracked_count === 0 &&
263
- conflict_count === 0 &&
264
- ahead === 0;
265
-
266
- const health = deriveHealth(
267
- ahead,
268
- behind,
269
- modified_count,
270
- staged_count,
271
- untracked_count,
272
- conflict_count,
273
- );
274
-
275
- return ok({
276
- path: repoPath,
277
- name: basename(repoPath),
278
- display_path: relative(scanRoot, repoPath),
279
-
280
- current_branch,
281
- head_commit,
282
- head_message,
283
- head_time,
284
-
285
- remote_url,
286
- ahead,
287
- behind,
288
-
289
- modified_count,
290
- staged_count,
291
- untracked_count,
292
- conflict_count,
293
- changes,
294
-
295
- stash_count: stashes.length,
296
- stashes,
297
-
298
- branches,
299
- local_branch_count: local_count,
300
- remote_branch_count: remote_count,
301
-
302
- tags: [],
303
- total_commits: 0,
304
- repo_size_bytes: 0,
305
- contributor_count: 0,
306
-
307
- recent_commits: [],
308
-
309
- commit_activity: null,
310
-
311
- ocn_status: null,
312
-
313
- is_clean,
314
- health,
315
- });
316
- }