@devframes/plugin-git 0.0.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/LICENSE.md +21 -0
- package/README.md +94 -0
- package/bin.mjs +3 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +12 -0
- package/dist/client/404/index.html +1 -0
- package/dist/client/404.html +1 -0
- package/dist/client/__next.__PAGE__.txt +6 -0
- package/dist/client/__next._full.txt +14 -0
- package/dist/client/__next._head.txt +5 -0
- package/dist/client/__next._index.txt +5 -0
- package/dist/client/__next._tree.txt +2 -0
- package/dist/client/_next/static/chunks/02~fscl7ckl8~.js +5 -0
- package/dist/client/_next/static/chunks/03~yq9q893hmn.js +1 -0
- package/dist/client/_next/static/chunks/0cby-xtlytqc8.js +31 -0
- package/dist/client/_next/static/chunks/0e1~n36m7gfkb.js +1 -0
- package/dist/client/_next/static/chunks/0f-3h51aw8u6x.js +1 -0
- package/dist/client/_next/static/chunks/0fh_ktutzk5fe.js +1 -0
- package/dist/client/_next/static/chunks/0o2sgd3e~ptl1.css +8 -0
- package/dist/client/_next/static/chunks/turbopack-0_ztfbn7~a1nl.js +1 -0
- package/dist/client/_next/static/hfdjoy_Phicjdm2uzC1Xt/_buildManifest.js +16 -0
- package/dist/client/_next/static/hfdjoy_Phicjdm2uzC1Xt/_clientMiddlewareManifest.js +1 -0
- package/dist/client/_next/static/hfdjoy_Phicjdm2uzC1Xt/_ssgManifest.js +1 -0
- package/dist/client/_not-found/__next._full.txt +15 -0
- package/dist/client/_not-found/__next._head.txt +5 -0
- package/dist/client/_not-found/__next._index.txt +5 -0
- package/dist/client/_not-found/__next._not-found.__PAGE__.txt +5 -0
- package/dist/client/_not-found/__next._not-found.txt +5 -0
- package/dist/client/_not-found/__next._tree.txt +2 -0
- package/dist/client/_not-found/index.html +1 -0
- package/dist/client/_not-found/index.txt +15 -0
- package/dist/client/index.html +1 -0
- package/dist/client/index.txt +14 -0
- package/dist/index.d.mts +205 -0
- package/dist/index.mjs +2 -0
- package/dist/src-Bm_Zrtej.mjs +756 -0
- package/package.json +80 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineDevframe } from "devframe/types";
|
|
3
|
+
import { dirname, resolve } from "pathe";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { defineRpcFunction } from "devframe";
|
|
8
|
+
//#region package.json
|
|
9
|
+
var name = "@devframes/plugin-git";
|
|
10
|
+
var version = "0.0.1";
|
|
11
|
+
var description = "Git integration for devframe — a read-only repository dashboard (status, log, branches, diff) with a Next.js + shadcn/ui SPA over type-safe RPC.";
|
|
12
|
+
var homepage = "https://github.com/devframes/devframe#readme";
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/node/git.ts
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const MAX_BUFFER = 1024 * 1024 * 64;
|
|
17
|
+
/**
|
|
18
|
+
* Run a git command in `cwd`. Rejects when git exits non-zero — callers that
|
|
19
|
+
* tolerate failure (e.g. "no upstream configured") should use {@link tryGit}.
|
|
20
|
+
*/
|
|
21
|
+
async function runGit(cwd, args) {
|
|
22
|
+
const { stdout, stderr } = await execFileAsync("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
maxBuffer: MAX_BUFFER,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
GIT_PAGER: "cat",
|
|
29
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
30
|
+
LC_ALL: "C"
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
stdout,
|
|
35
|
+
stderr
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Run a git command, returning trimmed stdout or `null` when it fails. */
|
|
39
|
+
async function tryGit(cwd, args) {
|
|
40
|
+
try {
|
|
41
|
+
const { stdout } = await runGit(cwd, args);
|
|
42
|
+
return stdout.replace(/\n$/, "");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Resolve the repository root for `cwd`, or `null` when `cwd` is outside a repo. */
|
|
48
|
+
async function resolveRepoRoot(cwd) {
|
|
49
|
+
return tryGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
50
|
+
}
|
|
51
|
+
/** Split git output on a separator, dropping the trailing empty segment. */
|
|
52
|
+
function splitClean(input, separator) {
|
|
53
|
+
return input.split(separator).filter((part) => part.length > 0);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Extract a concise, single-line message from a failed `execFile` error.
|
|
57
|
+
* git writes useful text (e.g. "nothing to commit") to stdout/stderr.
|
|
58
|
+
*/
|
|
59
|
+
function gitErrorMessage(error) {
|
|
60
|
+
const e = error;
|
|
61
|
+
return (e?.stderr || e?.stdout || e?.message || "git command failed").trim().split("\n")[0];
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/rpc/context.ts
|
|
65
|
+
const configs = /* @__PURE__ */ new WeakMap();
|
|
66
|
+
const contexts = /* @__PURE__ */ new WeakMap();
|
|
67
|
+
/**
|
|
68
|
+
* Record the working directory for a context. Called from the devframe
|
|
69
|
+
* `setup` before any RPC handler runs, so {@link getGitContext} can honor a
|
|
70
|
+
* `repoRoot` override instead of the raw `ctx.cwd`.
|
|
71
|
+
*/
|
|
72
|
+
function configureGit(ctx, config) {
|
|
73
|
+
configs.set(ctx, config);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Per-`DevframeNodeContext` git state. Each RPC function file pulls its
|
|
77
|
+
* working directory and (memoized) repo-root lookup from here instead of
|
|
78
|
+
* re-resolving on every call.
|
|
79
|
+
*/
|
|
80
|
+
function getGitContext(ctx) {
|
|
81
|
+
let existing = contexts.get(ctx);
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
const config = configs.get(ctx);
|
|
84
|
+
const cwd = config?.cwd ?? ctx.cwd;
|
|
85
|
+
const write = config?.write ?? false;
|
|
86
|
+
let rootPromise;
|
|
87
|
+
existing = {
|
|
88
|
+
cwd,
|
|
89
|
+
write,
|
|
90
|
+
resolveRoot: () => {
|
|
91
|
+
rootPromise ??= resolveRepoRoot(cwd);
|
|
92
|
+
return rootPromise;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
contexts.set(ctx, existing);
|
|
96
|
+
return existing;
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/rpc/functions/branches.ts
|
|
100
|
+
const FORMAT$1 = [
|
|
101
|
+
"%(refname:short)",
|
|
102
|
+
"%(objectname:short)",
|
|
103
|
+
"%(HEAD)",
|
|
104
|
+
"%(upstream:short)",
|
|
105
|
+
"%(upstream:track)",
|
|
106
|
+
"%(contents:subject)"
|
|
107
|
+
].join("");
|
|
108
|
+
function parseTrack(track) {
|
|
109
|
+
if (track.includes("gone")) return {
|
|
110
|
+
ahead: 0,
|
|
111
|
+
behind: 0,
|
|
112
|
+
gone: true
|
|
113
|
+
};
|
|
114
|
+
const ahead = track.match(/ahead (\d+)/);
|
|
115
|
+
const behind = track.match(/behind (\d+)/);
|
|
116
|
+
return {
|
|
117
|
+
ahead: ahead ? Number(ahead[1]) : 0,
|
|
118
|
+
behind: behind ? Number(behind[1]) : 0,
|
|
119
|
+
gone: false
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const branches = defineRpcFunction({
|
|
123
|
+
name: "git:branches",
|
|
124
|
+
type: "query",
|
|
125
|
+
snapshot: true,
|
|
126
|
+
jsonSerializable: true,
|
|
127
|
+
setup: (ctx) => {
|
|
128
|
+
const git = getGitContext(ctx);
|
|
129
|
+
return { handler: async () => {
|
|
130
|
+
if (!await git.resolveRoot()) return {
|
|
131
|
+
isRepo: false,
|
|
132
|
+
current: null,
|
|
133
|
+
branches: []
|
|
134
|
+
};
|
|
135
|
+
const raw = await tryGit(git.cwd, [
|
|
136
|
+
"for-each-ref",
|
|
137
|
+
`--format=${FORMAT$1}`,
|
|
138
|
+
"refs/heads"
|
|
139
|
+
]);
|
|
140
|
+
if (!raw) return {
|
|
141
|
+
isRepo: true,
|
|
142
|
+
current: null,
|
|
143
|
+
branches: []
|
|
144
|
+
};
|
|
145
|
+
let current = null;
|
|
146
|
+
const branches = splitClean(raw, "\n").map((line) => {
|
|
147
|
+
const [name, sha, head, upstream, track, subject] = line.split("");
|
|
148
|
+
const isCurrent = head === "*";
|
|
149
|
+
if (isCurrent) current = name;
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
current: isCurrent,
|
|
153
|
+
sha,
|
|
154
|
+
upstream: upstream || null,
|
|
155
|
+
subject: subject ?? "",
|
|
156
|
+
...parseTrack(track ?? "")
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
branches.sort((a, b) => Number(b.current) - Number(a.current));
|
|
160
|
+
return {
|
|
161
|
+
isRepo: true,
|
|
162
|
+
current,
|
|
163
|
+
branches
|
|
164
|
+
};
|
|
165
|
+
} };
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/rpc/functions/status.ts
|
|
170
|
+
const EMPTY_STATUS = {
|
|
171
|
+
isRepo: false,
|
|
172
|
+
root: null,
|
|
173
|
+
branch: null,
|
|
174
|
+
detached: false,
|
|
175
|
+
head: null,
|
|
176
|
+
upstream: null,
|
|
177
|
+
ahead: 0,
|
|
178
|
+
behind: 0,
|
|
179
|
+
staged: [],
|
|
180
|
+
unstaged: [],
|
|
181
|
+
untracked: [],
|
|
182
|
+
clean: true,
|
|
183
|
+
canWrite: false
|
|
184
|
+
};
|
|
185
|
+
function mapCode(code) {
|
|
186
|
+
switch (code) {
|
|
187
|
+
case "M": return "modified";
|
|
188
|
+
case "A": return "added";
|
|
189
|
+
case "D": return "deleted";
|
|
190
|
+
case "R": return "renamed";
|
|
191
|
+
case "C": return "copied";
|
|
192
|
+
case "T": return "type-changed";
|
|
193
|
+
case "U": return "unmerged";
|
|
194
|
+
default: return "unknown";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Parse `git status --porcelain=v2 --branch -z` into a structured snapshot.
|
|
199
|
+
* Records are NUL-separated; rename/copy (type `2`) entries consume an extra
|
|
200
|
+
* token for the original path.
|
|
201
|
+
*/
|
|
202
|
+
function parseStatus(root, raw) {
|
|
203
|
+
const tokens = raw.split("\0");
|
|
204
|
+
const status = {
|
|
205
|
+
...EMPTY_STATUS,
|
|
206
|
+
isRepo: true,
|
|
207
|
+
root,
|
|
208
|
+
staged: [],
|
|
209
|
+
unstaged: [],
|
|
210
|
+
untracked: []
|
|
211
|
+
};
|
|
212
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
213
|
+
const token = tokens[i];
|
|
214
|
+
if (!token) continue;
|
|
215
|
+
if (token.startsWith("# ")) {
|
|
216
|
+
const [, key, ...rest] = token.split(" ");
|
|
217
|
+
const value = rest.join(" ");
|
|
218
|
+
if (key === "branch.head") if (value === "(detached)") {
|
|
219
|
+
status.detached = true;
|
|
220
|
+
status.branch = null;
|
|
221
|
+
} else status.branch = value;
|
|
222
|
+
else if (key === "branch.oid" && value !== "(initial)") status.head = value.slice(0, 9);
|
|
223
|
+
else if (key === "branch.upstream") status.upstream = value;
|
|
224
|
+
else if (key === "branch.ab") {
|
|
225
|
+
const match = value.match(/\+(\d+)\s+-(\d+)/);
|
|
226
|
+
if (match) {
|
|
227
|
+
status.ahead = Number(match[1]);
|
|
228
|
+
status.behind = Number(match[2]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (token.startsWith("1 ") || token.startsWith("2 ")) {
|
|
234
|
+
const renamed = token.startsWith("2 ");
|
|
235
|
+
const fields = token.split(" ");
|
|
236
|
+
const xy = fields[1];
|
|
237
|
+
const x = xy[0];
|
|
238
|
+
const y = xy[1];
|
|
239
|
+
const path = fields.slice(renamed ? 9 : 8).join(" ");
|
|
240
|
+
const from = renamed ? tokens[++i] : void 0;
|
|
241
|
+
if (x !== ".") status.staged.push(from ? {
|
|
242
|
+
path,
|
|
243
|
+
from,
|
|
244
|
+
status: mapCode(x)
|
|
245
|
+
} : {
|
|
246
|
+
path,
|
|
247
|
+
status: mapCode(x)
|
|
248
|
+
});
|
|
249
|
+
if (y !== ".") status.unstaged.push({
|
|
250
|
+
path,
|
|
251
|
+
status: mapCode(y)
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (token.startsWith("u ")) {
|
|
256
|
+
const path = token.split(" ").slice(10).join(" ");
|
|
257
|
+
status.unstaged.push({
|
|
258
|
+
path,
|
|
259
|
+
status: "unmerged"
|
|
260
|
+
});
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (token.startsWith("? ")) status.untracked.push(token.slice(2));
|
|
264
|
+
}
|
|
265
|
+
status.clean = status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0;
|
|
266
|
+
return status;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Read the working-tree status for a git context. Shared by the `git:status`
|
|
270
|
+
* query and the write actions (which return fresh status after mutating).
|
|
271
|
+
*/
|
|
272
|
+
async function readStatus(git) {
|
|
273
|
+
const root = await git.resolveRoot();
|
|
274
|
+
if (!root) return {
|
|
275
|
+
...EMPTY_STATUS,
|
|
276
|
+
canWrite: false
|
|
277
|
+
};
|
|
278
|
+
const { stdout } = await runGit(git.cwd, [
|
|
279
|
+
"status",
|
|
280
|
+
"--porcelain=v2",
|
|
281
|
+
"--branch",
|
|
282
|
+
"-z"
|
|
283
|
+
]);
|
|
284
|
+
const status = parseStatus(root, stdout);
|
|
285
|
+
status.canWrite = git.write;
|
|
286
|
+
return status;
|
|
287
|
+
}
|
|
288
|
+
const status = defineRpcFunction({
|
|
289
|
+
name: "git:status",
|
|
290
|
+
type: "query",
|
|
291
|
+
snapshot: true,
|
|
292
|
+
jsonSerializable: true,
|
|
293
|
+
setup: (ctx) => {
|
|
294
|
+
const git = getGitContext(ctx);
|
|
295
|
+
return { handler: () => readStatus(git) };
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/rpc/functions/commit.ts
|
|
300
|
+
const commit = defineRpcFunction({
|
|
301
|
+
name: "git:commit",
|
|
302
|
+
type: "action",
|
|
303
|
+
jsonSerializable: true,
|
|
304
|
+
setup: (ctx) => {
|
|
305
|
+
const git = getGitContext(ctx);
|
|
306
|
+
return { handler: async (args) => {
|
|
307
|
+
const message = (args?.message ?? "").trim();
|
|
308
|
+
if (!await git.resolveRoot()) return {
|
|
309
|
+
ok: false,
|
|
310
|
+
hash: null,
|
|
311
|
+
message: "Not a git repository.",
|
|
312
|
+
status: await readStatus(git)
|
|
313
|
+
};
|
|
314
|
+
if (!message) return {
|
|
315
|
+
ok: false,
|
|
316
|
+
hash: null,
|
|
317
|
+
message: "Commit message is required.",
|
|
318
|
+
status: await readStatus(git)
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
await runGit(git.cwd, [
|
|
322
|
+
"commit",
|
|
323
|
+
"-m",
|
|
324
|
+
message
|
|
325
|
+
]);
|
|
326
|
+
return {
|
|
327
|
+
ok: true,
|
|
328
|
+
hash: await tryGit(git.cwd, [
|
|
329
|
+
"rev-parse",
|
|
330
|
+
"--short",
|
|
331
|
+
"HEAD"
|
|
332
|
+
]),
|
|
333
|
+
message: "Committed.",
|
|
334
|
+
status: await readStatus(git)
|
|
335
|
+
};
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
hash: null,
|
|
340
|
+
message: gitErrorMessage(error),
|
|
341
|
+
status: await readStatus(git)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
} };
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/rpc/functions/diff.ts
|
|
349
|
+
/** Hard cap on the returned patch text to keep payloads bounded. */
|
|
350
|
+
const PATCH_CHAR_LIMIT$1 = 2e5;
|
|
351
|
+
function parseNumstat$1(raw) {
|
|
352
|
+
return splitClean(raw, "\n").map((line) => {
|
|
353
|
+
const [add, del, ...rest] = line.split(" ");
|
|
354
|
+
const binary = add === "-" || del === "-";
|
|
355
|
+
return {
|
|
356
|
+
path: rest.join(" "),
|
|
357
|
+
additions: binary ? 0 : Number(add),
|
|
358
|
+
deletions: binary ? 0 : Number(del),
|
|
359
|
+
binary
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const diff = defineRpcFunction({
|
|
364
|
+
name: "git:diff",
|
|
365
|
+
type: "query",
|
|
366
|
+
snapshot: true,
|
|
367
|
+
jsonSerializable: true,
|
|
368
|
+
setup: (ctx) => {
|
|
369
|
+
const git = getGitContext(ctx);
|
|
370
|
+
return { handler: async (args = {}) => {
|
|
371
|
+
const { path, staged = false } = args;
|
|
372
|
+
if (!await git.resolveRoot()) return {
|
|
373
|
+
isRepo: false,
|
|
374
|
+
staged,
|
|
375
|
+
path: path ?? null,
|
|
376
|
+
files: [],
|
|
377
|
+
totalAdditions: 0,
|
|
378
|
+
totalDeletions: 0,
|
|
379
|
+
patch: null,
|
|
380
|
+
truncated: false
|
|
381
|
+
};
|
|
382
|
+
const base = staged ? ["diff", "--cached"] : ["diff"];
|
|
383
|
+
const scope = path ? ["--", path] : [];
|
|
384
|
+
const numstatRaw = await tryGit(git.cwd, [
|
|
385
|
+
...base,
|
|
386
|
+
"--numstat",
|
|
387
|
+
...scope
|
|
388
|
+
]);
|
|
389
|
+
const files = numstatRaw ? parseNumstat$1(numstatRaw) : [];
|
|
390
|
+
const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
|
|
391
|
+
const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
|
|
392
|
+
let patch = null;
|
|
393
|
+
let truncated = false;
|
|
394
|
+
if (path) {
|
|
395
|
+
const { stdout } = await runGit(git.cwd, [...base, ...scope]);
|
|
396
|
+
if (stdout.length > PATCH_CHAR_LIMIT$1) {
|
|
397
|
+
patch = stdout.slice(0, PATCH_CHAR_LIMIT$1);
|
|
398
|
+
truncated = true;
|
|
399
|
+
} else patch = stdout;
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
isRepo: true,
|
|
403
|
+
staged,
|
|
404
|
+
path: path ?? null,
|
|
405
|
+
files,
|
|
406
|
+
totalAdditions,
|
|
407
|
+
totalDeletions,
|
|
408
|
+
patch,
|
|
409
|
+
truncated
|
|
410
|
+
};
|
|
411
|
+
} };
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/rpc/functions/log.ts
|
|
416
|
+
const FORMAT = [
|
|
417
|
+
"%H",
|
|
418
|
+
"%h",
|
|
419
|
+
"%P",
|
|
420
|
+
"%an",
|
|
421
|
+
"%ae",
|
|
422
|
+
"%aI",
|
|
423
|
+
"%D",
|
|
424
|
+
"%s",
|
|
425
|
+
"%b"
|
|
426
|
+
].join("") + "";
|
|
427
|
+
function clamp(value, min, max) {
|
|
428
|
+
return Math.min(Math.max(value, min), max);
|
|
429
|
+
}
|
|
430
|
+
function parseLog(raw) {
|
|
431
|
+
return splitClean(raw, "").map((record) => {
|
|
432
|
+
const [hash, shortHash, parents, author, email, isoDate, refs, subject, body] = record.replace(/^\n/, "").split("");
|
|
433
|
+
return {
|
|
434
|
+
hash,
|
|
435
|
+
shortHash,
|
|
436
|
+
author,
|
|
437
|
+
email,
|
|
438
|
+
date: Date.parse(isoDate),
|
|
439
|
+
subject,
|
|
440
|
+
body: (body ?? "").trim(),
|
|
441
|
+
refs: refs ? refs.split(", ").map((r) => r.trim()).filter(Boolean) : [],
|
|
442
|
+
parents: parents ? parents.split(" ").filter(Boolean) : []
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const SNAPSHOT_LIMIT$1 = 200;
|
|
447
|
+
const log = defineRpcFunction({
|
|
448
|
+
name: "git:log",
|
|
449
|
+
type: "query",
|
|
450
|
+
jsonSerializable: true,
|
|
451
|
+
dump: async (_ctx, handler) => {
|
|
452
|
+
const baked = {
|
|
453
|
+
...await handler({
|
|
454
|
+
limit: SNAPSHOT_LIMIT$1,
|
|
455
|
+
skip: 0
|
|
456
|
+
}),
|
|
457
|
+
hasMore: false
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
records: [{
|
|
461
|
+
inputs: [],
|
|
462
|
+
output: baked
|
|
463
|
+
}],
|
|
464
|
+
fallback: baked
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
setup: (ctx) => {
|
|
468
|
+
const git = getGitContext(ctx);
|
|
469
|
+
return { handler: async (args = {}) => {
|
|
470
|
+
const limit = clamp(Math.trunc(args.limit ?? 30), 1, 200);
|
|
471
|
+
const skip = Math.max(0, Math.trunc(args.skip ?? 0));
|
|
472
|
+
const ref = args.ref?.trim() || void 0;
|
|
473
|
+
if (!await git.resolveRoot()) return {
|
|
474
|
+
isRepo: false,
|
|
475
|
+
commits: [],
|
|
476
|
+
limit,
|
|
477
|
+
skip,
|
|
478
|
+
hasMore: false
|
|
479
|
+
};
|
|
480
|
+
const command = [
|
|
481
|
+
"log",
|
|
482
|
+
"--topo-order",
|
|
483
|
+
`--max-count=${limit}`,
|
|
484
|
+
`--skip=${skip}`,
|
|
485
|
+
`--pretty=format:${FORMAT}`
|
|
486
|
+
];
|
|
487
|
+
if (ref) command.push(ref);
|
|
488
|
+
const raw = await tryGit(git.cwd, command);
|
|
489
|
+
const commits = raw ? parseLog(raw) : [];
|
|
490
|
+
return {
|
|
491
|
+
isRepo: true,
|
|
492
|
+
commits,
|
|
493
|
+
limit,
|
|
494
|
+
skip,
|
|
495
|
+
hasMore: commits.length === limit
|
|
496
|
+
};
|
|
497
|
+
} };
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/rpc/functions/show.ts
|
|
502
|
+
/** Hard cap on the returned patch text to keep payloads bounded. */
|
|
503
|
+
const PATCH_CHAR_LIMIT = 2e5;
|
|
504
|
+
/** Matches the window `git:log` bakes, so any visible commit has a snapshot. */
|
|
505
|
+
const SNAPSHOT_LIMIT = 200;
|
|
506
|
+
const EMPTY_DETAIL = {
|
|
507
|
+
isRepo: false,
|
|
508
|
+
found: false,
|
|
509
|
+
hash: "",
|
|
510
|
+
shortHash: "",
|
|
511
|
+
author: "",
|
|
512
|
+
email: "",
|
|
513
|
+
date: 0,
|
|
514
|
+
committer: "",
|
|
515
|
+
committerEmail: "",
|
|
516
|
+
commitDate: 0,
|
|
517
|
+
subject: "",
|
|
518
|
+
body: "",
|
|
519
|
+
parents: [],
|
|
520
|
+
refs: [],
|
|
521
|
+
files: [],
|
|
522
|
+
totalAdditions: 0,
|
|
523
|
+
totalDeletions: 0,
|
|
524
|
+
patch: null,
|
|
525
|
+
truncated: false
|
|
526
|
+
};
|
|
527
|
+
const SHOW_FORMAT = [
|
|
528
|
+
"%H",
|
|
529
|
+
"%h",
|
|
530
|
+
"%P",
|
|
531
|
+
"%an",
|
|
532
|
+
"%ae",
|
|
533
|
+
"%aI",
|
|
534
|
+
"%cn",
|
|
535
|
+
"%ce",
|
|
536
|
+
"%cI",
|
|
537
|
+
"%D",
|
|
538
|
+
"%s",
|
|
539
|
+
"%b"
|
|
540
|
+
].join("");
|
|
541
|
+
function parseNumstat(raw) {
|
|
542
|
+
return splitClean(raw, "\n").map((line) => {
|
|
543
|
+
const [add, del, ...rest] = line.split(" ");
|
|
544
|
+
const binary = add === "-" || del === "-";
|
|
545
|
+
return {
|
|
546
|
+
path: rest.join(" "),
|
|
547
|
+
additions: binary ? 0 : Number(add),
|
|
548
|
+
deletions: binary ? 0 : Number(del),
|
|
549
|
+
binary
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async function readCommit(git, hash, includePatch) {
|
|
554
|
+
const meta = await tryGit(git.cwd, [
|
|
555
|
+
"show",
|
|
556
|
+
"-s",
|
|
557
|
+
`--format=${SHOW_FORMAT}`,
|
|
558
|
+
hash
|
|
559
|
+
]);
|
|
560
|
+
if (meta == null) return {
|
|
561
|
+
...EMPTY_DETAIL,
|
|
562
|
+
isRepo: true
|
|
563
|
+
};
|
|
564
|
+
const [fullHash, shortHash, parents, author, email, authorDate, committer, committerEmail, committerDate, refs, subject, body] = meta.split("");
|
|
565
|
+
const numstat = await tryGit(git.cwd, [
|
|
566
|
+
"diff-tree",
|
|
567
|
+
"--no-commit-id",
|
|
568
|
+
"--numstat",
|
|
569
|
+
"-r",
|
|
570
|
+
"--root",
|
|
571
|
+
hash
|
|
572
|
+
]);
|
|
573
|
+
const files = numstat ? parseNumstat(numstat) : [];
|
|
574
|
+
const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
|
|
575
|
+
const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
|
|
576
|
+
let patch = null;
|
|
577
|
+
let truncated = false;
|
|
578
|
+
if (includePatch) {
|
|
579
|
+
const raw = await tryGit(git.cwd, [
|
|
580
|
+
"diff-tree",
|
|
581
|
+
"-p",
|
|
582
|
+
"--no-commit-id",
|
|
583
|
+
"-r",
|
|
584
|
+
"--root",
|
|
585
|
+
hash
|
|
586
|
+
]);
|
|
587
|
+
if (raw != null) if (raw.length > PATCH_CHAR_LIMIT) {
|
|
588
|
+
patch = raw.slice(0, PATCH_CHAR_LIMIT);
|
|
589
|
+
truncated = true;
|
|
590
|
+
} else patch = raw;
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
isRepo: true,
|
|
594
|
+
found: true,
|
|
595
|
+
hash: fullHash,
|
|
596
|
+
shortHash,
|
|
597
|
+
author,
|
|
598
|
+
email,
|
|
599
|
+
date: Date.parse(authorDate),
|
|
600
|
+
committer,
|
|
601
|
+
committerEmail,
|
|
602
|
+
commitDate: Date.parse(committerDate),
|
|
603
|
+
subject,
|
|
604
|
+
body: (body ?? "").trim(),
|
|
605
|
+
parents: parents ? parents.split(" ").filter(Boolean) : [],
|
|
606
|
+
refs: refs ? refs.split(", ").map((r) => r.trim()).filter(Boolean) : [],
|
|
607
|
+
files,
|
|
608
|
+
totalAdditions,
|
|
609
|
+
totalDeletions,
|
|
610
|
+
patch,
|
|
611
|
+
truncated
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const show = defineRpcFunction({
|
|
615
|
+
name: "git:show",
|
|
616
|
+
type: "query",
|
|
617
|
+
jsonSerializable: true,
|
|
618
|
+
dump: async (ctx, _handler) => {
|
|
619
|
+
const git = getGitContext(ctx);
|
|
620
|
+
if (!await git.resolveRoot()) return {
|
|
621
|
+
records: [],
|
|
622
|
+
fallback: EMPTY_DETAIL
|
|
623
|
+
};
|
|
624
|
+
const raw = await tryGit(git.cwd, [
|
|
625
|
+
"log",
|
|
626
|
+
"--topo-order",
|
|
627
|
+
`--max-count=${SNAPSHOT_LIMIT}`,
|
|
628
|
+
"--pretty=format:%H"
|
|
629
|
+
]);
|
|
630
|
+
const hashes = raw ? raw.split("\n").filter(Boolean) : [];
|
|
631
|
+
const records = [];
|
|
632
|
+
for (const hash of hashes) {
|
|
633
|
+
const output = await readCommit(git, hash, false);
|
|
634
|
+
records.push({
|
|
635
|
+
inputs: [{ hash }],
|
|
636
|
+
output
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
records,
|
|
641
|
+
fallback: records[0]?.output ?? {
|
|
642
|
+
...EMPTY_DETAIL,
|
|
643
|
+
isRepo: true
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
},
|
|
647
|
+
setup: (ctx) => {
|
|
648
|
+
const git = getGitContext(ctx);
|
|
649
|
+
return { handler: async (args) => {
|
|
650
|
+
const hash = (args?.hash ?? "").trim();
|
|
651
|
+
const includePatch = args?.patch ?? true;
|
|
652
|
+
if (!await git.resolveRoot() || !hash) return EMPTY_DETAIL;
|
|
653
|
+
return readCommit(git, hash, includePatch);
|
|
654
|
+
} };
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/rpc/functions/stage.ts
|
|
659
|
+
const stage = defineRpcFunction({
|
|
660
|
+
name: "git:stage",
|
|
661
|
+
type: "action",
|
|
662
|
+
jsonSerializable: true,
|
|
663
|
+
setup: (ctx) => {
|
|
664
|
+
const git = getGitContext(ctx);
|
|
665
|
+
return { handler: async (args) => {
|
|
666
|
+
const paths = args?.paths ?? [];
|
|
667
|
+
if (await git.resolveRoot() && paths.length > 0) await runGit(git.cwd, [
|
|
668
|
+
"add",
|
|
669
|
+
"--",
|
|
670
|
+
...paths
|
|
671
|
+
]);
|
|
672
|
+
return readStatus(git);
|
|
673
|
+
} };
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/rpc/functions/unstage.ts
|
|
678
|
+
const unstage = defineRpcFunction({
|
|
679
|
+
name: "git:unstage",
|
|
680
|
+
type: "action",
|
|
681
|
+
jsonSerializable: true,
|
|
682
|
+
setup: (ctx) => {
|
|
683
|
+
const git = getGitContext(ctx);
|
|
684
|
+
return { handler: async (args) => {
|
|
685
|
+
const paths = args?.paths ?? [];
|
|
686
|
+
if (await git.resolveRoot() && paths.length > 0) await runGit(git.cwd, [
|
|
687
|
+
"restore",
|
|
688
|
+
"--staged",
|
|
689
|
+
"--",
|
|
690
|
+
...paths
|
|
691
|
+
]);
|
|
692
|
+
return readStatus(git);
|
|
693
|
+
} };
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/rpc/index.ts
|
|
698
|
+
/** Read-only RPC — always registered. */
|
|
699
|
+
const readFunctions = [
|
|
700
|
+
status,
|
|
701
|
+
log,
|
|
702
|
+
show,
|
|
703
|
+
branches,
|
|
704
|
+
diff
|
|
705
|
+
];
|
|
706
|
+
/** Mutating RPC — registered only when write actions are enabled. */
|
|
707
|
+
const writeFunctions = [
|
|
708
|
+
stage,
|
|
709
|
+
unstage,
|
|
710
|
+
commit
|
|
711
|
+
];
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/index.ts
|
|
714
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
715
|
+
/**
|
|
716
|
+
* Create the Git dashboard devframe. Mount it into any host via devframe's
|
|
717
|
+
* adapters, or run it standalone with the bundled CLI (`devframe-git`).
|
|
718
|
+
*
|
|
719
|
+
* @experimental This plugin is experimental and may change without a major
|
|
720
|
+
* version bump until it stabilizes.
|
|
721
|
+
*/
|
|
722
|
+
function createGitDevframe(options = {}) {
|
|
723
|
+
const distDir = options.distDir ?? resolve(PKG_ROOT, "dist/client");
|
|
724
|
+
return defineDevframe({
|
|
725
|
+
id: "git",
|
|
726
|
+
name: "Git",
|
|
727
|
+
version,
|
|
728
|
+
packageName: name,
|
|
729
|
+
homepage,
|
|
730
|
+
description,
|
|
731
|
+
icon: "ph:git-branch-duotone",
|
|
732
|
+
basePath: options.basePath,
|
|
733
|
+
cli: {
|
|
734
|
+
command: "devframe-git",
|
|
735
|
+
port: options.port ?? 9710,
|
|
736
|
+
distDir,
|
|
737
|
+
auth: false,
|
|
738
|
+
configure(cli) {
|
|
739
|
+
cli.option("--write", "Enable staging, unstaging, and committing from the UI");
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
spa: { loader: "none" },
|
|
743
|
+
setup(ctx, info) {
|
|
744
|
+
const write = options.write ?? info?.flags?.write === true;
|
|
745
|
+
configureGit(ctx, {
|
|
746
|
+
cwd: options.repoRoot ? resolve(options.repoRoot) : ctx.cwd,
|
|
747
|
+
write
|
|
748
|
+
});
|
|
749
|
+
for (const fn of readFunctions) ctx.rpc.register(fn);
|
|
750
|
+
if (write) for (const fn of writeFunctions) ctx.rpc.register(fn);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
var src_default = createGitDevframe();
|
|
755
|
+
//#endregion
|
|
756
|
+
export { src_default as n, createGitDevframe as t };
|