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 +19 -2
- package/design.md +10 -1
- package/openapi.yaml +131 -0
- package/package.json +1 -1
- package/src/app.ts +70 -0
- package/src/git.ts +163 -8
- package/src/validation.ts +9 -0
- package/tests/app.test.ts +111 -0
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`, `
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
});
|