git-daemon 0.1.7 → 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
@@ -7,7 +7,8 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
7
7
 
8
8
  ## What it does
9
9
 
10
- - Clone, fetch, and read Git status using your system Git credentials
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/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
@@ -153,6 +154,22 @@ curl -N \
153
154
  http://127.0.0.1:8790/v1/jobs/<JOB_ID>/stream
154
155
  ```
155
156
 
157
+ List branches (local + remote by default):
158
+
159
+ ```bash
160
+ curl -H "Origin: https://app.example.com" \
161
+ -H "Authorization: Bearer <TOKEN>" \
162
+ "http://127.0.0.1:8790/v1/git/branches?repoPath=owner/repo"
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
+
156
173
  ## Configuration
157
174
 
158
175
  Config is stored in OS-specific directories:
package/design.md CHANGED
@@ -20,6 +20,7 @@ The daemon exposes a **localhost HTTP API** guarded by:
20
20
  * Provide additional local dev actions:
21
21
 
22
22
  * `fetch`
23
+ * `branches`
23
24
  * `status`
24
25
  * open folder / terminal / VS Code
25
26
  * run dependency installation (`npm i` / pnpm / yarn)
@@ -275,6 +276,14 @@ Meta response fields (examples):
275
276
 
276
277
  * body: `{ repoPath, remote?: "origin", prune?: true }`
277
278
  * note: fetch updates remote tracking only; no merge/rebase is performed
279
+ * `GET /v1/git/branches?repoPath=...&includeRemote=true` → branch list
280
+
281
+ * returns: `{ branches: [{ name, fullName, type: "local"|"remote", current, upstream? }] }`
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
278
287
  * `GET /v1/git/status?repoPath=...` → structured status
279
288
 
280
289
  * returns: `{ branch, ahead, behind, stagedCount, unstagedCount, untrackedCount, conflictsCount, clean }`
@@ -472,7 +481,7 @@ Recommended `errorCode` values:
472
481
  ## Future Extensions
473
482
 
474
483
  * Repo registry: “known repos” list for safer operations and better UX
475
- * `git pull`, `checkout`, `branch list`, `log` endpoints
484
+ * `git pull`, `checkout`, `log` endpoints
476
485
  * Support multiple terminals/editors
477
486
  * Signed request challenge (HMAC) in addition to bearer token
478
487
 
package/openapi.yaml CHANGED
@@ -182,6 +182,55 @@ paths:
182
182
  $ref: '#/components/responses/UnprocessableEntity'
183
183
  '500':
184
184
  $ref: '#/components/responses/InternalError'
185
+ /v1/git/branches:
186
+ get:
187
+ summary: List repository branches
188
+ parameters:
189
+ - $ref: '#/components/parameters/OriginHeader'
190
+ - $ref: '#/components/parameters/RepoPath'
191
+ - $ref: '#/components/parameters/IncludeRemote'
192
+ responses:
193
+ '200':
194
+ description: Repository branches
195
+ content:
196
+ application/json:
197
+ schema:
198
+ $ref: '#/components/schemas/GitBranchesResponse'
199
+ '401':
200
+ $ref: '#/components/responses/Unauthorized'
201
+ '403':
202
+ $ref: '#/components/responses/Forbidden'
203
+ '404':
204
+ $ref: '#/components/responses/NotFound'
205
+ '409':
206
+ $ref: '#/components/responses/Conflict'
207
+ '422':
208
+ $ref: '#/components/responses/UnprocessableEntity'
209
+ '500':
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'
185
234
  /v1/git/status:
186
235
  get:
187
236
  summary: Get repository status
@@ -309,6 +358,13 @@ components:
309
358
  required: true
310
359
  schema:
311
360
  type: string
361
+ IncludeRemote:
362
+ name: includeRemote
363
+ in: query
364
+ required: false
365
+ schema:
366
+ type: boolean
367
+ default: true
312
368
  schemas:
313
369
  MetaResponse:
314
370
  type: object
@@ -548,6 +604,81 @@ components:
548
604
  minimum: 0
549
605
  clean:
550
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
654
+ GitBranchesResponse:
655
+ type: object
656
+ required:
657
+ - branches
658
+ properties:
659
+ branches:
660
+ type: array
661
+ items:
662
+ $ref: '#/components/schemas/GitBranch'
663
+ GitBranch:
664
+ type: object
665
+ required:
666
+ - name
667
+ - fullName
668
+ - type
669
+ - current
670
+ properties:
671
+ name:
672
+ type: string
673
+ fullName:
674
+ type: string
675
+ type:
676
+ type: string
677
+ enum: [local, remote]
678
+ current:
679
+ type: boolean
680
+ upstream:
681
+ type: string
551
682
  OsOpenRequest:
552
683
  type: object
553
684
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-daemon",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "main": "dist/daemon.js",
package/src/app.ts CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  pairRequestSchema,
29
29
  gitCloneRequestSchema,
30
30
  gitFetchRequestSchema,
31
+ gitBranchesQuerySchema,
32
+ gitSummaryQuerySchema,
31
33
  gitStatusQuerySchema,
32
34
  osOpenRequestSchema,
33
35
  depsInstallRequestSchema,
@@ -41,6 +43,8 @@ import {
41
43
  import {
42
44
  cloneRepo,
43
45
  fetchRepo,
46
+ listRepoBranches,
47
+ getRepoSummary,
44
48
  getRepoStatus,
45
49
  RepoNotFoundError,
46
50
  resolveRepoPath,
@@ -335,6 +339,72 @@ export const createApp = (ctx: DaemonContext) => {
335
339
  },
336
340
  );
337
341
 
342
+ app.get(
343
+ "/v1/git/branches",
344
+ authGuard(ctx.tokenStore),
345
+ async (req, res, next) => {
346
+ try {
347
+ const payload = parseBody(gitBranchesQuerySchema, req.query);
348
+ const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
349
+ const includeRemote = payload.includeRemote !== "false";
350
+ const branches = await listRepoBranches(
351
+ workspaceRoot,
352
+ payload.repoPath,
353
+ {
354
+ includeRemote,
355
+ },
356
+ );
357
+ res.json({ branches });
358
+ } catch (err) {
359
+ if (
360
+ err instanceof RepoNotFoundError ||
361
+ err instanceof MissingPathError
362
+ ) {
363
+ next(repoNotFound());
364
+ return;
365
+ }
366
+ next(err);
367
+ }
368
+ },
369
+ );
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
+
338
408
  app.get(
339
409
  "/v1/git/status",
340
410
  authGuard(ctx.tokenStore),
package/src/git.ts CHANGED
@@ -15,6 +15,41 @@ export type GitStatus = {
15
15
  clean: boolean;
16
16
  };
17
17
 
18
+ export type GitBranch = {
19
+ name: string;
20
+ fullName: string;
21
+ type: "local" | "remote";
22
+ current: boolean;
23
+ upstream?: string;
24
+ };
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
+
18
53
  export class RepoNotFoundError extends Error {}
19
54
 
20
55
  export const cloneRepo = async (
@@ -76,6 +111,64 @@ export const getRepoStatus = async (
76
111
  return parseStatus(result.stdout);
77
112
  };
78
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
+
150
+ export const listRepoBranches = async (
151
+ workspaceRoot: string,
152
+ repoPath: string,
153
+ options?: { includeRemote?: boolean },
154
+ ): Promise<GitBranch[]> => {
155
+ const resolved = await resolveRepoPath(workspaceRoot, repoPath);
156
+ const includeRemote = options?.includeRemote ?? true;
157
+ const { execa } = await import("execa");
158
+ const refs = ["refs/heads"];
159
+ if (includeRemote) {
160
+ refs.push("refs/remotes");
161
+ }
162
+ const result = await execa("git", [
163
+ "-C",
164
+ resolved,
165
+ "for-each-ref",
166
+ "--format=%(refname)\t%(refname:short)\t%(objectname)\t%(HEAD)\t%(upstream:short)",
167
+ ...refs,
168
+ ]);
169
+ return parseBranches(result.stdout);
170
+ };
171
+
79
172
  export const resolveRepoPath = async (
80
173
  workspaceRoot: string,
81
174
  repoPath: string,
@@ -99,7 +192,29 @@ const assertRepoExists = async (repoPath: string) => {
99
192
  };
100
193
 
101
194
  const parseStatus = (output: string): GitStatus => {
102
- 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;
103
218
  let ahead = 0;
104
219
  let behind = 0;
105
220
  let stagedCount = 0;
@@ -110,7 +225,16 @@ const parseStatus = (output: string): GitStatus => {
110
225
  const lines = output.split(/\r?\n/);
111
226
  for (const line of lines) {
112
227
  if (line.startsWith("# branch.head")) {
113
- 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;
114
238
  continue;
115
239
  }
116
240
  if (line.startsWith("# branch.ab")) {
@@ -141,20 +265,51 @@ const parseStatus = (output: string): GitStatus => {
141
265
  }
142
266
  }
143
267
 
144
- const clean =
145
- stagedCount === 0 &&
146
- unstagedCount === 0 &&
147
- untrackedCount === 0 &&
148
- conflictsCount === 0;
268
+ const detached = head === "(detached)";
269
+ let branch = head;
270
+ if (detached) {
271
+ branch = oid || head;
272
+ upstream = null;
273
+ }
149
274
 
150
275
  return {
151
276
  branch,
277
+ upstream,
152
278
  ahead,
153
279
  behind,
154
280
  stagedCount,
155
281
  unstagedCount,
156
282
  untrackedCount,
157
283
  conflictsCount,
158
- clean,
284
+ detached,
159
285
  };
160
286
  };
287
+
288
+ const parseBranches = (output: string): GitBranch[] => {
289
+ const branches: GitBranch[] = [];
290
+ const lines = output.split(/\r?\n/);
291
+ for (const line of lines) {
292
+ if (!line) {
293
+ continue;
294
+ }
295
+ const [fullName, shortName, , headFlag, upstream] = line.split("\t");
296
+ if (!fullName || !shortName) {
297
+ continue;
298
+ }
299
+ if (fullName.startsWith("refs/remotes/") && fullName.endsWith("/HEAD")) {
300
+ continue;
301
+ }
302
+ const type = fullName.startsWith("refs/heads/") ? "local" : "remote";
303
+ const branch: GitBranch = {
304
+ name: shortName,
305
+ fullName,
306
+ type,
307
+ current: headFlag === "*",
308
+ };
309
+ if (upstream) {
310
+ branch.upstream = upstream;
311
+ }
312
+ branches.push(branch);
313
+ }
314
+ return branches;
315
+ };
package/src/validation.ts CHANGED
@@ -56,6 +56,15 @@ 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
+
63
+ export const gitBranchesQuerySchema = z.object({
64
+ repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
65
+ includeRemote: z.enum(["true", "false"]).optional(),
66
+ });
67
+
59
68
  export const osOpenRequestSchema = z.object({
60
69
  target: z.enum(["folder", "terminal", "vscode"]),
61
70
  path: z.string().min(1).max(MAX_PATH_LENGTH),
package/tests/app.test.ts CHANGED
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import os from "os";
5
5
  import { promises as fs } from "fs";
6
6
  import pino from "pino";
7
+ import { execa } from "execa";
7
8
  import { createApp, type DaemonContext } from "../src/app";
8
9
  import { TokenStore } from "../src/tokens";
9
10
  import { PairingManager } from "../src/pairing";
@@ -13,6 +14,32 @@ import type { AppConfig } from "../src/types";
13
14
  const createTempDir = async () =>
14
15
  fs.mkdtemp(path.join(os.tmpdir(), "git-daemon-test-"));
15
16
 
17
+ const runGit = async (cwd: string, args: string[]) => {
18
+ await execa("git", args, { cwd });
19
+ };
20
+
21
+ const setupRepoWithRemote = async (workspaceRoot: string) => {
22
+ const repoDir = path.join(workspaceRoot, "repo");
23
+ const remoteDir = path.join(workspaceRoot, "remote.git");
24
+ await fs.mkdir(repoDir, { recursive: true });
25
+ await runGit(repoDir, ["init", "-b", "main"]);
26
+ await runGit(repoDir, ["config", "user.email", "test@example.com"]);
27
+ await runGit(repoDir, ["config", "user.name", "Test User"]);
28
+ await fs.writeFile(path.join(repoDir, "README.md"), "hello");
29
+ await runGit(repoDir, ["add", "README.md"]);
30
+ await runGit(repoDir, ["commit", "-m", "init"]);
31
+ await runGit(workspaceRoot, ["init", "--bare", remoteDir]);
32
+ await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
33
+ await runGit(repoDir, ["push", "-u", "origin", "main"]);
34
+ await runGit(repoDir, ["checkout", "-b", "feature"]);
35
+ await fs.writeFile(path.join(repoDir, "feature.txt"), "feature");
36
+ await runGit(repoDir, ["add", "feature.txt"]);
37
+ await runGit(repoDir, ["commit", "-m", "feature"]);
38
+ await runGit(repoDir, ["push", "-u", "origin", "feature"]);
39
+ await runGit(repoDir, ["fetch", "origin"]);
40
+ return { repoPath: "repo" };
41
+ };
42
+
16
43
  const createConfig = (
17
44
  workspaceRoot: string | null,
18
45
  origin: string,
@@ -119,4 +146,88 @@ describe("Git Daemon API", () => {
119
146
  expect(res.status).toBe(422);
120
147
  expect(res.body.errorCode).toBe("invalid_repo_url");
121
148
  });
149
+
150
+ it("lists branches including remotes by default", async () => {
151
+ const workspaceRoot = await createTempDir();
152
+ const { repoPath } = await setupRepoWithRemote(workspaceRoot);
153
+ const { app, ctx } = await createContext(workspaceRoot, origin);
154
+ const { token } = await ctx.tokenStore.issueToken(origin, 30);
155
+
156
+ const res = await request(app)
157
+ .get("/v1/git/branches")
158
+ .query({ repoPath })
159
+ .set("Origin", origin)
160
+ .set("Host", "127.0.0.1")
161
+ .set("Authorization", `Bearer ${token}`);
162
+
163
+ expect(res.status).toBe(200);
164
+ const branches = res.body.branches as Array<{
165
+ name: string;
166
+ type: "local" | "remote";
167
+ current: boolean;
168
+ }>;
169
+ const names = branches.map((branch) => branch.name);
170
+ expect(names).toEqual(
171
+ expect.arrayContaining([
172
+ "main",
173
+ "feature",
174
+ "origin/main",
175
+ "origin/feature",
176
+ ]),
177
+ );
178
+ const current = branches.find((branch) => branch.current);
179
+ expect(current?.name).toBe("feature");
180
+ const originMain = branches.find((branch) => branch.name === "origin/main");
181
+ expect(originMain?.type).toBe("remote");
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
+ });
122
233
  });