git-daemon 0.1.7 → 0.1.8

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,7 @@ 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
11
  - Stream long-running job logs via Server-Sent Events (SSE)
12
12
  - Open a repo in the OS file browser, terminal, or VS Code (with approvals)
13
13
  - Install dependencies with safer defaults (`--ignore-scripts` by default)
@@ -24,7 +24,7 @@ Git Daemon is a local Node.js service that exposes a small, authenticated HTTP A
24
24
  ## Requirements
25
25
 
26
26
  - Node.js (for running the daemon)
27
- - Git (for clone/fetch/status)
27
+ - Git (for clone/fetch/branches/status)
28
28
  - Optional: `code` CLI for VS Code, `pnpm`/`yarn` for dependency installs
29
29
 
30
30
  ## Install
@@ -153,6 +153,14 @@ curl -N \
153
153
  http://127.0.0.1:8790/v1/jobs/<JOB_ID>/stream
154
154
  ```
155
155
 
156
+ List branches (local + remote by default):
157
+
158
+ ```bash
159
+ curl -H "Origin: https://app.example.com" \
160
+ -H "Authorization: Bearer <TOKEN>" \
161
+ "http://127.0.0.1:8790/v1/git/branches?repoPath=owner/repo"
162
+ ```
163
+
156
164
  ## Configuration
157
165
 
158
166
  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,10 @@ 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`)
278
283
  * `GET /v1/git/status?repoPath=...` → structured status
279
284
 
280
285
  * returns: `{ branch, ahead, behind, stagedCount, unstagedCount, untrackedCount, conflictsCount, clean }`
@@ -472,7 +477,7 @@ Recommended `errorCode` values:
472
477
  ## Future Extensions
473
478
 
474
479
  * Repo registry: “known repos” list for safer operations and better UX
475
- * `git pull`, `checkout`, `branch list`, `log` endpoints
480
+ * `git pull`, `checkout`, `log` endpoints
476
481
  * Support multiple terminals/editors
477
482
  * Signed request challenge (HMAC) in addition to bearer token
478
483
 
package/openapi.yaml CHANGED
@@ -182,6 +182,32 @@ 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'
185
211
  /v1/git/status:
186
212
  get:
187
213
  summary: Get repository status
@@ -309,6 +335,13 @@ components:
309
335
  required: true
310
336
  schema:
311
337
  type: string
338
+ IncludeRemote:
339
+ name: includeRemote
340
+ in: query
341
+ required: false
342
+ schema:
343
+ type: boolean
344
+ default: true
312
345
  schemas:
313
346
  MetaResponse:
314
347
  type: object
@@ -548,6 +581,34 @@ components:
548
581
  minimum: 0
549
582
  clean:
550
583
  type: boolean
584
+ GitBranchesResponse:
585
+ type: object
586
+ required:
587
+ - branches
588
+ properties:
589
+ branches:
590
+ type: array
591
+ items:
592
+ $ref: '#/components/schemas/GitBranch'
593
+ GitBranch:
594
+ type: object
595
+ required:
596
+ - name
597
+ - fullName
598
+ - type
599
+ - current
600
+ properties:
601
+ name:
602
+ type: string
603
+ fullName:
604
+ type: string
605
+ type:
606
+ type: string
607
+ enum: [local, remote]
608
+ current:
609
+ type: boolean
610
+ upstream:
611
+ type: string
551
612
  OsOpenRequest:
552
613
  type: object
553
614
  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.8",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "main": "dist/daemon.js",
package/src/app.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  pairRequestSchema,
29
29
  gitCloneRequestSchema,
30
30
  gitFetchRequestSchema,
31
+ gitBranchesQuerySchema,
31
32
  gitStatusQuerySchema,
32
33
  osOpenRequestSchema,
33
34
  depsInstallRequestSchema,
@@ -41,6 +42,7 @@ import {
41
42
  import {
42
43
  cloneRepo,
43
44
  fetchRepo,
45
+ listRepoBranches,
44
46
  getRepoStatus,
45
47
  RepoNotFoundError,
46
48
  resolveRepoPath,
@@ -335,6 +337,35 @@ export const createApp = (ctx: DaemonContext) => {
335
337
  },
336
338
  );
337
339
 
340
+ app.get(
341
+ "/v1/git/branches",
342
+ authGuard(ctx.tokenStore),
343
+ async (req, res, next) => {
344
+ try {
345
+ const payload = parseBody(gitBranchesQuerySchema, req.query);
346
+ const workspaceRoot = ensureWorkspaceRoot(ctx.config.workspaceRoot);
347
+ const includeRemote = payload.includeRemote !== "false";
348
+ const branches = await listRepoBranches(
349
+ workspaceRoot,
350
+ payload.repoPath,
351
+ {
352
+ includeRemote,
353
+ },
354
+ );
355
+ res.json({ branches });
356
+ } catch (err) {
357
+ if (
358
+ err instanceof RepoNotFoundError ||
359
+ err instanceof MissingPathError
360
+ ) {
361
+ next(repoNotFound());
362
+ return;
363
+ }
364
+ next(err);
365
+ }
366
+ },
367
+ );
368
+
338
369
  app.get(
339
370
  "/v1/git/status",
340
371
  authGuard(ctx.tokenStore),
package/src/git.ts CHANGED
@@ -15,6 +15,14 @@ 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
+
18
26
  export class RepoNotFoundError extends Error {}
19
27
 
20
28
  export const cloneRepo = async (
@@ -76,6 +84,28 @@ export const getRepoStatus = async (
76
84
  return parseStatus(result.stdout);
77
85
  };
78
86
 
87
+ export const listRepoBranches = async (
88
+ workspaceRoot: string,
89
+ repoPath: string,
90
+ options?: { includeRemote?: boolean },
91
+ ): Promise<GitBranch[]> => {
92
+ const resolved = await resolveRepoPath(workspaceRoot, repoPath);
93
+ const includeRemote = options?.includeRemote ?? true;
94
+ const { execa } = await import("execa");
95
+ const refs = ["refs/heads"];
96
+ if (includeRemote) {
97
+ refs.push("refs/remotes");
98
+ }
99
+ const result = await execa("git", [
100
+ "-C",
101
+ resolved,
102
+ "for-each-ref",
103
+ "--format=%(refname)\t%(refname:short)\t%(objectname)\t%(HEAD)\t%(upstream:short)",
104
+ ...refs,
105
+ ]);
106
+ return parseBranches(result.stdout);
107
+ };
108
+
79
109
  export const resolveRepoPath = async (
80
110
  workspaceRoot: string,
81
111
  repoPath: string,
@@ -158,3 +188,32 @@ const parseStatus = (output: string): GitStatus => {
158
188
  clean,
159
189
  };
160
190
  };
191
+
192
+ const parseBranches = (output: string): GitBranch[] => {
193
+ const branches: GitBranch[] = [];
194
+ const lines = output.split(/\r?\n/);
195
+ for (const line of lines) {
196
+ if (!line) {
197
+ continue;
198
+ }
199
+ const [fullName, shortName, , headFlag, upstream] = line.split("\t");
200
+ if (!fullName || !shortName) {
201
+ continue;
202
+ }
203
+ if (fullName.startsWith("refs/remotes/") && fullName.endsWith("/HEAD")) {
204
+ continue;
205
+ }
206
+ const type = fullName.startsWith("refs/heads/") ? "local" : "remote";
207
+ const branch: GitBranch = {
208
+ name: shortName,
209
+ fullName,
210
+ type,
211
+ current: headFlag === "*",
212
+ };
213
+ if (upstream) {
214
+ branch.upstream = upstream;
215
+ }
216
+ branches.push(branch);
217
+ }
218
+ return branches;
219
+ };
package/src/validation.ts CHANGED
@@ -56,6 +56,11 @@ export const gitStatusQuerySchema = z.object({
56
56
  repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
57
57
  });
58
58
 
59
+ export const gitBranchesQuerySchema = z.object({
60
+ repoPath: z.string().min(1).max(MAX_PATH_LENGTH),
61
+ includeRemote: z.enum(["true", "false"]).optional(),
62
+ });
63
+
59
64
  export const osOpenRequestSchema = z.object({
60
65
  target: z.enum(["folder", "terminal", "vscode"]),
61
66
  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,38 @@ 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
+ });
122
183
  });