git-daemon 0.1.8 → 0.1.9
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/README.md +10 -1
- package/design.md +4 -0
- package/openapi.yaml +70 -0
- package/package.json +1 -1
- package/src/app.ts +39 -0
- package/src/git.ts +104 -8
- package/src/validation.ts +4 -0
- package/tests/app.test.ts +50 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
|
|
|
8
8
|
## What it does
|
|
9
9
|
|
|
10
10
|
- Clone, fetch, list branches, and read Git status using your system Git credentials
|
|
11
|
+
- Provide a status summary for UI badges/tooltips
|
|
11
12
|
- Stream long-running job logs via Server-Sent Events (SSE)
|
|
12
13
|
- Open a repo in the OS file browser, terminal, or VS Code (with approvals)
|
|
13
14
|
- Install dependencies with safer defaults (`--ignore-scripts` by default)
|
|
@@ -24,7 +25,7 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
|
|
|
24
25
|
## Requirements
|
|
25
26
|
|
|
26
27
|
- Node.js (for running the daemon)
|
|
27
|
-
- Git (for clone/fetch/branches/status)
|
|
28
|
+
- Git (for clone/fetch/branches/status/summary)
|
|
28
29
|
- Optional: `code` CLI for VS Code, `pnpm`/`yarn` for dependency installs
|
|
29
30
|
|
|
30
31
|
## Install
|
|
@@ -161,6 +162,14 @@ curl -H "Origin: https://app.example.com" \
|
|
|
161
162
|
"http://127.0.0.1:8790/v1/git/branches?repoPath=owner/repo"
|
|
162
163
|
```
|
|
163
164
|
|
|
165
|
+
Status summary (UI-friendly):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
curl -H "Origin: https://app.example.com" \
|
|
169
|
+
-H "Authorization: Bearer <TOKEN>" \
|
|
170
|
+
"http://127.0.0.1:8790/v1/git/summary?repoPath=owner/repo"
|
|
171
|
+
```
|
|
172
|
+
|
|
164
173
|
## Configuration
|
|
165
174
|
|
|
166
175
|
Config is stored in OS-specific directories:
|
package/design.md
CHANGED
|
@@ -280,6 +280,10 @@ Meta response fields (examples):
|
|
|
280
280
|
|
|
281
281
|
* returns: `{ branches: [{ name, fullName, type: "local"|"remote", current, upstream? }] }`
|
|
282
282
|
* `includeRemote` defaults to `true` and includes remote tracking branches (e.g. `origin/main`)
|
|
283
|
+
* `GET /v1/git/summary?repoPath=...` → status summary for UI badges/tooltips
|
|
284
|
+
|
|
285
|
+
* returns: `{ repoPath, exists, branch, upstream, ahead, behind, dirty, staged, unstaged, untracked, conflicts, detached }`
|
|
286
|
+
* if repo is missing, `exists` is `false` with zeroed counts
|
|
283
287
|
* `GET /v1/git/status?repoPath=...` → structured status
|
|
284
288
|
|
|
285
289
|
* returns: `{ branch, ahead, behind, stagedCount, unstagedCount, untrackedCount, conflictsCount, clean }`
|
package/openapi.yaml
CHANGED
|
@@ -208,6 +208,29 @@ paths:
|
|
|
208
208
|
$ref: '#/components/responses/UnprocessableEntity'
|
|
209
209
|
'500':
|
|
210
210
|
$ref: '#/components/responses/InternalError'
|
|
211
|
+
/v1/git/summary:
|
|
212
|
+
get:
|
|
213
|
+
summary: Get repository status summary
|
|
214
|
+
parameters:
|
|
215
|
+
- $ref: '#/components/parameters/OriginHeader'
|
|
216
|
+
- $ref: '#/components/parameters/RepoPath'
|
|
217
|
+
responses:
|
|
218
|
+
'200':
|
|
219
|
+
description: Repository status summary
|
|
220
|
+
content:
|
|
221
|
+
application/json:
|
|
222
|
+
schema:
|
|
223
|
+
$ref: '#/components/schemas/GitSummary'
|
|
224
|
+
'401':
|
|
225
|
+
$ref: '#/components/responses/Unauthorized'
|
|
226
|
+
'403':
|
|
227
|
+
$ref: '#/components/responses/Forbidden'
|
|
228
|
+
'409':
|
|
229
|
+
$ref: '#/components/responses/Conflict'
|
|
230
|
+
'422':
|
|
231
|
+
$ref: '#/components/responses/UnprocessableEntity'
|
|
232
|
+
'500':
|
|
233
|
+
$ref: '#/components/responses/InternalError'
|
|
211
234
|
/v1/git/status:
|
|
212
235
|
get:
|
|
213
236
|
summary: Get repository status
|
|
@@ -581,6 +604,53 @@ components:
|
|
|
581
604
|
minimum: 0
|
|
582
605
|
clean:
|
|
583
606
|
type: boolean
|
|
607
|
+
GitSummary:
|
|
608
|
+
type: object
|
|
609
|
+
required:
|
|
610
|
+
- repoPath
|
|
611
|
+
- exists
|
|
612
|
+
- branch
|
|
613
|
+
- upstream
|
|
614
|
+
- ahead
|
|
615
|
+
- behind
|
|
616
|
+
- dirty
|
|
617
|
+
- staged
|
|
618
|
+
- unstaged
|
|
619
|
+
- untracked
|
|
620
|
+
- conflicts
|
|
621
|
+
- detached
|
|
622
|
+
properties:
|
|
623
|
+
repoPath:
|
|
624
|
+
type: string
|
|
625
|
+
exists:
|
|
626
|
+
type: boolean
|
|
627
|
+
branch:
|
|
628
|
+
type: string
|
|
629
|
+
upstream:
|
|
630
|
+
type: string
|
|
631
|
+
nullable: true
|
|
632
|
+
ahead:
|
|
633
|
+
type: integer
|
|
634
|
+
minimum: 0
|
|
635
|
+
behind:
|
|
636
|
+
type: integer
|
|
637
|
+
minimum: 0
|
|
638
|
+
dirty:
|
|
639
|
+
type: boolean
|
|
640
|
+
staged:
|
|
641
|
+
type: integer
|
|
642
|
+
minimum: 0
|
|
643
|
+
unstaged:
|
|
644
|
+
type: integer
|
|
645
|
+
minimum: 0
|
|
646
|
+
untracked:
|
|
647
|
+
type: integer
|
|
648
|
+
minimum: 0
|
|
649
|
+
conflicts:
|
|
650
|
+
type: integer
|
|
651
|
+
minimum: 0
|
|
652
|
+
detached:
|
|
653
|
+
type: boolean
|
|
584
654
|
GitBranchesResponse:
|
|
585
655
|
type: object
|
|
586
656
|
required:
|
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
gitCloneRequestSchema,
|
|
30
30
|
gitFetchRequestSchema,
|
|
31
31
|
gitBranchesQuerySchema,
|
|
32
|
+
gitSummaryQuerySchema,
|
|
32
33
|
gitStatusQuerySchema,
|
|
33
34
|
osOpenRequestSchema,
|
|
34
35
|
depsInstallRequestSchema,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
cloneRepo,
|
|
44
45
|
fetchRepo,
|
|
45
46
|
listRepoBranches,
|
|
47
|
+
getRepoSummary,
|
|
46
48
|
getRepoStatus,
|
|
47
49
|
RepoNotFoundError,
|
|
48
50
|
resolveRepoPath,
|
|
@@ -366,6 +368,43 @@ export const createApp = (ctx: DaemonContext) => {
|
|
|
366
368
|
},
|
|
367
369
|
);
|
|
368
370
|
|
|
371
|
+
app.get(
|
|
372
|
+
"/v1/git/summary",
|
|
373
|
+
authGuard(ctx.tokenStore),
|
|
374
|
+
async (req, res, next) => {
|
|
375
|
+
let payload: { repoPath: string } | undefined;
|
|
376
|
+
try {
|
|
377
|
+
payload = parseBody(gitSummaryQuerySchema, req.query);
|
|
378
|
+
const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
|
|
379
|
+
const summary = await getRepoSummary(workspaceRoot, payload.repoPath);
|
|
380
|
+
res.json(summary);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (
|
|
383
|
+
(err instanceof RepoNotFoundError ||
|
|
384
|
+
err instanceof MissingPathError) &&
|
|
385
|
+
payload
|
|
386
|
+
) {
|
|
387
|
+
res.json({
|
|
388
|
+
repoPath: payload.repoPath,
|
|
389
|
+
exists: false,
|
|
390
|
+
branch: "",
|
|
391
|
+
upstream: null,
|
|
392
|
+
ahead: 0,
|
|
393
|
+
behind: 0,
|
|
394
|
+
dirty: false,
|
|
395
|
+
staged: 0,
|
|
396
|
+
unstaged: 0,
|
|
397
|
+
untracked: 0,
|
|
398
|
+
conflicts: 0,
|
|
399
|
+
detached: false,
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
next(err);
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
);
|
|
407
|
+
|
|
369
408
|
app.get(
|
|
370
409
|
"/v1/git/status",
|
|
371
410
|
authGuard(ctx.tokenStore),
|
package/src/git.ts
CHANGED
|
@@ -23,6 +23,33 @@ export type GitBranch = {
|
|
|
23
23
|
upstream?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
export type GitSummary = {
|
|
27
|
+
repoPath: string;
|
|
28
|
+
exists: boolean;
|
|
29
|
+
branch: string;
|
|
30
|
+
upstream: string | null;
|
|
31
|
+
ahead: number;
|
|
32
|
+
behind: number;
|
|
33
|
+
dirty: boolean;
|
|
34
|
+
staged: number;
|
|
35
|
+
unstaged: number;
|
|
36
|
+
untracked: number;
|
|
37
|
+
conflicts: number;
|
|
38
|
+
detached: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type GitStatusInfo = {
|
|
42
|
+
branch: string;
|
|
43
|
+
upstream: string | null;
|
|
44
|
+
ahead: number;
|
|
45
|
+
behind: number;
|
|
46
|
+
stagedCount: number;
|
|
47
|
+
unstagedCount: number;
|
|
48
|
+
untrackedCount: number;
|
|
49
|
+
conflictsCount: number;
|
|
50
|
+
detached: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
26
53
|
export class RepoNotFoundError extends Error {}
|
|
27
54
|
|
|
28
55
|
export const cloneRepo = async (
|
|
@@ -84,6 +111,42 @@ export const getRepoStatus = async (
|
|
|
84
111
|
return parseStatus(result.stdout);
|
|
85
112
|
};
|
|
86
113
|
|
|
114
|
+
export const getRepoSummary = async (
|
|
115
|
+
workspaceRoot: string,
|
|
116
|
+
repoPath: string,
|
|
117
|
+
): Promise<GitSummary> => {
|
|
118
|
+
const resolved = await resolveRepoPath(workspaceRoot, repoPath);
|
|
119
|
+
const { execa } = await import("execa");
|
|
120
|
+
const result = await execa("git", [
|
|
121
|
+
"-C",
|
|
122
|
+
resolved,
|
|
123
|
+
"status",
|
|
124
|
+
"--porcelain=2",
|
|
125
|
+
"-b",
|
|
126
|
+
]);
|
|
127
|
+
const info = parseStatusInfo(result.stdout);
|
|
128
|
+
const dirty =
|
|
129
|
+
info.stagedCount > 0 ||
|
|
130
|
+
info.unstagedCount > 0 ||
|
|
131
|
+
info.untrackedCount > 0 ||
|
|
132
|
+
info.conflictsCount > 0;
|
|
133
|
+
const upstream = info.detached ? null : info.upstream;
|
|
134
|
+
return {
|
|
135
|
+
repoPath,
|
|
136
|
+
exists: true,
|
|
137
|
+
branch: info.branch,
|
|
138
|
+
upstream,
|
|
139
|
+
ahead: upstream ? info.ahead : 0,
|
|
140
|
+
behind: upstream ? info.behind : 0,
|
|
141
|
+
dirty,
|
|
142
|
+
staged: info.stagedCount,
|
|
143
|
+
unstaged: info.unstagedCount,
|
|
144
|
+
untracked: info.untrackedCount,
|
|
145
|
+
conflicts: info.conflictsCount,
|
|
146
|
+
detached: info.detached,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
87
150
|
export const listRepoBranches = async (
|
|
88
151
|
workspaceRoot: string,
|
|
89
152
|
repoPath: string,
|
|
@@ -129,7 +192,29 @@ const assertRepoExists = async (repoPath: string) => {
|
|
|
129
192
|
};
|
|
130
193
|
|
|
131
194
|
const parseStatus = (output: string): GitStatus => {
|
|
132
|
-
|
|
195
|
+
const info = parseStatusInfo(output);
|
|
196
|
+
const clean =
|
|
197
|
+
info.stagedCount === 0 &&
|
|
198
|
+
info.unstagedCount === 0 &&
|
|
199
|
+
info.untrackedCount === 0 &&
|
|
200
|
+
info.conflictsCount === 0;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
branch: info.branch,
|
|
204
|
+
ahead: info.ahead,
|
|
205
|
+
behind: info.behind,
|
|
206
|
+
stagedCount: info.stagedCount,
|
|
207
|
+
unstagedCount: info.unstagedCount,
|
|
208
|
+
untrackedCount: info.untrackedCount,
|
|
209
|
+
conflictsCount: info.conflictsCount,
|
|
210
|
+
clean,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const parseStatusInfo = (output: string): GitStatusInfo => {
|
|
215
|
+
let head = "";
|
|
216
|
+
let oid = "";
|
|
217
|
+
let upstream: string | null = null;
|
|
133
218
|
let ahead = 0;
|
|
134
219
|
let behind = 0;
|
|
135
220
|
let stagedCount = 0;
|
|
@@ -140,7 +225,16 @@ const parseStatus = (output: string): GitStatus => {
|
|
|
140
225
|
const lines = output.split(/\r?\n/);
|
|
141
226
|
for (const line of lines) {
|
|
142
227
|
if (line.startsWith("# branch.head")) {
|
|
143
|
-
|
|
228
|
+
head = line.split(" ").slice(2).join(" ").trim();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (line.startsWith("# branch.oid")) {
|
|
232
|
+
oid = line.split(" ").slice(2).join(" ").trim();
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (line.startsWith("# branch.upstream")) {
|
|
236
|
+
const value = line.split(" ").slice(2).join(" ").trim();
|
|
237
|
+
upstream = value.length > 0 ? value : null;
|
|
144
238
|
continue;
|
|
145
239
|
}
|
|
146
240
|
if (line.startsWith("# branch.ab")) {
|
|
@@ -171,21 +265,23 @@ const parseStatus = (output: string): GitStatus => {
|
|
|
171
265
|
}
|
|
172
266
|
}
|
|
173
267
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
268
|
+
const detached = head === "(detached)";
|
|
269
|
+
let branch = head;
|
|
270
|
+
if (detached) {
|
|
271
|
+
branch = oid || head;
|
|
272
|
+
upstream = null;
|
|
273
|
+
}
|
|
179
274
|
|
|
180
275
|
return {
|
|
181
276
|
branch,
|
|
277
|
+
upstream,
|
|
182
278
|
ahead,
|
|
183
279
|
behind,
|
|
184
280
|
stagedCount,
|
|
185
281
|
unstagedCount,
|
|
186
282
|
untrackedCount,
|
|
187
283
|
conflictsCount,
|
|
188
|
-
|
|
284
|
+
detached,
|
|
189
285
|
};
|
|
190
286
|
};
|
|
191
287
|
|
package/src/validation.ts
CHANGED
|
@@ -56,6 +56,10 @@ export const gitStatusQuerySchema = z.object({
|
|
|
56
56
|
repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
export const gitSummaryQuerySchema = z.object({
|
|
60
|
+
repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
|
|
61
|
+
});
|
|
62
|
+
|
|
59
63
|
export const gitBranchesQuerySchema = z.object({
|
|
60
64
|
repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
|
|
61
65
|
includeRemote: z.enum(["true", "false"]).optional(),
|
package/tests/app.test.ts
CHANGED
|
@@ -180,4 +180,54 @@ describe("Git Daemon API", () => {
|
|
|
180
180
|
const originMain = branches.find((branch) => branch.name === "origin/main");
|
|
181
181
|
expect(originMain?.type).toBe("remote");
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
it("returns a UI-friendly status summary", async () => {
|
|
185
|
+
const workspaceRoot = await createTempDir();
|
|
186
|
+
const { repoPath } = await setupRepoWithRemote(workspaceRoot);
|
|
187
|
+
const repoDir = path.join(workspaceRoot, repoPath);
|
|
188
|
+
await fs.writeFile(path.join(repoDir, "scratch.txt"), "dirty");
|
|
189
|
+
const { app, ctx } = await createContext(workspaceRoot, origin);
|
|
190
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
191
|
+
|
|
192
|
+
const res = await request(app)
|
|
193
|
+
.get("/v1/git/summary")
|
|
194
|
+
.query({ repoPath })
|
|
195
|
+
.set("Origin", origin)
|
|
196
|
+
.set("Host", "127.0.0.1")
|
|
197
|
+
.set("Authorization", `Bearer ${token}`);
|
|
198
|
+
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
expect(res.body.repoPath).toBe(repoPath);
|
|
201
|
+
expect(res.body.exists).toBe(true);
|
|
202
|
+
expect(res.body.branch).toBe("feature");
|
|
203
|
+
expect(res.body.upstream).toBe("origin/feature");
|
|
204
|
+
expect(res.body.ahead).toBe(0);
|
|
205
|
+
expect(res.body.behind).toBe(0);
|
|
206
|
+
expect(res.body.dirty).toBe(true);
|
|
207
|
+
expect(res.body.untracked).toBe(1);
|
|
208
|
+
expect(res.body.staged).toBe(0);
|
|
209
|
+
expect(res.body.unstaged).toBe(0);
|
|
210
|
+
expect(res.body.conflicts).toBe(0);
|
|
211
|
+
expect(res.body.detached).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns exists false when summary repo is missing", async () => {
|
|
215
|
+
const workspaceRoot = await createTempDir();
|
|
216
|
+
const { app, ctx } = await createContext(workspaceRoot, origin);
|
|
217
|
+
const { token } = await ctx.tokenStore.issueToken(origin, 30);
|
|
218
|
+
|
|
219
|
+
const res = await request(app)
|
|
220
|
+
.get("/v1/git/summary")
|
|
221
|
+
.query({ repoPath: "missing" })
|
|
222
|
+
.set("Origin", origin)
|
|
223
|
+
.set("Host", "127.0.0.1")
|
|
224
|
+
.set("Authorization", `Bearer ${token}`);
|
|
225
|
+
|
|
226
|
+
expect(res.status).toBe(200);
|
|
227
|
+
expect(res.body.exists).toBe(false);
|
|
228
|
+
expect(res.body.branch).toBe("");
|
|
229
|
+
expect(res.body.dirty).toBe(false);
|
|
230
|
+
expect(res.body.ahead).toBe(0);
|
|
231
|
+
expect(res.body.behind).toBe(0);
|
|
232
|
+
});
|
|
183
233
|
});
|