@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.
- package/bin/overview +10 -0
- package/dist/overview.js +10361 -0
- package/package.json +22 -15
- package/bunfig.toml +0 -7
- package/packages/core/__tests__/concurrency.test.ts +0 -111
- package/packages/core/__tests__/helpers.ts +0 -60
- package/packages/core/__tests__/integration/git-status.test.ts +0 -62
- package/packages/core/__tests__/integration/scanner.test.ts +0 -140
- package/packages/core/__tests__/ocn.test.ts +0 -164
- package/packages/core/package.json +0 -13
- package/packages/core/src/cache.ts +0 -31
- package/packages/core/src/concurrency.ts +0 -44
- package/packages/core/src/devpad.ts +0 -61
- package/packages/core/src/git-graph.ts +0 -54
- package/packages/core/src/git-stats.ts +0 -201
- package/packages/core/src/git-status.ts +0 -316
- package/packages/core/src/github.ts +0 -286
- package/packages/core/src/index.ts +0 -58
- package/packages/core/src/ocn.ts +0 -74
- package/packages/core/src/scanner.ts +0 -118
- package/packages/core/src/types.ts +0 -199
- package/packages/core/src/watcher.ts +0 -128
- package/packages/core/src/worktree.ts +0 -80
- package/packages/core/tsconfig.json +0 -5
- package/packages/render/bunfig.toml +0 -8
- package/packages/render/jsx-runtime.d.ts +0 -3
- package/packages/render/package.json +0 -18
- package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +0 -780
- package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +0 -304
- package/packages/render/src/components/git-graph.tsx +0 -127
- package/packages/render/src/components/help-overlay.tsx +0 -108
- package/packages/render/src/components/index.ts +0 -7
- package/packages/render/src/components/repo-list.tsx +0 -127
- package/packages/render/src/components/stats-panel.tsx +0 -116
- package/packages/render/src/components/status-badge.tsx +0 -70
- package/packages/render/src/components/status-bar.tsx +0 -56
- package/packages/render/src/components/widget-container.tsx +0 -286
- package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +0 -326
- package/packages/render/src/components/widgets/branch-list.tsx +0 -93
- package/packages/render/src/components/widgets/commit-activity.tsx +0 -112
- package/packages/render/src/components/widgets/devpad-milestones.tsx +0 -88
- package/packages/render/src/components/widgets/devpad-tasks.tsx +0 -81
- package/packages/render/src/components/widgets/file-changes.tsx +0 -78
- package/packages/render/src/components/widgets/git-status.tsx +0 -125
- package/packages/render/src/components/widgets/github-ci.tsx +0 -98
- package/packages/render/src/components/widgets/github-issues.tsx +0 -101
- package/packages/render/src/components/widgets/github-prs.tsx +0 -119
- package/packages/render/src/components/widgets/github-release.tsx +0 -73
- package/packages/render/src/components/widgets/index.ts +0 -12
- package/packages/render/src/components/widgets/recent-commits.tsx +0 -64
- package/packages/render/src/components/widgets/registry.ts +0 -23
- package/packages/render/src/components/widgets/repo-meta.tsx +0 -80
- package/packages/render/src/config/index.ts +0 -104
- package/packages/render/src/lib/__tests__/fetch-context.test.ts +0 -200
- package/packages/render/src/lib/__tests__/widget-grid.test.ts +0 -665
- package/packages/render/src/lib/actions.ts +0 -68
- package/packages/render/src/lib/fetch-context.ts +0 -102
- package/packages/render/src/lib/filter.ts +0 -94
- package/packages/render/src/lib/format.ts +0 -36
- package/packages/render/src/lib/use-devpad.ts +0 -167
- package/packages/render/src/lib/use-github.ts +0 -75
- package/packages/render/src/lib/widget-grid.ts +0 -204
- package/packages/render/src/lib/widget-state.ts +0 -96
- package/packages/render/src/overview.tsx +0 -16
- package/packages/render/src/screens/index.ts +0 -1
- package/packages/render/src/screens/main-screen.tsx +0 -410
- package/packages/render/src/theme/index.ts +0 -37
- package/packages/render/tsconfig.json +0 -9
- 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
|
-
}
|