git-daemon 0.1.6 → 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:
@@ -176,6 +184,10 @@ Key settings live in `config.json`:
176
184
 
177
185
  Tokens are stored (hashed) in `tokens.json`. Logs are written under the configured `logging.directory` with rotation.
178
186
 
187
+ Approvals can be scoped per repo or origin-wide. To allow a capability for all repos
188
+ from an origin, set `"repoPath": null` in an approvals entry. When a TTY is
189
+ available, the daemon will prompt for approval on first use.
190
+
179
191
  ## Development
180
192
 
181
193
  Run tests:
@@ -174,7 +174,11 @@
174
174
  "format": "uri"
175
175
  },
176
176
  "repoPath": {
177
- "type": "string"
177
+ "type": [
178
+ "string",
179
+ "null"
180
+ ],
181
+ "description": "Repo path to scope approvals. Null means all repos for the origin."
178
182
  },
179
183
  "capabilities": {
180
184
  "type": "array",
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.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "main": "dist/daemon.js",
package/src/app.ts CHANGED
@@ -3,7 +3,9 @@ import rateLimit from "express-rate-limit";
3
3
  import type { Logger } from "pino";
4
4
  import type { Request, Response, NextFunction } from "express";
5
5
  import { promises as fs } from "fs";
6
+ import * as fsSync from "fs";
6
7
  import path from "path";
8
+ import readline from "readline";
7
9
  import { createHttpLogger } from "./logger";
8
10
  import type { AppConfig, Capabilities } from "./types";
9
11
  import {
@@ -26,6 +28,7 @@ import {
26
28
  pairRequestSchema,
27
29
  gitCloneRequestSchema,
28
30
  gitFetchRequestSchema,
31
+ gitBranchesQuerySchema,
29
32
  gitStatusQuerySchema,
30
33
  osOpenRequestSchema,
31
34
  depsInstallRequestSchema,
@@ -39,6 +42,7 @@ import {
39
42
  import {
40
43
  cloneRepo,
41
44
  fetchRepo,
45
+ listRepoBranches,
42
46
  getRepoStatus,
43
47
  RepoNotFoundError,
44
48
  resolveRepoPath,
@@ -48,7 +52,8 @@ import { openTarget } from "./os";
48
52
  import type { TokenStore } from "./tokens";
49
53
  import type { PairingManager } from "./pairing";
50
54
  import type { JobManager } from "./jobs";
51
- import { requireApproval } from "./approvals";
55
+ import { hasApproval, requireApproval } from "./approvals";
56
+ import { saveConfig } from "./config";
52
57
 
53
58
  export type DaemonContext = {
54
59
  config: AppConfig;
@@ -332,6 +337,35 @@ export const createApp = (ctx: DaemonContext) => {
332
337
  },
333
338
  );
334
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
+
335
369
  app.get(
336
370
  "/v1/git/status",
337
371
  authGuard(ctx.tokenStore),
@@ -365,8 +399,8 @@ export const createApp = (ctx: DaemonContext) => {
365
399
  );
366
400
 
367
401
  if (payload.target === "terminal") {
368
- requireApproval(
369
- ctx.config,
402
+ await ensureApproval(
403
+ ctx,
370
404
  origin,
371
405
  resolved,
372
406
  "open-terminal",
@@ -374,8 +408,8 @@ export const createApp = (ctx: DaemonContext) => {
374
408
  );
375
409
  }
376
410
  if (payload.target === "vscode") {
377
- requireApproval(
378
- ctx.config,
411
+ await ensureApproval(
412
+ ctx,
379
413
  origin,
380
414
  resolved,
381
415
  "open-vscode",
@@ -411,8 +445,8 @@ export const createApp = (ctx: DaemonContext) => {
411
445
  } catch {
412
446
  throw repoNotFound();
413
447
  }
414
- requireApproval(
415
- ctx.config,
448
+ await ensureApproval(
449
+ ctx,
416
450
  origin,
417
451
  resolved,
418
452
  "deps/install",
@@ -462,8 +496,22 @@ export const createApp = (ctx: DaemonContext) => {
462
496
  }
463
497
  });
464
498
 
465
- app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
499
+ app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
466
500
  if (err instanceof ApiError) {
501
+ if (err.status === 409) {
502
+ console.warn(
503
+ `[Git Daemon] 409 ${err.body.errorCode} ${req.method} ${req.originalUrl} origin=${req.headers.origin ?? ""}`,
504
+ );
505
+ ctx.logger.warn(
506
+ {
507
+ errorCode: err.body.errorCode,
508
+ method: req.method,
509
+ path: req.originalUrl,
510
+ origin: req.headers.origin,
511
+ },
512
+ "Request rejected with conflict",
513
+ );
514
+ }
467
515
  res.status(err.status).json(err.body);
468
516
  return;
469
517
  }
@@ -480,3 +528,86 @@ export const createApp = (ctx: DaemonContext) => {
480
528
 
481
529
  return app;
482
530
  };
531
+
532
+ const ensureApproval = async (
533
+ ctx: DaemonContext,
534
+ origin: string,
535
+ repoPath: string,
536
+ capability: "open-terminal" | "open-vscode" | "deps/install",
537
+ workspaceRoot?: string | null,
538
+ ) => {
539
+ if (hasApproval(ctx.config, origin, repoPath, capability, workspaceRoot)) {
540
+ return;
541
+ }
542
+ const approved = await promptApproval(origin, repoPath, capability);
543
+ if (!approved) {
544
+ requireApproval(ctx.config, origin, repoPath, capability, workspaceRoot);
545
+ return;
546
+ }
547
+ upsertOriginApproval(ctx, origin, capability);
548
+ await saveConfig(ctx.configDir, ctx.config);
549
+ };
550
+
551
+ const createPromptInterface = () => {
552
+ if (process.stdin.isTTY && process.stdout.isTTY) {
553
+ return readline.createInterface({
554
+ input: process.stdin,
555
+ output: process.stdout,
556
+ });
557
+ }
558
+ const ttyPath = process.platform === "win32" ? "CON" : "/dev/tty";
559
+ try {
560
+ const input = fsSync.createReadStream(ttyPath, { encoding: "utf8" });
561
+ const output = fsSync.createWriteStream(ttyPath);
562
+ return readline.createInterface({ input, output });
563
+ } catch {
564
+ return null;
565
+ }
566
+ };
567
+
568
+ const askQuestion = (rl: readline.Interface, question: string) =>
569
+ new Promise<string>((resolve) => {
570
+ rl.question(question, (answer) => resolve(answer));
571
+ });
572
+
573
+ const promptApproval = async (
574
+ origin: string,
575
+ repoPath: string,
576
+ capability: string,
577
+ ) => {
578
+ const rl = createPromptInterface();
579
+ if (!rl) {
580
+ return false;
581
+ }
582
+ const answer = await askQuestion(
583
+ rl,
584
+ `Approve ${capability} for origin ${origin} (all repos)? [y/N] Requested path: ${repoPath} `,
585
+ );
586
+ rl.close();
587
+ const normalized = answer.trim().toLowerCase();
588
+ return normalized === "y" || normalized === "yes";
589
+ };
590
+
591
+ const upsertOriginApproval = (
592
+ ctx: DaemonContext,
593
+ origin: string,
594
+ capability: "open-terminal" | "open-vscode" | "deps/install",
595
+ ) => {
596
+ const existing = ctx.config.approvals.entries.find(
597
+ (entry) =>
598
+ entry.origin === origin &&
599
+ (entry.repoPath === null || entry.repoPath === "*"),
600
+ );
601
+ if (existing) {
602
+ if (!existing.capabilities.includes(capability)) {
603
+ existing.capabilities.push(capability);
604
+ }
605
+ return;
606
+ }
607
+ ctx.config.approvals.entries.push({
608
+ origin,
609
+ repoPath: null,
610
+ capabilities: [capability],
611
+ approvedAt: new Date().toISOString(),
612
+ });
613
+ };
package/src/approvals.ts CHANGED
@@ -13,10 +13,13 @@ export const hasApproval = (
13
13
  if (entry.origin !== origin || !entry.capabilities.includes(capability)) {
14
14
  return false;
15
15
  }
16
+ if (entry.repoPath === null || entry.repoPath === "*") {
17
+ return true;
18
+ }
16
19
  if (entry.repoPath === repoPath) {
17
20
  return true;
18
21
  }
19
- if (workspaceRoot && !path.isAbsolute(entry.repoPath)) {
22
+ if (workspaceRoot && entry.repoPath && !path.isAbsolute(entry.repoPath)) {
20
23
  return path.resolve(workspaceRoot, entry.repoPath) === repoPath;
21
24
  }
22
25
  return false;
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/types.ts CHANGED
@@ -2,7 +2,7 @@ export type Capability = "open-terminal" | "open-vscode" | "deps/install";
2
2
 
3
3
  export type ApprovalEntry = {
4
4
  origin: string;
5
- repoPath: string;
5
+ repoPath: string | null;
6
6
  capabilities: Capability[];
7
7
  approvedAt: string;
8
8
  };
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
  });