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 +10 -2
- package/design.md +6 -1
- package/openapi.yaml +61 -0
- package/package.json +1 -1
- package/src/app.ts +31 -0
- package/src/git.ts +59 -0
- 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:
|
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
|
@@ -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
|
});
|