@devframes/service-git 0.9.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-PRESENT Anthony Fu <https://github.com/antfu>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,214 @@
1
+ import { DevframeServiceDefinition } from "devframe/types";
2
+ //#region src/types.d.ts
3
+ type FileStatusCode = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' | 'type-changed' | 'unmerged' | 'unknown';
4
+ interface StatusFileEntry {
5
+ path: string;
6
+ /** Previous path, present for renames and copies. */
7
+ from?: string;
8
+ status: FileStatusCode;
9
+ }
10
+ interface GitStatus {
11
+ /** `false` when the working directory is not inside a git repository. */
12
+ isRepo: boolean;
13
+ root: string | null;
14
+ /** Current branch name, or `null` when HEAD is detached. */
15
+ branch: string | null;
16
+ detached: boolean;
17
+ /** Short HEAD object name. */
18
+ head: string | null;
19
+ upstream: string | null;
20
+ ahead: number;
21
+ behind: number;
22
+ staged: StatusFileEntry[];
23
+ unstaged: StatusFileEntry[];
24
+ untracked: string[];
25
+ /** `true` when there are no staged, unstaged, or untracked changes. */
26
+ clean: boolean;
27
+ /** `true` when stage / unstage / commit actions are available. */
28
+ canWrite: boolean;
29
+ }
30
+ interface Commit {
31
+ hash: string;
32
+ shortHash: string;
33
+ author: string;
34
+ email: string;
35
+ /** Author date as epoch milliseconds. */
36
+ date: number;
37
+ subject: string;
38
+ body: string;
39
+ /** Ref names pointing at this commit (branches, tags, HEAD). */
40
+ refs: string[];
41
+ /** Full parent hashes — drives the commit graph. */
42
+ parents: string[];
43
+ }
44
+ interface GitLog {
45
+ isRepo: boolean;
46
+ commits: Commit[];
47
+ limit: number;
48
+ skip: number;
49
+ /** `true` when the page filled to `limit`, hinting at further history. */
50
+ hasMore: boolean;
51
+ }
52
+ interface LogArgs {
53
+ /** Number of commits to return (clamped to 1–200, default 30). */
54
+ limit?: number;
55
+ /** Commits to skip from the tip, for pagination (default 0). */
56
+ skip?: number;
57
+ /** Optional ref/branch to read history from (default: current HEAD). */
58
+ ref?: string;
59
+ }
60
+ interface Branch {
61
+ name: string;
62
+ current: boolean;
63
+ sha: string;
64
+ upstream: string | null;
65
+ subject: string;
66
+ ahead: number;
67
+ behind: number;
68
+ /** `true` when the upstream branch no longer exists. */
69
+ gone: boolean;
70
+ }
71
+ interface GitBranches {
72
+ isRepo: boolean;
73
+ current: string | null;
74
+ branches: Branch[];
75
+ }
76
+ interface DiffFile {
77
+ path: string;
78
+ additions: number;
79
+ deletions: number;
80
+ binary: boolean;
81
+ }
82
+ interface GitDiff {
83
+ isRepo: boolean;
84
+ staged: boolean;
85
+ path: string | null;
86
+ files: DiffFile[];
87
+ totalAdditions: number;
88
+ totalDeletions: number;
89
+ /** Unified patch text — populated when `path` targets a single file. */
90
+ patch: string | null;
91
+ /** `true` when `patch` was clipped to the internal char limit. */
92
+ truncated: boolean;
93
+ }
94
+ interface DiffArgs {
95
+ /** Limit the diff to a single path; omit for the whole tree. */
96
+ path?: string;
97
+ /** Diff the index against HEAD instead of the working tree. */
98
+ staged?: boolean;
99
+ }
100
+ interface CommitFile {
101
+ path: string;
102
+ additions: number;
103
+ deletions: number;
104
+ binary: boolean;
105
+ /** Change kind relative to the parent (add / modify / delete / rename …). */
106
+ status: FileStatusCode;
107
+ }
108
+ interface CommitDetail {
109
+ /** `false` when the working directory is not inside a git repository. */
110
+ isRepo: boolean;
111
+ /** `false` when the hash does not resolve to a commit. */
112
+ found: boolean;
113
+ hash: string;
114
+ shortHash: string;
115
+ author: string;
116
+ email: string;
117
+ /** Author date as epoch milliseconds. */
118
+ date: number;
119
+ committer: string;
120
+ committerEmail: string;
121
+ /** Commit date as epoch milliseconds. */
122
+ commitDate: number;
123
+ subject: string;
124
+ body: string;
125
+ parents: string[];
126
+ refs: string[];
127
+ files: CommitFile[];
128
+ totalAdditions: number;
129
+ totalDeletions: number;
130
+ /** Unified patch text for the commit, or `null` when omitted/unavailable. */
131
+ patch: string | null;
132
+ /** `true` when `patch` was clipped to the internal char limit. */
133
+ truncated: boolean;
134
+ }
135
+ interface ShowArgs {
136
+ /** Commit-ish to inspect (full or short hash). */
137
+ hash: string;
138
+ /** Include the full unified patch (default `true`). */
139
+ patch?: boolean;
140
+ }
141
+ interface StageArgs {
142
+ /** Paths to stage (`git add`). */
143
+ paths: string[];
144
+ }
145
+ interface UnstageArgs {
146
+ /** Paths to unstage (`git restore --staged`). */
147
+ paths: string[];
148
+ }
149
+ interface CommitArgs {
150
+ /** Commit message. */
151
+ message: string;
152
+ }
153
+ interface CommitResult {
154
+ /** `true` when the commit succeeded. */
155
+ ok: boolean;
156
+ /** Short hash of the new commit, or `null` on failure. */
157
+ hash: string | null;
158
+ /** Human-readable outcome (e.g. "nothing to commit"). */
159
+ message: string;
160
+ /** Working-tree status after the attempt. */
161
+ status: GitStatus;
162
+ }
163
+ /** The node API a consumer gets from `ctx.services.get('@devframes/service-git')`. */
164
+ interface GitServiceApi {
165
+ status: () => Promise<GitStatus>;
166
+ log: (args?: LogArgs) => Promise<GitLog>;
167
+ show: (args: ShowArgs) => Promise<CommitDetail>;
168
+ diff: (args?: DiffArgs) => Promise<GitDiff>;
169
+ branches: () => Promise<GitBranches>;
170
+ stage: (args: StageArgs) => Promise<GitStatus>;
171
+ unstage: (args: UnstageArgs) => Promise<GitStatus>;
172
+ commit: (args: CommitArgs) => Promise<CommitResult>;
173
+ }
174
+ //#endregion
175
+ //#region src/index.d.ts
176
+ declare const GIT_SERVICE_PACKAGE = "@devframes/service-git";
177
+ declare const GIT_SERVICE_SCOPE = "devframes:service:git";
178
+ interface GitServiceOptions {
179
+ /**
180
+ * Repository directory to operate on. Defaults to the context's `cwd`; the
181
+ * repo root is discovered once (`rev-parse --show-toplevel`) and memoized.
182
+ */
183
+ cwd?: string;
184
+ }
185
+ declare module 'devframe' {
186
+ interface DevframeRpcServerFunctions {
187
+ 'devframes:service:git:status': () => Promise<GitStatus>;
188
+ 'devframes:service:git:log': (args?: LogArgs) => Promise<GitLog>;
189
+ 'devframes:service:git:show': (args: ShowArgs) => Promise<CommitDetail>;
190
+ 'devframes:service:git:diff': (args?: DiffArgs) => Promise<GitDiff>;
191
+ 'devframes:service:git:branches': () => Promise<GitBranches>;
192
+ 'devframes:service:git:stage': (args: StageArgs) => Promise<GitStatus>;
193
+ 'devframes:service:git:unstage': (args: UnstageArgs) => Promise<GitStatus>;
194
+ 'devframes:service:git:commit': (args: CommitArgs) => Promise<CommitResult>;
195
+ }
196
+ interface DevframeServicesRegistry {
197
+ '@devframes/service-git': GitServiceApi;
198
+ }
199
+ interface DevframeServicesScopeRegistry {
200
+ '@devframes/service-git': 'devframes:service:git';
201
+ }
202
+ }
203
+ /**
204
+ * The git wire service — read/write git operations shared over RPC by every
205
+ * plugin on the host, generalizing the utilities that used to live inside the
206
+ * git plugin. The exec wrapper and output parsers stay internal; consumers get
207
+ * the typed {@link GitServiceApi} in-process (`ctx.services.get`) and the same
208
+ * ops over `devframes:service:git:*` RPC. Write ops are always exposed —
209
+ * authorization is the host's connection-trust boundary. The service defines
210
+ * no `dump`/`snapshot`; a devframe bakes what it needs via `snapshotRpc`.
211
+ */
212
+ declare function createGitService(options?: GitServiceOptions): DevframeServiceDefinition<GitServiceApi, GitServiceOptions>;
213
+ //#endregion
214
+ export { Branch, Commit, CommitArgs, CommitDetail, CommitFile, CommitResult, DiffArgs, DiffFile, FileStatusCode, GIT_SERVICE_PACKAGE, GIT_SERVICE_SCOPE, GitBranches, GitDiff, GitLog, GitServiceApi, GitServiceOptions, GitStatus, LogArgs, ShowArgs, StageArgs, StatusFileEntry, UnstageArgs, createGitService, createGitService as default };
package/dist/index.mjs ADDED
@@ -0,0 +1,764 @@
1
+ import { defineRpcFunction } from "devframe";
2
+ import { s } from "devframe/utils/simple-schema";
3
+ import { execFile } from "node:child_process";
4
+ import process from "node:process";
5
+ import { promisify } from "node:util";
6
+ //#region package.json
7
+ var version = "0.9.1";
8
+ //#endregion
9
+ //#region src/git.ts
10
+ const execFileAsync = promisify(execFile);
11
+ const MAX_BUFFER = 67108864;
12
+ /**
13
+ * Run a git command in `cwd`. Rejects when git exits non-zero — callers that
14
+ * tolerate failure (e.g. "no upstream configured") should use {@link tryGit}.
15
+ */
16
+ async function runGit(cwd, args) {
17
+ const { stdout, stderr } = await execFileAsync("git", args, {
18
+ cwd,
19
+ maxBuffer: MAX_BUFFER,
20
+ windowsHide: true,
21
+ env: {
22
+ ...process.env,
23
+ GIT_PAGER: "cat",
24
+ GIT_OPTIONAL_LOCKS: "0",
25
+ LC_ALL: "C"
26
+ }
27
+ });
28
+ return {
29
+ stdout,
30
+ stderr
31
+ };
32
+ }
33
+ /** Run a git command, returning trimmed stdout or `null` when it fails. */
34
+ async function tryGit(cwd, args) {
35
+ try {
36
+ const { stdout } = await runGit(cwd, args);
37
+ return stdout.replace(/\n$/, "");
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * Caller-supplied revisions must not start with `-`, which Git would parse as
44
+ * an option before it treats the value as a revision.
45
+ */
46
+ function isSafeRevision(rev) {
47
+ return rev.length > 0 && !rev.startsWith("-");
48
+ }
49
+ /** Resolve the repository root for `cwd`, or `null` when `cwd` is outside a repo. */
50
+ async function resolveRepoRoot(cwd) {
51
+ return tryGit(cwd, ["rev-parse", "--show-toplevel"]);
52
+ }
53
+ /** Split git output on a separator, dropping the trailing empty segment. */
54
+ function splitClean(input, separator) {
55
+ return input.split(separator).filter((part) => part.length > 0);
56
+ }
57
+ /**
58
+ * Extract a concise, single-line message from a failed `execFile` error.
59
+ * git writes useful text (e.g. "nothing to commit") to stdout/stderr.
60
+ */
61
+ function gitErrorMessage(error) {
62
+ const e = error;
63
+ return (e?.stderr || e?.stdout || e?.message || "git command failed").trim().split("\n")[0];
64
+ }
65
+ //#endregion
66
+ //#region src/operations.ts
67
+ /** Hard cap on returned patch text to keep payloads bounded. */
68
+ const PATCH_CHAR_LIMIT = 2e5;
69
+ const EMPTY_STATUS = {
70
+ isRepo: false,
71
+ root: null,
72
+ branch: null,
73
+ detached: false,
74
+ head: null,
75
+ upstream: null,
76
+ ahead: 0,
77
+ behind: 0,
78
+ staged: [],
79
+ unstaged: [],
80
+ untracked: [],
81
+ clean: true,
82
+ canWrite: false
83
+ };
84
+ function mapCode(code) {
85
+ switch (code) {
86
+ case "M": return "modified";
87
+ case "A": return "added";
88
+ case "D": return "deleted";
89
+ case "R": return "renamed";
90
+ case "C": return "copied";
91
+ case "T": return "type-changed";
92
+ case "U": return "unmerged";
93
+ default: return "unknown";
94
+ }
95
+ }
96
+ /**
97
+ * Parse `git status --porcelain=v2 --branch -z` into a structured snapshot.
98
+ * Records are NUL-separated; rename/copy (type `2`) entries consume an extra
99
+ * token for the original path.
100
+ */
101
+ function parseStatus(root, raw) {
102
+ const tokens = raw.split("\0");
103
+ const status = {
104
+ ...EMPTY_STATUS,
105
+ isRepo: true,
106
+ root,
107
+ staged: [],
108
+ unstaged: [],
109
+ untracked: []
110
+ };
111
+ for (let i = 0; i < tokens.length; i++) {
112
+ const token = tokens[i];
113
+ if (!token) continue;
114
+ if (token.startsWith("# ")) {
115
+ const [, key, ...rest] = token.split(" ");
116
+ const value = rest.join(" ");
117
+ if (key === "branch.head") {
118
+ if (value === "(detached)") {
119
+ status.detached = true;
120
+ status.branch = null;
121
+ } else status.branch = value;
122
+ } else if (key === "branch.oid" && value !== "(initial)") status.head = value.slice(0, 9);
123
+ else if (key === "branch.upstream") status.upstream = value;
124
+ else if (key === "branch.ab") {
125
+ const match = value.match(/\+(\d+)\s+-(\d+)/);
126
+ if (match) {
127
+ status.ahead = Number(match[1]);
128
+ status.behind = Number(match[2]);
129
+ }
130
+ }
131
+ continue;
132
+ }
133
+ if (token.startsWith("1 ") || token.startsWith("2 ")) {
134
+ const renamed = token.startsWith("2 ");
135
+ const fields = token.split(" ");
136
+ const xy = fields[1];
137
+ const x = xy[0];
138
+ const y = xy[1];
139
+ const path = fields.slice(renamed ? 9 : 8).join(" ");
140
+ const from = renamed ? tokens[++i] : void 0;
141
+ if (x !== ".") status.staged.push(from ? {
142
+ path,
143
+ from,
144
+ status: mapCode(x)
145
+ } : {
146
+ path,
147
+ status: mapCode(x)
148
+ });
149
+ if (y !== ".") status.unstaged.push({
150
+ path,
151
+ status: mapCode(y)
152
+ });
153
+ continue;
154
+ }
155
+ if (token.startsWith("u ")) {
156
+ const path = token.split(" ").slice(10).join(" ");
157
+ status.unstaged.push({
158
+ path,
159
+ status: "unmerged"
160
+ });
161
+ continue;
162
+ }
163
+ if (token.startsWith("? ")) status.untracked.push(token.slice(2));
164
+ }
165
+ status.clean = status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0;
166
+ return status;
167
+ }
168
+ const LOG_FORMAT = [
169
+ "%H",
170
+ "%h",
171
+ "%P",
172
+ "%an",
173
+ "%ae",
174
+ "%aI",
175
+ "%D",
176
+ "%s",
177
+ "%b"
178
+ ].join("") + "";
179
+ function clamp(value, min, max) {
180
+ return Math.min(Math.max(value, min), max);
181
+ }
182
+ function parseLog(raw) {
183
+ return splitClean(raw, "").map((record) => {
184
+ const [hash, shortHash, parents, author, email, isoDate, refs, subject, body] = record.replace(/^\n/, "").split("");
185
+ return {
186
+ hash,
187
+ shortHash,
188
+ author,
189
+ email,
190
+ date: Date.parse(isoDate),
191
+ subject,
192
+ body: (body ?? "").trim(),
193
+ refs: refs ? refs.split(", ").map((r) => r.trim()).filter(Boolean) : [],
194
+ parents: parents ? parents.split(" ").filter(Boolean) : []
195
+ };
196
+ });
197
+ }
198
+ const BRANCH_FORMAT = [
199
+ "%(refname:short)",
200
+ "%(objectname:short)",
201
+ "%(HEAD)",
202
+ "%(upstream:short)",
203
+ "%(upstream:track)",
204
+ "%(contents:subject)"
205
+ ].join("");
206
+ function parseTrack(track) {
207
+ if (track.includes("gone")) return {
208
+ ahead: 0,
209
+ behind: 0,
210
+ gone: true
211
+ };
212
+ const ahead = track.match(/ahead (\d+)/);
213
+ const behind = track.match(/behind (\d+)/);
214
+ return {
215
+ ahead: ahead ? Number(ahead[1]) : 0,
216
+ behind: behind ? Number(behind[1]) : 0,
217
+ gone: false
218
+ };
219
+ }
220
+ function parseNumstat(raw) {
221
+ return splitClean(raw, "\n").map((line) => {
222
+ const [add, del, ...rest] = line.split(" ");
223
+ const binary = add === "-" || del === "-";
224
+ return {
225
+ path: rest.join(" "),
226
+ additions: binary ? 0 : Number(add),
227
+ deletions: binary ? 0 : Number(del),
228
+ binary
229
+ };
230
+ });
231
+ }
232
+ const SHOW_FORMAT = [
233
+ "%H",
234
+ "%h",
235
+ "%P",
236
+ "%an",
237
+ "%ae",
238
+ "%aI",
239
+ "%cn",
240
+ "%ce",
241
+ "%cI",
242
+ "%D",
243
+ "%s",
244
+ "%b"
245
+ ].join("");
246
+ const EMPTY_DETAIL = {
247
+ isRepo: false,
248
+ found: false,
249
+ hash: "",
250
+ shortHash: "",
251
+ author: "",
252
+ email: "",
253
+ date: 0,
254
+ committer: "",
255
+ committerEmail: "",
256
+ commitDate: 0,
257
+ subject: "",
258
+ body: "",
259
+ parents: [],
260
+ refs: [],
261
+ files: [],
262
+ totalAdditions: 0,
263
+ totalDeletions: 0,
264
+ patch: null,
265
+ truncated: false
266
+ };
267
+ function mapStatusCode(code) {
268
+ switch (code[0]) {
269
+ case "M": return "modified";
270
+ case "A": return "added";
271
+ case "D": return "deleted";
272
+ case "R": return "renamed";
273
+ case "C": return "copied";
274
+ case "T": return "type-changed";
275
+ case "U": return "unmerged";
276
+ default: return "unknown";
277
+ }
278
+ }
279
+ function parseNameStatus(raw) {
280
+ const map = /* @__PURE__ */ new Map();
281
+ for (const line of splitClean(raw, "\n")) {
282
+ const [code, ...paths] = line.split(" ");
283
+ const path = paths[paths.length - 1];
284
+ if (path) map.set(path, mapStatusCode(code));
285
+ }
286
+ return map;
287
+ }
288
+ function parseCommitNumstat(raw, status) {
289
+ return splitClean(raw, "\n").map((line) => {
290
+ const [add, del, ...rest] = line.split(" ");
291
+ const binary = add === "-" || del === "-";
292
+ const path = rest.join(" ");
293
+ return {
294
+ path,
295
+ additions: binary ? 0 : Number(add),
296
+ deletions: binary ? 0 : Number(del),
297
+ binary,
298
+ status: status.get(path) ?? "modified"
299
+ };
300
+ });
301
+ }
302
+ function clipPatch(raw) {
303
+ return raw.length > PATCH_CHAR_LIMIT ? {
304
+ patch: raw.slice(0, PATCH_CHAR_LIMIT),
305
+ truncated: true
306
+ } : {
307
+ patch: raw,
308
+ truncated: false
309
+ };
310
+ }
311
+ /**
312
+ * Build the git node API bound to a single working directory, with the repo
313
+ * root resolved once (memoized). This is the surface returned to in-process
314
+ * consumers and wrapped by the service's RPC.
315
+ */
316
+ function createGitOps(cwd) {
317
+ let rootPromise;
318
+ const resolveRoot = () => rootPromise ??= resolveRepoRoot(cwd);
319
+ async function status() {
320
+ const root = await resolveRoot();
321
+ if (!root) return { ...EMPTY_STATUS };
322
+ const { stdout } = await runGit(cwd, [
323
+ "status",
324
+ "--porcelain=v2",
325
+ "--branch",
326
+ "-z"
327
+ ]);
328
+ const result = parseStatus(root, stdout);
329
+ result.canWrite = true;
330
+ return result;
331
+ }
332
+ async function readCommit(hash, includePatch) {
333
+ if (!isSafeRevision(hash)) return {
334
+ ...EMPTY_DETAIL,
335
+ isRepo: true
336
+ };
337
+ const meta = await tryGit(cwd, [
338
+ "show",
339
+ "-s",
340
+ `--format=${SHOW_FORMAT}`,
341
+ "--end-of-options",
342
+ hash
343
+ ]);
344
+ if (meta == null) return {
345
+ ...EMPTY_DETAIL,
346
+ isRepo: true
347
+ };
348
+ const [fullHash, shortHash, parents, author, email, authorDate, committer, committerEmail, committerDate, refs, subject, body] = meta.split("");
349
+ const numstat = await tryGit(cwd, [
350
+ "diff-tree",
351
+ "--no-commit-id",
352
+ "--numstat",
353
+ "-r",
354
+ "--root",
355
+ "--end-of-options",
356
+ hash
357
+ ]);
358
+ const nameStatusRaw = await tryGit(cwd, [
359
+ "diff-tree",
360
+ "--no-commit-id",
361
+ "--name-status",
362
+ "-r",
363
+ "--root",
364
+ "--end-of-options",
365
+ hash
366
+ ]);
367
+ const files = numstat ? parseCommitNumstat(numstat, nameStatusRaw ? parseNameStatus(nameStatusRaw) : /* @__PURE__ */ new Map()) : [];
368
+ let patch = null;
369
+ let truncated = false;
370
+ if (includePatch) {
371
+ const raw = await tryGit(cwd, [
372
+ "diff-tree",
373
+ "-p",
374
+ "--no-commit-id",
375
+ "-r",
376
+ "--root",
377
+ "--end-of-options",
378
+ hash
379
+ ]);
380
+ if (raw != null) ({patch, truncated} = clipPatch(raw));
381
+ }
382
+ return {
383
+ isRepo: true,
384
+ found: true,
385
+ hash: fullHash,
386
+ shortHash,
387
+ author,
388
+ email,
389
+ date: Date.parse(authorDate),
390
+ committer,
391
+ committerEmail,
392
+ commitDate: Date.parse(committerDate),
393
+ subject,
394
+ body: (body ?? "").trim(),
395
+ parents: parents ? parents.split(" ").filter(Boolean) : [],
396
+ refs: refs ? refs.split(", ").map((r) => r.trim()).filter(Boolean) : [],
397
+ files,
398
+ totalAdditions: files.reduce((sum, f) => sum + f.additions, 0),
399
+ totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
400
+ patch,
401
+ truncated
402
+ };
403
+ }
404
+ return {
405
+ status,
406
+ async log(args = {}) {
407
+ const limit = clamp(Math.trunc(args.limit ?? 30), 1, 200);
408
+ const skip = Math.max(0, Math.trunc(args.skip ?? 0));
409
+ const ref = args.ref?.trim() || void 0;
410
+ if (!await resolveRoot()) return {
411
+ isRepo: false,
412
+ commits: [],
413
+ limit,
414
+ skip,
415
+ hasMore: false
416
+ };
417
+ const command = [
418
+ "log",
419
+ "--topo-order",
420
+ `--max-count=${limit}`,
421
+ `--skip=${skip}`,
422
+ `--pretty=format:${LOG_FORMAT}`
423
+ ];
424
+ if (ref) {
425
+ if (!isSafeRevision(ref)) return {
426
+ isRepo: true,
427
+ commits: [],
428
+ limit,
429
+ skip,
430
+ hasMore: false
431
+ };
432
+ command.push("--end-of-options", ref);
433
+ }
434
+ const raw = await tryGit(cwd, command);
435
+ const commits = raw ? parseLog(raw) : [];
436
+ return {
437
+ isRepo: true,
438
+ commits,
439
+ limit,
440
+ skip,
441
+ hasMore: commits.length === limit
442
+ };
443
+ },
444
+ async show(args) {
445
+ const hash = (args?.hash ?? "").trim();
446
+ const includePatch = args?.patch ?? true;
447
+ if (!await resolveRoot() || !hash) return { ...EMPTY_DETAIL };
448
+ return readCommit(hash, includePatch);
449
+ },
450
+ async diff(args = {}) {
451
+ const { path, staged = false } = args;
452
+ if (!await resolveRoot()) return {
453
+ isRepo: false,
454
+ staged,
455
+ path: path ?? null,
456
+ files: [],
457
+ totalAdditions: 0,
458
+ totalDeletions: 0,
459
+ patch: null,
460
+ truncated: false
461
+ };
462
+ const base = staged ? ["diff", "--cached"] : ["diff"];
463
+ const scope = path ? ["--", path] : [];
464
+ const numstatRaw = await tryGit(cwd, [
465
+ ...base,
466
+ "--numstat",
467
+ ...scope
468
+ ]);
469
+ const files = numstatRaw ? parseNumstat(numstatRaw) : [];
470
+ let patch = null;
471
+ let truncated = false;
472
+ if (path) {
473
+ const { stdout } = await runGit(cwd, [...base, ...scope]);
474
+ ({patch, truncated} = clipPatch(stdout));
475
+ }
476
+ return {
477
+ isRepo: true,
478
+ staged,
479
+ path: path ?? null,
480
+ files,
481
+ totalAdditions: files.reduce((sum, f) => sum + f.additions, 0),
482
+ totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),
483
+ patch,
484
+ truncated
485
+ };
486
+ },
487
+ async branches() {
488
+ if (!await resolveRoot()) return {
489
+ isRepo: false,
490
+ current: null,
491
+ branches: []
492
+ };
493
+ const raw = await tryGit(cwd, [
494
+ "for-each-ref",
495
+ `--format=${BRANCH_FORMAT}`,
496
+ "refs/heads"
497
+ ]);
498
+ if (!raw) return {
499
+ isRepo: true,
500
+ current: null,
501
+ branches: []
502
+ };
503
+ let current = null;
504
+ const branches = splitClean(raw, "\n").map((line) => {
505
+ const [name, sha, head, upstream, track, subject] = line.split("");
506
+ const isCurrent = head === "*";
507
+ if (isCurrent) current = name;
508
+ return {
509
+ name,
510
+ current: isCurrent,
511
+ sha,
512
+ upstream: upstream || null,
513
+ subject: subject ?? "",
514
+ ...parseTrack(track ?? "")
515
+ };
516
+ });
517
+ branches.sort((a, b) => Number(b.current) - Number(a.current));
518
+ return {
519
+ isRepo: true,
520
+ current,
521
+ branches
522
+ };
523
+ },
524
+ async stage(args) {
525
+ const paths = args?.paths ?? [];
526
+ if (await resolveRoot() && paths.length > 0) await runGit(cwd, [
527
+ "add",
528
+ "--",
529
+ ...paths
530
+ ]);
531
+ return status();
532
+ },
533
+ async unstage(args) {
534
+ const paths = args?.paths ?? [];
535
+ if (await resolveRoot() && paths.length > 0) await runGit(cwd, [
536
+ "restore",
537
+ "--staged",
538
+ "--",
539
+ ...paths
540
+ ]);
541
+ return status();
542
+ },
543
+ async commit(args) {
544
+ const message = (args?.message ?? "").trim();
545
+ if (!await resolveRoot()) return {
546
+ ok: false,
547
+ hash: null,
548
+ message: "Not a git repository.",
549
+ status: await status()
550
+ };
551
+ if (!message) return {
552
+ ok: false,
553
+ hash: null,
554
+ message: "Commit message is required.",
555
+ status: await status()
556
+ };
557
+ try {
558
+ await runGit(cwd, [
559
+ "commit",
560
+ "-m",
561
+ message
562
+ ]);
563
+ return {
564
+ ok: true,
565
+ hash: await tryGit(cwd, [
566
+ "rev-parse",
567
+ "--short",
568
+ "HEAD"
569
+ ]),
570
+ message: "Committed.",
571
+ status: await status()
572
+ };
573
+ } catch (error) {
574
+ return {
575
+ ok: false,
576
+ hash: null,
577
+ message: gitErrorMessage(error),
578
+ status: await status()
579
+ };
580
+ }
581
+ }
582
+ };
583
+ }
584
+ //#endregion
585
+ //#region src/index.ts
586
+ const GIT_SERVICE_PACKAGE = "@devframes/service-git";
587
+ const GIT_SERVICE_SCOPE = "devframes:service:git";
588
+ const commitSchema = s.object({
589
+ hash: s.string(),
590
+ shortHash: s.string(),
591
+ author: s.string(),
592
+ email: s.string(),
593
+ date: s.number(),
594
+ subject: s.string(),
595
+ body: s.string(),
596
+ refs: s.array(s.string()),
597
+ parents: s.array(s.string())
598
+ });
599
+ const gitLogSchema = s.object({
600
+ isRepo: s.boolean(),
601
+ commits: s.array(commitSchema),
602
+ limit: s.number(),
603
+ skip: s.number(),
604
+ hasMore: s.boolean()
605
+ });
606
+ const fileStatusCodeSchema = s.picklist([
607
+ "modified",
608
+ "added",
609
+ "deleted",
610
+ "renamed",
611
+ "copied",
612
+ "type-changed",
613
+ "unmerged",
614
+ "unknown"
615
+ ]);
616
+ const commitDetailSchema = s.object({
617
+ isRepo: s.boolean(),
618
+ found: s.boolean(),
619
+ hash: s.string(),
620
+ shortHash: s.string(),
621
+ author: s.string(),
622
+ email: s.string(),
623
+ date: s.number(),
624
+ committer: s.string(),
625
+ committerEmail: s.string(),
626
+ commitDate: s.number(),
627
+ subject: s.string(),
628
+ body: s.string(),
629
+ parents: s.array(s.string()),
630
+ refs: s.array(s.string()),
631
+ files: s.array(s.object({
632
+ path: s.string(),
633
+ additions: s.number(),
634
+ deletions: s.number(),
635
+ binary: s.boolean(),
636
+ status: fileStatusCodeSchema
637
+ })),
638
+ totalAdditions: s.number(),
639
+ totalDeletions: s.number(),
640
+ patch: s.nullable(s.string()),
641
+ truncated: s.boolean()
642
+ });
643
+ const gitDiffSchema = s.object({
644
+ isRepo: s.boolean(),
645
+ staged: s.boolean(),
646
+ path: s.nullable(s.string()),
647
+ files: s.array(s.object({
648
+ path: s.string(),
649
+ additions: s.number(),
650
+ deletions: s.number(),
651
+ binary: s.boolean()
652
+ })),
653
+ totalAdditions: s.number(),
654
+ totalDeletions: s.number(),
655
+ patch: s.nullable(s.string()),
656
+ truncated: s.boolean()
657
+ });
658
+ /**
659
+ * The git wire service — read/write git operations shared over RPC by every
660
+ * plugin on the host, generalizing the utilities that used to live inside the
661
+ * git plugin. The exec wrapper and output parsers stay internal; consumers get
662
+ * the typed {@link GitServiceApi} in-process (`ctx.services.get`) and the same
663
+ * ops over `devframes:service:git:*` RPC. Write ops are always exposed —
664
+ * authorization is the host's connection-trust boundary. The service defines
665
+ * no `dump`/`snapshot`; a devframe bakes what it needs via `snapshotRpc`.
666
+ */
667
+ function createGitService(options) {
668
+ return {
669
+ package: GIT_SERVICE_PACKAGE,
670
+ version,
671
+ scope: GIT_SERVICE_SCOPE,
672
+ options,
673
+ setup(ctx, { options }) {
674
+ const ops = createGitOps(options?.cwd ?? ctx.cwd);
675
+ ctx.rpc.register(defineRpcFunction({
676
+ name: "status",
677
+ type: "query",
678
+ jsonSerializable: true,
679
+ agent: {
680
+ title: "Git status",
681
+ description: "Working-tree status of the inspected repository: current branch, ahead/behind counts, and every staged/unstaged/untracked file. Safe to call freely."
682
+ },
683
+ handler: () => ops.status()
684
+ }));
685
+ ctx.rpc.register(defineRpcFunction({
686
+ name: "log",
687
+ type: "query",
688
+ jsonSerializable: true,
689
+ args: [s.object({
690
+ limit: s.optional(s.number()),
691
+ skip: s.optional(s.number()),
692
+ ref: s.optional(s.string())
693
+ })],
694
+ returns: gitLogSchema,
695
+ agent: {
696
+ title: "Git log",
697
+ description: "Commit history of the inspected repository, newest first. Paginate with limit (1-200, default 30) and skip; pass ref to read another branch. Safe to call freely."
698
+ },
699
+ handler: (args = {}) => ops.log(args)
700
+ }));
701
+ ctx.rpc.register(defineRpcFunction({
702
+ name: "show",
703
+ type: "query",
704
+ jsonSerializable: true,
705
+ args: [s.object({
706
+ hash: s.string(),
707
+ patch: s.optional(s.boolean())
708
+ })],
709
+ returns: commitDetailSchema,
710
+ agent: {
711
+ title: "Git show",
712
+ description: "Full detail of one commit by hash: metadata, changed files, and the unified patch (pass patch: false to skip it for large commits). Safe to call freely."
713
+ },
714
+ handler: (args) => ops.show(args)
715
+ }));
716
+ ctx.rpc.register(defineRpcFunction({
717
+ name: "diff",
718
+ type: "query",
719
+ jsonSerializable: true,
720
+ args: [s.object({
721
+ path: s.optional(s.string()),
722
+ staged: s.optional(s.boolean())
723
+ })],
724
+ returns: gitDiffSchema,
725
+ agent: {
726
+ title: "Git diff",
727
+ description: "Unified diff of uncommitted changes — the working tree by default, the index with staged: true, one file with path. Safe to call freely."
728
+ },
729
+ handler: (args = {}) => ops.diff(args)
730
+ }));
731
+ ctx.rpc.register(defineRpcFunction({
732
+ name: "branches",
733
+ type: "query",
734
+ jsonSerializable: true,
735
+ agent: {
736
+ title: "Git branches",
737
+ description: "List local branches with tracking state (ahead/behind, gone upstreams) and the current branch. Safe to call freely."
738
+ },
739
+ handler: () => ops.branches()
740
+ }));
741
+ ctx.rpc.register(defineRpcFunction({
742
+ name: "stage",
743
+ type: "action",
744
+ jsonSerializable: true,
745
+ handler: (args) => ops.stage(args)
746
+ }));
747
+ ctx.rpc.register(defineRpcFunction({
748
+ name: "unstage",
749
+ type: "action",
750
+ jsonSerializable: true,
751
+ handler: (args) => ops.unstage(args)
752
+ }));
753
+ ctx.rpc.register(defineRpcFunction({
754
+ name: "commit",
755
+ type: "action",
756
+ jsonSerializable: true,
757
+ handler: (args) => ops.commit(args)
758
+ }));
759
+ return ops;
760
+ }
761
+ };
762
+ }
763
+ //#endregion
764
+ export { GIT_SERVICE_PACKAGE, GIT_SERVICE_SCOPE, createGitService, createGitService as default };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@devframes/service-git",
3
+ "type": "module",
4
+ "version": "0.9.1",
5
+ "description": "Devframe wire service exposing read/write git operations (status, log, show, diff, branches, stage, unstage, commit) over RPC.",
6
+ "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/devframes/devframe#readme",
9
+ "repository": {
10
+ "directory": "services/git",
11
+ "type": "git",
12
+ "url": "git+https://github.com/devframes/devframe.git"
13
+ },
14
+ "bugs": "https://github.com/devframes/devframe/issues",
15
+ "keywords": [
16
+ "devframe",
17
+ "devframe-service",
18
+ "devtools",
19
+ "git"
20
+ ],
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": "./dist/index.mjs",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "types": "./dist/index.d.mts",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "peerDependencies": {
31
+ "devframe": "0.9.2"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^26.2.0",
35
+ "tsdown": "^0.22.14",
36
+ "vitest": "^4.1.10",
37
+ "devframe": "0.9.2"
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown",
41
+ "watch": "tsdown --watch",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }