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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-daemon",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "main": "dist/daemon.js",
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
- let branch = "";
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
- branch = line.split(" ").slice(2).join(" ").trim();
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 clean =
175
- stagedCount === 0 &&
176
- unstagedCount === 0 &&
177
- untrackedCount === 0 &&
178
- conflictsCount === 0;
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
- clean,
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
  });