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 +14 -2
- package/config.schema.json +5 -1
- package/design.md +6 -1
- package/openapi.yaml +61 -0
- package/package.json +1 -1
- package/src/app.ts +139 -8
- package/src/approvals.ts +4 -1
- package/src/git.ts +59 -0
- package/src/types.ts +1 -1
- package/src/validation.ts +5 -0
- package/tests/app.test.ts +61 -0
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:
|
package/config.schema.json
CHANGED
|
@@ -174,7 +174,11 @@
|
|
|
174
174
|
"format": "uri"
|
|
175
175
|
},
|
|
176
176
|
"repoPath": {
|
|
177
|
-
"type":
|
|
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`, `
|
|
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
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
|
-
|
|
369
|
-
ctx
|
|
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
|
-
|
|
378
|
-
ctx
|
|
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
|
-
|
|
415
|
-
ctx
|
|
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,
|
|
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
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
|
});
|