cueloop 0.1.0-alpha.0
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/package.json +32 -0
- package/src/args.ts +37 -0
- package/src/main.ts +124 -0
- package/src/pr.ts +118 -0
- package/src/session-cmds.ts +114 -0
- package/src/workspace.ts +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cueloop",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cueloop": "./src/main.ts"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/main.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@cueloop/schema": "workspace:*",
|
|
14
|
+
"@cueloop/daemon": "workspace:*",
|
|
15
|
+
"@cueloop/client": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
30
|
+
"directory": "packages/cli"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Tiny flag parser: --key value, --key=value, boolean --flag, positionals. */
|
|
2
|
+
|
|
3
|
+
export interface ParsedArgs {
|
|
4
|
+
positional: string[];
|
|
5
|
+
flags: Record<string, string | boolean>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
9
|
+
const positional: string[] = [];
|
|
10
|
+
const flags: Record<string, string | boolean> = {};
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i]!;
|
|
13
|
+
if (!a.startsWith("--")) {
|
|
14
|
+
positional.push(a);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const eq = a.indexOf("=");
|
|
18
|
+
if (eq !== -1) {
|
|
19
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
20
|
+
} else {
|
|
21
|
+
const key = a.slice(2);
|
|
22
|
+
const next = argv[i + 1];
|
|
23
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
24
|
+
flags[key] = next;
|
|
25
|
+
i++;
|
|
26
|
+
} else {
|
|
27
|
+
flags[key] = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { positional, flags };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function flagStr(flags: Record<string, string | boolean>, key: string): string | undefined {
|
|
35
|
+
const v = flags[key];
|
|
36
|
+
return typeof v === "string" ? v : undefined;
|
|
37
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* cueloop entry points (#18): `cueloop` opens the TUI on the inbox,
|
|
4
|
+
* `cueloop diff` reviews the working tree, `cueloop review <pr>` a PR,
|
|
5
|
+
* `cueloop session *` mirrors the daemon API for agents and scripts,
|
|
6
|
+
* `cueloop serve` shares a session over ssh (read-only observers),
|
|
7
|
+
* `cueloop daemon` runs the daemon in the foreground.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseArgs, flagStr } from "./args";
|
|
11
|
+
import { sessionCommand } from "./session-cmds";
|
|
12
|
+
import { resolveWorkspace, workingTreeDiff } from "./workspace";
|
|
13
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
14
|
+
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
const cmd = argv[0];
|
|
17
|
+
|
|
18
|
+
async function main(): Promise<number> {
|
|
19
|
+
switch (cmd) {
|
|
20
|
+
case "session":
|
|
21
|
+
return sessionCommand(argv.slice(1));
|
|
22
|
+
case "daemon": {
|
|
23
|
+
const { DaemonServer } = await import("@cueloop/daemon");
|
|
24
|
+
const server = new DaemonServer({ idleExitMs: 0 });
|
|
25
|
+
const path = server.start();
|
|
26
|
+
if (path === null) {
|
|
27
|
+
console.error("a cueloop daemon already owns this home - nothing to do");
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
console.log(`cueloop daemon (foreground) on ${path}`);
|
|
31
|
+
await new Promise(() => {}); // run until signalled
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
case "diff": {
|
|
35
|
+
const workspace = await resolveWorkspace();
|
|
36
|
+
const diff = await workingTreeDiff();
|
|
37
|
+
if (!diff.trim()) {
|
|
38
|
+
console.error("working tree is clean - nothing to review");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
42
|
+
const session = await client.sessionCreate(workspace, {
|
|
43
|
+
type: "diff",
|
|
44
|
+
content: diff,
|
|
45
|
+
meta: { title: `working tree @ ${workspace.branch}`, cwd: process.cwd() },
|
|
46
|
+
});
|
|
47
|
+
client.close();
|
|
48
|
+
return runTui(session.id);
|
|
49
|
+
}
|
|
50
|
+
case "serve": {
|
|
51
|
+
const { positional, flags } = parseArgs(argv.slice(1));
|
|
52
|
+
const port = flagStr(flags, "port");
|
|
53
|
+
const { serveClient } = await import("@cueloop/client");
|
|
54
|
+
const handle = await serveClient({
|
|
55
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
56
|
+
host: flagStr(flags, "host"),
|
|
57
|
+
sessionId: positional[0],
|
|
58
|
+
});
|
|
59
|
+
console.log(
|
|
60
|
+
[
|
|
61
|
+
"",
|
|
62
|
+
`observers join with: ssh -p ${handle.port} ${handle.host === "0.0.0.0" || handle.host === "::" ? "<this-host>" : handle.host}`,
|
|
63
|
+
"",
|
|
64
|
+
"no passwords, no keys: anyone who can reach this address can watch.",
|
|
65
|
+
"share the address deliberately (SSH tunnel, tailnet). observers are",
|
|
66
|
+
"read-only; you stay the one writable controller via `cueloop` locally.",
|
|
67
|
+
"ctrl-c stops serving.",
|
|
68
|
+
].join("\n"),
|
|
69
|
+
);
|
|
70
|
+
const stop = () => void handle.close().finally(() => process.exit(0));
|
|
71
|
+
process.on("SIGINT", stop);
|
|
72
|
+
process.on("SIGTERM", stop);
|
|
73
|
+
await new Promise(() => {}); // serve until signalled
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
case "review": {
|
|
77
|
+
const { reviewCommand } = await import("./pr");
|
|
78
|
+
return reviewCommand(argv.slice(1));
|
|
79
|
+
}
|
|
80
|
+
case "review-post": {
|
|
81
|
+
const { reviewPostCommand } = await import("./pr");
|
|
82
|
+
return reviewPostCommand(argv.slice(1));
|
|
83
|
+
}
|
|
84
|
+
case "--help":
|
|
85
|
+
case "help":
|
|
86
|
+
printHelp();
|
|
87
|
+
return 0;
|
|
88
|
+
case undefined:
|
|
89
|
+
return runTui();
|
|
90
|
+
default: {
|
|
91
|
+
// `cueloop <session-id>` opens the TUI on that session
|
|
92
|
+
if (cmd.startsWith("ses_")) return runTui(cmd);
|
|
93
|
+
printHelp();
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runTui(sessionId?: string): Promise<number> {
|
|
100
|
+
const { runClient } = await import("@cueloop/client");
|
|
101
|
+
return runClient({ sessionId });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function printHelp(): void {
|
|
105
|
+
console.log(
|
|
106
|
+
[
|
|
107
|
+
"cueloop - review surface for coding agents",
|
|
108
|
+
"",
|
|
109
|
+
"usage:",
|
|
110
|
+
" cueloop open the inbox",
|
|
111
|
+
" cueloop <session-id> open one session",
|
|
112
|
+
" cueloop diff review the working tree",
|
|
113
|
+
" cueloop review <pr> review a pull request (--no-tui prints the session)",
|
|
114
|
+
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
115
|
+
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
116
|
+
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
117
|
+
" password-less - share the address deliberately)",
|
|
118
|
+
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
119
|
+
" cueloop daemon run the daemon in the foreground",
|
|
120
|
+
].join("\n"),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.exit(await main());
|
package/src/pr.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR review entry (#18): `cueloop review <pr>` fetches the PR diff through the
|
|
3
|
+
* user's `gh` CLI (auth fully delegated), opens a diff session, and posts the
|
|
4
|
+
* verdict back to the PR as a real review when the session resolves.
|
|
5
|
+
* `cueloop review-post <session-id> <pr>` is the non-interactive post-back
|
|
6
|
+
* half for agents and scripts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
10
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
11
|
+
import { parseArgs } from "./args";
|
|
12
|
+
import { resolveWorkspace } from "./workspace";
|
|
13
|
+
|
|
14
|
+
/** The gh binary is injectable so tests can stub it. */
|
|
15
|
+
function ghBin(): string {
|
|
16
|
+
return process.env.CUELOOP_GH || "gh";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GhResult {
|
|
20
|
+
code: number;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function gh(args: string[]): Promise<GhResult> {
|
|
26
|
+
const proc = Bun.spawn([ghBin(), ...args], { stdout: "pipe", stderr: "pipe" });
|
|
27
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
28
|
+
new Response(proc.stdout).text(),
|
|
29
|
+
new Response(proc.stderr).text(),
|
|
30
|
+
proc.exited,
|
|
31
|
+
]);
|
|
32
|
+
return { code, stdout, stderr };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Verdict kinds map 1:1 onto gh review flags. */
|
|
36
|
+
const VERDICT_FLAG: Record<VerdictKind, string> = {
|
|
37
|
+
approve: "--approve",
|
|
38
|
+
request_changes: "--request-changes",
|
|
39
|
+
comment: "--comment",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function reviewCommand(argv: string[]): Promise<number> {
|
|
43
|
+
const { positional, flags } = parseArgs(argv);
|
|
44
|
+
const pr = positional[0];
|
|
45
|
+
if (!pr) {
|
|
46
|
+
console.error("usage: cueloop review <pr> [--no-tui]");
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const diff = await gh(["pr", "diff", pr]);
|
|
50
|
+
if (diff.code !== 0) {
|
|
51
|
+
console.error(diff.stderr.trim() || `gh pr diff ${pr} failed (exit ${diff.code})`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
if (!diff.stdout.trim()) {
|
|
55
|
+
console.error(`PR ${pr} has an empty diff - nothing to review`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const workspace = await resolveWorkspace();
|
|
59
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
60
|
+
const session = await client.sessionCreate(workspace, {
|
|
61
|
+
type: "diff",
|
|
62
|
+
content: diff.stdout,
|
|
63
|
+
meta: { title: `PR ${pr}`, pr, cwd: process.cwd() },
|
|
64
|
+
});
|
|
65
|
+
client.close();
|
|
66
|
+
|
|
67
|
+
if (flags["no-tui"]) {
|
|
68
|
+
console.log(JSON.stringify(session, null, 2));
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { runClient } = await import("@cueloop/client");
|
|
73
|
+
await runClient({ sessionId: session.id });
|
|
74
|
+
|
|
75
|
+
const after = await getSession(session.id);
|
|
76
|
+
if (after.status !== "resolved" || !after.verdict) {
|
|
77
|
+
console.log(`session ${session.id} is unresolved - nothing was posted to PR ${pr}`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
return postVerdict(after, pr);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function reviewPostCommand(argv: string[]): Promise<number> {
|
|
84
|
+
const { positional } = parseArgs(argv);
|
|
85
|
+
const sessionId = positional[0];
|
|
86
|
+
const pr = positional[1];
|
|
87
|
+
if (!sessionId || !pr) {
|
|
88
|
+
console.error("usage: cueloop review-post <session-id> <pr>");
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const session = await getSession(sessionId);
|
|
92
|
+
if (session.status !== "resolved" || !session.verdict) {
|
|
93
|
+
console.error(`session ${sessionId} is unresolved - nothing was posted to PR ${pr}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
return postVerdict(session, pr);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getSession(id: string): Promise<ReviewSession> {
|
|
100
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
101
|
+
try {
|
|
102
|
+
return await client.sessionGet(id);
|
|
103
|
+
} finally {
|
|
104
|
+
client.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Post the resolved session's verdict to the PR: feedback.md is the review body. */
|
|
109
|
+
async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
|
|
110
|
+
const verdict = session.verdict!;
|
|
111
|
+
const result = await gh(["pr", "review", pr, VERDICT_FLAG[verdict.kind], "--body", verdict.feedback]);
|
|
112
|
+
if (result.code !== 0) {
|
|
113
|
+
console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
console.log(`posted ${verdict.kind} review to PR ${pr} (session ${session.id})`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop session *` - mirrors the daemon socket API 1:1 (#14). One surface,
|
|
3
|
+
* three consumers: agent adapters, the dev loop, and integrations. Output is
|
|
4
|
+
* JSON on stdout; exit code 0 unless the daemon returned an error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ArtifactType, VerdictKind } from "@cueloop/schema";
|
|
8
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
9
|
+
import { parseArgs, flagStr } from "./args";
|
|
10
|
+
import { resolveWorkspace } from "./workspace";
|
|
11
|
+
|
|
12
|
+
async function readStdin(): Promise<string> {
|
|
13
|
+
return await new Response(Bun.stdin.stream()).text();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function out(value: unknown): void {
|
|
17
|
+
console.log(JSON.stringify(value, null, 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function sessionCommand(argv: string[]): Promise<number> {
|
|
21
|
+
const { positional, flags } = parseArgs(argv);
|
|
22
|
+
const verb = positional[0];
|
|
23
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
24
|
+
try {
|
|
25
|
+
switch (verb) {
|
|
26
|
+
case "create": {
|
|
27
|
+
const workspace = await resolveWorkspace(flagStr(flags, "cwd"));
|
|
28
|
+
const contentFile = flagStr(flags, "content-file");
|
|
29
|
+
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
30
|
+
const session = await client.sessionCreate(workspace, {
|
|
31
|
+
type: (flagStr(flags, "type") ?? "plan") as ArtifactType,
|
|
32
|
+
content,
|
|
33
|
+
meta: {
|
|
34
|
+
agent: flagStr(flags, "agent"),
|
|
35
|
+
agentSessionId: flagStr(flags, "agent-session-id"),
|
|
36
|
+
planPath: flagStr(flags, "plan-path"),
|
|
37
|
+
title: flagStr(flags, "title"),
|
|
38
|
+
cwd: flagStr(flags, "cwd") ?? process.cwd(),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
out(session);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
case "get": {
|
|
45
|
+
out(await client.sessionGet(required(positional[1], "session id")));
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
case "list": {
|
|
49
|
+
const status = flagStr(flags, "status") as "pending" | "resolved" | undefined;
|
|
50
|
+
out(await client.sessionList(status ? { status } : undefined));
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
case "wait": {
|
|
54
|
+
const id = required(positional[1], "session id");
|
|
55
|
+
const timeoutMs = Number(flagStr(flags, "timeout-ms") ?? "60000");
|
|
56
|
+
const session = await client.sessionWait(id, timeoutMs);
|
|
57
|
+
if (session === null) {
|
|
58
|
+
out({ status: "pending" });
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
out({
|
|
62
|
+
status: "resolved",
|
|
63
|
+
allow: session.verdict!.kind === "approve",
|
|
64
|
+
verdict: session.verdict!.kind,
|
|
65
|
+
feedback: session.verdict!.feedback,
|
|
66
|
+
});
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
case "annotate": {
|
|
70
|
+
const id = required(positional[1], "session id");
|
|
71
|
+
const body = flagStr(flags, "body") ?? "";
|
|
72
|
+
const quote = required(flagStr(flags, "quote"), "--quote");
|
|
73
|
+
out(
|
|
74
|
+
await client.sessionAnnotate(id, {
|
|
75
|
+
id: flagStr(flags, "annotation-id") ?? `a_${Date.now().toString(36)}`,
|
|
76
|
+
kind: flagStr(flags, "kind") ?? "comment",
|
|
77
|
+
anchor: {
|
|
78
|
+
quote,
|
|
79
|
+
prefix: flagStr(flags, "prefix") ?? "",
|
|
80
|
+
suffix: flagStr(flags, "suffix") ?? "",
|
|
81
|
+
},
|
|
82
|
+
body,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
case "resolve": {
|
|
88
|
+
const id = required(positional[1], "session id");
|
|
89
|
+
const kind = required(flagStr(flags, "verdict"), "--verdict") as VerdictKind;
|
|
90
|
+
out(await client.sessionResolve(id, kind, flagStr(flags, "summary") ?? ""));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
case "submit-revision": {
|
|
94
|
+
const id = required(positional[1], "session id");
|
|
95
|
+
const contentFile = flagStr(flags, "content-file");
|
|
96
|
+
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
97
|
+
out(await client.sessionSubmitRevision(id, content));
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
console.error(
|
|
102
|
+
"usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
|
|
103
|
+
);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
client.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function required<T>(v: T | undefined, what: string): T {
|
|
112
|
+
if (v === undefined) throw new Error(`missing ${what}`);
|
|
113
|
+
return v;
|
|
114
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Workspace key resolution (#9): repo root + branch from the cwd. */
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceKey } from "@cueloop/schema";
|
|
4
|
+
|
|
5
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
6
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
7
|
+
const out = await new Response(proc.stdout).text();
|
|
8
|
+
if ((await proc.exited) !== 0) return null;
|
|
9
|
+
return out.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
13
|
+
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
14
|
+
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
15
|
+
return { repoRoot, branch };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<string> {
|
|
19
|
+
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
20
|
+
// untracked files included by default (map note: changed-first)
|
|
21
|
+
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
22
|
+
let untracked = "";
|
|
23
|
+
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
24
|
+
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
25
|
+
const proc = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
26
|
+
cwd,
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "ignore",
|
|
29
|
+
});
|
|
30
|
+
const out = await new Response(proc.stdout).text();
|
|
31
|
+
const code = await proc.exited;
|
|
32
|
+
if ((code === 0 || code === 1) && out.trim()) untracked += out.trim() + "\n";
|
|
33
|
+
}
|
|
34
|
+
return [tracked, untracked].filter(Boolean).join("\n");
|
|
35
|
+
}
|