pi-chassis 0.0.0-stage → 0.7.1
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/CHANGELOG.md +118 -0
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/THIRD_PARTY_NOTICES +30 -0
- package/dist/chassis/alert.d.ts +50 -0
- package/dist/chassis/alert.js +107 -0
- package/dist/chassis/api.d.ts +411 -0
- package/dist/chassis/api.js +676 -0
- package/dist/chassis/bash-output.d.ts +89 -0
- package/dist/chassis/bash-output.js +157 -0
- package/dist/chassis/brand.d.ts +99 -0
- package/dist/chassis/brand.js +79 -0
- package/dist/chassis/client.d.ts +87 -0
- package/dist/chassis/client.js +271 -0
- package/dist/chassis/commands.d.ts +60 -0
- package/dist/chassis/commands.js +211 -0
- package/dist/chassis/editor.d.ts +70 -0
- package/dist/chassis/editor.js +213 -0
- package/dist/chassis/footer.d.ts +14 -0
- package/dist/chassis/footer.js +136 -0
- package/dist/chassis/header.d.ts +12 -0
- package/dist/chassis/header.js +38 -0
- package/dist/chassis/home.d.ts +48 -0
- package/dist/chassis/home.js +184 -0
- package/dist/chassis/icons.d.ts +203 -0
- package/dist/chassis/icons.js +190 -0
- package/dist/chassis/index.d.ts +21 -0
- package/dist/chassis/index.js +582 -0
- package/dist/chassis/layout.d.ts +40 -0
- package/dist/chassis/layout.js +264 -0
- package/dist/chassis/message-modes.d.ts +21 -0
- package/dist/chassis/message-modes.js +49 -0
- package/dist/chassis/messages.d.ts +75 -0
- package/dist/chassis/messages.js +392 -0
- package/dist/chassis/modes.d.ts +21 -0
- package/dist/chassis/modes.js +99 -0
- package/dist/chassis/palette.d.ts +84 -0
- package/dist/chassis/palette.js +235 -0
- package/dist/chassis/peek.d.ts +61 -0
- package/dist/chassis/peek.js +156 -0
- package/dist/chassis/prompt-row.d.ts +42 -0
- package/dist/chassis/prompt-row.js +68 -0
- package/dist/chassis/provider-view.d.ts +34 -0
- package/dist/chassis/provider-view.js +107 -0
- package/dist/chassis/provider.d.ts +69 -0
- package/dist/chassis/provider.js +73 -0
- package/dist/chassis/readonly-command.d.ts +19 -0
- package/dist/chassis/readonly-command.js +454 -0
- package/dist/chassis/sanitize.d.ts +42 -0
- package/dist/chassis/sanitize.js +103 -0
- package/dist/chassis/session-info.d.ts +39 -0
- package/dist/chassis/session-info.js +107 -0
- package/dist/chassis/sidebar.d.ts +48 -0
- package/dist/chassis/sidebar.js +273 -0
- package/dist/chassis/state.d.ts +38 -0
- package/dist/chassis/state.js +19 -0
- package/dist/chassis/telemetry.d.ts +60 -0
- package/dist/chassis/telemetry.js +191 -0
- package/dist/chassis/tools.d.ts +112 -0
- package/dist/chassis/tools.js +588 -0
- package/dist/chassis/version.d.ts +2 -0
- package/dist/chassis/version.js +20 -0
- package/dist/diff/git-diff.d.ts +71 -0
- package/dist/diff/git-diff.js +325 -0
- package/dist/diff/host.d.ts +34 -0
- package/dist/diff/host.js +50 -0
- package/dist/diff/index.d.ts +13 -0
- package/dist/diff/index.js +86 -0
- package/dist/diff/render.d.ts +67 -0
- package/dist/diff/render.js +196 -0
- package/dist/diff/types.d.ts +60 -0
- package/dist/diff/types.js +4 -0
- package/dist/diff/view.d.ts +118 -0
- package/dist/diff/view.js +528 -0
- package/dist/git/flows.d.ts +92 -0
- package/dist/git/flows.js +415 -0
- package/dist/git/forge.d.ts +26 -0
- package/dist/git/forge.js +41 -0
- package/dist/git/index.d.ts +20 -0
- package/dist/git/index.js +121 -0
- package/dist/git/repo.d.ts +64 -0
- package/dist/git/repo.js +186 -0
- package/dist/git/run.d.ts +38 -0
- package/dist/git/run.js +127 -0
- package/dist/git/settings.d.ts +8 -0
- package/dist/git/settings.js +38 -0
- package/dist/git/text.d.ts +51 -0
- package/dist/git/text.js +109 -0
- package/dist/widgets/container/detect.d.ts +23 -0
- package/dist/widgets/container/detect.js +67 -0
- package/dist/widgets/container/index.d.ts +9 -0
- package/dist/widgets/container/index.js +26 -0
- package/dist/widgets/mcp/index.d.ts +13 -0
- package/dist/widgets/mcp/index.js +50 -0
- package/dist/widgets/mcp/status.d.ts +50 -0
- package/dist/widgets/mcp/status.js +70 -0
- package/dist/widgets/session-todos/index.d.ts +12 -0
- package/dist/widgets/session-todos/index.js +52 -0
- package/dist/widgets/subagents/index.d.ts +16 -0
- package/dist/widgets/subagents/index.js +173 -0
- package/dist/widgets/subagents/status.d.ts +61 -0
- package/dist/widgets/subagents/status.js +198 -0
- package/dist/widgets/todo/index.d.ts +13 -0
- package/dist/widgets/todo/index.js +65 -0
- package/dist/widgets/todo/parse.d.ts +29 -0
- package/dist/widgets/todo/parse.js +122 -0
- package/docs/branding.md +25 -0
- package/docs/development.md +22 -0
- package/docs/diff-api.md +18 -0
- package/docs/git.md +23 -0
- package/docs/internals.md +13 -0
- package/docs/provider-status.md +23 -0
- package/docs/status-widgets.md +157 -0
- package/docs/tool-rows.md +50 -0
- package/docs/usage.md +62 -0
- package/docs/widgets.md +113 -0
- package/extensions/chassis/alert.ts +127 -0
- package/extensions/chassis/api.ts +967 -0
- package/extensions/chassis/bash-output.ts +186 -0
- package/extensions/chassis/brand.ts +151 -0
- package/extensions/chassis/client.ts +284 -0
- package/extensions/chassis/commands.ts +244 -0
- package/extensions/chassis/editor.ts +253 -0
- package/extensions/chassis/footer.ts +131 -0
- package/extensions/chassis/header.ts +43 -0
- package/extensions/chassis/home.ts +186 -0
- package/extensions/chassis/icons.ts +220 -0
- package/extensions/chassis/index.ts +605 -0
- package/extensions/chassis/layout.ts +298 -0
- package/extensions/chassis/message-modes.ts +48 -0
- package/extensions/chassis/messages.ts +403 -0
- package/extensions/chassis/modes.ts +108 -0
- package/extensions/chassis/palette.ts +258 -0
- package/extensions/chassis/peek.ts +167 -0
- package/extensions/chassis/prompt-row.ts +86 -0
- package/extensions/chassis/provider-view.ts +118 -0
- package/extensions/chassis/provider.ts +115 -0
- package/extensions/chassis/readonly-command.ts +440 -0
- package/extensions/chassis/sanitize.ts +108 -0
- package/extensions/chassis/session-info.ts +127 -0
- package/extensions/chassis/sidebar.ts +285 -0
- package/extensions/chassis/state.ts +50 -0
- package/extensions/chassis/telemetry.ts +222 -0
- package/extensions/chassis/tools.ts +618 -0
- package/extensions/chassis/version.ts +20 -0
- package/extensions/diff/git-diff.ts +365 -0
- package/extensions/diff/host.ts +58 -0
- package/extensions/diff/index.ts +101 -0
- package/extensions/diff/render.ts +217 -0
- package/extensions/diff/types.ts +67 -0
- package/extensions/diff/view.ts +523 -0
- package/extensions/git/flows.ts +485 -0
- package/extensions/git/forge.ts +54 -0
- package/extensions/git/index.ts +117 -0
- package/extensions/git/repo.ts +200 -0
- package/extensions/git/run.ts +143 -0
- package/extensions/git/settings.ts +38 -0
- package/extensions/git/text.ts +133 -0
- package/extensions/widgets/container/detect.ts +81 -0
- package/extensions/widgets/container/index.ts +29 -0
- package/extensions/widgets/mcp/index.ts +53 -0
- package/extensions/widgets/mcp/status.ts +94 -0
- package/extensions/widgets/session-todos/index.ts +55 -0
- package/extensions/widgets/subagents/index.ts +170 -0
- package/extensions/widgets/subagents/status.ts +237 -0
- package/extensions/widgets/todo/index.ts +66 -0
- package/extensions/widgets/todo/parse.ts +119 -0
- package/package.json +91 -4
- package/scripts/link-pi.mjs +76 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git workflows behind /commit, /push, /merge and /pr. Each is a short conversation: gather
|
|
3
|
+
* what git knows, draft with the model where that helps, show it, and only then change anything.
|
|
4
|
+
*
|
|
5
|
+
* Rules every flow keeps:
|
|
6
|
+
* - git is run with argument arrays (run.ts), never through a shell.
|
|
7
|
+
* - nothing is forced: no force-push, no --no-verify, no hard reset. Hooks run as usual.
|
|
8
|
+
* - anything that publishes or merges asks first; a message you typed yourself is not asked twice.
|
|
9
|
+
* - a failure shows git's own output and leaves the repository as git left it.
|
|
10
|
+
*
|
|
11
|
+
* Every command also reports what it did, with a fresh repository status, through `Env.report`, so
|
|
12
|
+
* the model can be told on its next turn (index.ts sends it). A report is made exactly once whenever
|
|
13
|
+
* a command ran, whether it finished, failed, was cancelled, found nothing to do or threw. Reports
|
|
14
|
+
* hold text written here, plus at most one short quoted line of tool output, labelled untrusted:
|
|
15
|
+
* hook, remote and forge output is never passed on in full.
|
|
16
|
+
*
|
|
17
|
+
* The UI and the model arrive through `Env`, so the flows are tested against real temporary
|
|
18
|
+
* repositories with scripted answers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { stripEscapes } from "../chassis/sanitize.ts";
|
|
22
|
+
import { createRequest, findForge, findUrl } from "./forge.ts";
|
|
23
|
+
import { aheadBehind, changedPaths, compareRef, currentBranch, defaultBranch, isRepo, localBranches, refExists, remoteName, remoteHost, repoStatus, type RepoStatus, saveIndex } from "./repo.ts";
|
|
24
|
+
import { failure, git, gitOut, PLAIN_DIFF, type RunResult } from "./run.ts";
|
|
25
|
+
import { cleanMessage, commitPrompt, parsePrArgs, prPrompt, splitPr, subject } from "./text.ts";
|
|
26
|
+
|
|
27
|
+
export type Level = "info" | "warning" | "error";
|
|
28
|
+
|
|
29
|
+
export interface Ui {
|
|
30
|
+
notify(message: string, level?: Level): void;
|
|
31
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
32
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
33
|
+
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GitCommand = "commit" | "push" | "merge" | "pr";
|
|
37
|
+
|
|
38
|
+
/** How a command ended: it did its job, had nothing to do, was cancelled, was refused, or failed. */
|
|
39
|
+
export type OutcomeKind = "done" | "nothing" | "cancelled" | "blocked" | "failed";
|
|
40
|
+
|
|
41
|
+
export interface Outcome {
|
|
42
|
+
kind: OutcomeKind;
|
|
43
|
+
/** What happened, in one line written by this code, starting lower case ("committed a7420ed ..."). */
|
|
44
|
+
text: string;
|
|
45
|
+
/** The one line of a failed tool's output worth showing (see `keyLine`); untrusted. */
|
|
46
|
+
output?: string;
|
|
47
|
+
sha?: string;
|
|
48
|
+
url?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the model is told after a command: plain text, plus the same facts as data. */
|
|
52
|
+
export interface Report {
|
|
53
|
+
command: GitCommand;
|
|
54
|
+
content: string;
|
|
55
|
+
details: {
|
|
56
|
+
command: GitCommand;
|
|
57
|
+
outcome: OutcomeKind;
|
|
58
|
+
summary: string;
|
|
59
|
+
/** One line of tool output, at most 200 characters; untrusted. */
|
|
60
|
+
output?: string;
|
|
61
|
+
sha?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
/** The repository right after the command; undefined when it could not be read. */
|
|
64
|
+
repo?: RepoStatus & { clean: boolean };
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Env {
|
|
69
|
+
cwd: string;
|
|
70
|
+
ui: Ui;
|
|
71
|
+
/** Pass a command's result on to the model (it is not shown to the user). */
|
|
72
|
+
report(report: Report): void;
|
|
73
|
+
/** Ask the model for text; undefined when there is no model or the call failed. */
|
|
74
|
+
draft(prompt: { system: string; user: string }): Promise<string | undefined>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
|
|
78
|
+
|
|
79
|
+
const ERROR_LINE = /error|fatal|rejected|denied|refused|GH\d{3}/i;
|
|
80
|
+
const TOOL_LINE_MAX = 200;
|
|
81
|
+
const TEXT_MAX = 120;
|
|
82
|
+
|
|
83
|
+
/** Text on one line: escape sequences and control characters (line breaks included) become spaces. */
|
|
84
|
+
function oneLine(text: string): string {
|
|
85
|
+
return stripEscapes(text)
|
|
86
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ")
|
|
87
|
+
.replace(/ {2,}/g, " ")
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** At most `max` characters, the last being "…" when something was cut. */
|
|
92
|
+
function cut(text: string, max: number): string {
|
|
93
|
+
if (text.length <= max) return text;
|
|
94
|
+
let end = max - 1;
|
|
95
|
+
const last = text.charCodeAt(end - 1);
|
|
96
|
+
if (end > 0 && last >= 0xd800 && last <= 0xdbff) end--;
|
|
97
|
+
return `${text.slice(0, end)}…`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const escapeQuoted = (text: string): string => text.replace(/[\\"]/g, "\\$&");
|
|
101
|
+
|
|
102
|
+
/** Typed or commit-derived text for a report: one line, at most 120 characters. */
|
|
103
|
+
export function clip(text: string, max = TEXT_MAX): string {
|
|
104
|
+
return cut(oneLine(text), max);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `clip`ped text inside quotes, with quotes and backslashes escaped. */
|
|
108
|
+
const quote = (text: string): string => `"${escapeQuoted(clip(text))}"`;
|
|
109
|
+
|
|
110
|
+
const PUSH_TRAILER = /^error: failed to push some refs/i;
|
|
111
|
+
const REJECTION_LINE = /^! \[|\[remote rejected\]/;
|
|
112
|
+
|
|
113
|
+
/** Credentials that may appear in tool output: URL userinfo and well-known token prefixes. */
|
|
114
|
+
function redact(text: string): string {
|
|
115
|
+
return text.replace(/:\/\/[^/@\s]+@/g, "://").replace(/\b(?:ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "***");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The one line of a failed tool's output worth passing on. Hooks, linters and git put the line that
|
|
120
|
+
* says what is wrong last, so this takes the last line that looks like an error, else the last
|
|
121
|
+
* line. A rejection line from git push ("! [rejected] ..." or "[remote rejected] ...") wins over
|
|
122
|
+
* other lines, and git's generic "error: failed to push some refs" trailer only counts when
|
|
123
|
+
* nothing else matches. Credentials are masked. Single line, and at most 200 characters once
|
|
124
|
+
* quotes are escaped.
|
|
125
|
+
*/
|
|
126
|
+
export function keyLine(r: RunResult): string {
|
|
127
|
+
const lines = (r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`).split(/\r\n|\r|\n/).map((l) => redact(oneLine(l))).filter(Boolean);
|
|
128
|
+
const useful = lines.filter((l) => !PUSH_TRAILER.test(l));
|
|
129
|
+
const picked =
|
|
130
|
+
useful.findLast((l) => REJECTION_LINE.test(l)) ??
|
|
131
|
+
useful.findLast((l) => ERROR_LINE.test(l)) ??
|
|
132
|
+
lines.findLast((l) => ERROR_LINE.test(l)) ??
|
|
133
|
+
lines.at(-1) ??
|
|
134
|
+
`exit ${r.code}`;
|
|
135
|
+
for (let max = TOOL_LINE_MAX; max > 1; max--) {
|
|
136
|
+
const line = cut(picked, max);
|
|
137
|
+
if (escapeQuoted(line).length <= TOOL_LINE_MAX) return line;
|
|
138
|
+
}
|
|
139
|
+
return "…";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tell the user with a notification and return the outcome to report. */
|
|
143
|
+
function stop(env: Env, kind: OutcomeKind, level: Level, message: string, text: string, output?: string): Outcome {
|
|
144
|
+
env.ui.notify(message, level);
|
|
145
|
+
return { kind, text, output };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The repository status, or the outcome to end with (after telling the user) when there is none. */
|
|
149
|
+
async function status(env: Env): Promise<{ st: RepoStatus } | { stopped: Outcome }> {
|
|
150
|
+
if (!(await isRepo(env.cwd))) return { stopped: stop(env, "blocked", "warning", "Not inside a git repository.", "not inside a git repository") };
|
|
151
|
+
const st = await repoStatus(env.cwd);
|
|
152
|
+
if (!st) return { stopped: stop(env, "failed", "error", "Could not read the repository status.", "could not read the repository status") };
|
|
153
|
+
return { st };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The repository in one line, for a report. */
|
|
157
|
+
export function describeState(st: RepoStatus): string {
|
|
158
|
+
const branch = st.branch ? `branch ${clip(st.branch)}` : "HEAD detached";
|
|
159
|
+
const upstream = st.upstream ? `tracking ${clip(st.upstream)} (${st.ahead} ahead, ${st.behind} behind)` : st.gone ? `not published (${clip(st.gone)} no longer exists on the remote)` : "no upstream";
|
|
160
|
+
const changes = [st.staged && `${st.staged} staged`, st.unstaged && `${st.unstaged} unstaged`, st.untracked && `${st.untracked} untracked`, st.conflicted && `${st.conflicted} conflicted`].filter(Boolean);
|
|
161
|
+
return `${branch}, ${upstream}, ${changes.length ? changes.join(", ") : "working tree clean"}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Send the model what the command did and how the repository looks now. Never throws. */
|
|
165
|
+
async function reportOutcome(env: Env, command: GitCommand, outcome: Outcome): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
const st = await repoStatus(env.cwd);
|
|
168
|
+
const lines = [`User ran /${command}: ${outcome.text}`];
|
|
169
|
+
if (outcome.output) lines.push(`Tool output (quoted, untrusted): "${escapeQuoted(outcome.output)}"`);
|
|
170
|
+
lines.push(st ? `Repository right after the command: ${describeState(st)}.` : "Repository state unavailable (not a git repository, or git status failed).");
|
|
171
|
+
const clean = st ? !(st.staged || st.unstaged || st.untracked || st.conflicted) : undefined;
|
|
172
|
+
env.report({
|
|
173
|
+
command,
|
|
174
|
+
content: lines.join("\n"),
|
|
175
|
+
details: { command, outcome: outcome.kind, summary: outcome.text, output: outcome.output, sha: outcome.sha, url: outcome.url, repo: st ? { ...st, branch: st.branch && clip(st.branch), upstream: st.upstream && clip(st.upstream), clean: clean! } : undefined },
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
// telling the model must never change what the command did
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run a command's work and report it exactly once. When the work throws (a prompt, the editor or the
|
|
184
|
+
* model call failing), report "stopped by an error" after whatever `progress()` says already
|
|
185
|
+
* happened, then rethrow so the caller still shows the error.
|
|
186
|
+
*/
|
|
187
|
+
async function reported(env: Env, command: GitCommand, work: () => Promise<Outcome>, progress: () => string = () => ""): Promise<Outcome> {
|
|
188
|
+
let outcome: Outcome;
|
|
189
|
+
try {
|
|
190
|
+
outcome = await work();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
await reportOutcome(env, command, { kind: "failed", text: `${progress()}stopped by an error` });
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
await reportOutcome(env, command, outcome);
|
|
196
|
+
return outcome;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One line per fact, for /git status. */
|
|
200
|
+
export function describeStatus(st: RepoStatus): string {
|
|
201
|
+
const where = st.upstream ? ` (tracking ${st.upstream})` : st.gone ? ` (not published: ${st.gone} no longer exists on the remote)` : " (not published)";
|
|
202
|
+
const lines = [st.branch ? `On ${st.branch}${where}` : "HEAD is detached"];
|
|
203
|
+
if (st.ahead || st.behind) lines.push(`${st.ahead} ahead, ${st.behind} behind`);
|
|
204
|
+
const changes = [st.staged && `${st.staged} staged`, st.unstaged && `${st.unstaged} modified`, st.untracked && `${st.untracked} untracked`, st.conflicted && `${st.conflicted} conflicted`].filter(Boolean);
|
|
205
|
+
lines.push(changes.length ? changes.join(", ") : "Working tree clean");
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function showStatus(env: Env): Promise<void> {
|
|
210
|
+
const got = await status(env);
|
|
211
|
+
if ("st" in got) env.ui.notify(describeStatus(got.st), "info");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The files a stage-everything would add, as a short list for a confirmation. */
|
|
215
|
+
const fileList = (paths: string[], shown = 15): string => {
|
|
216
|
+
const lines = paths.slice(0, shown).map((p) => ` ${p}`);
|
|
217
|
+
if (paths.length > shown) lines.push(` and ${paths.length - shown} more`);
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export async function commit(env: Env, args: string): Promise<void> {
|
|
222
|
+
await reported(env, "commit", () => runCommit(env, args));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function runCommit(env: Env, args: string): Promise<Outcome> {
|
|
226
|
+
const got = await status(env);
|
|
227
|
+
if ("stopped" in got) return got.stopped;
|
|
228
|
+
const st = got.st;
|
|
229
|
+
if (st.conflicted) return stop(env, "blocked", "warning", "There are unresolved merge conflicts. Resolve them first.", "unresolved merge conflicts");
|
|
230
|
+
|
|
231
|
+
// Set when this command staged everything itself: puts the index back if the commit is dropped.
|
|
232
|
+
let undoStaging: (() => Promise<boolean>) | undefined;
|
|
233
|
+
if (!st.staged) {
|
|
234
|
+
const paths = await changedPaths(env.cwd);
|
|
235
|
+
const n = paths?.length ?? st.unstaged + st.untracked;
|
|
236
|
+
if (!n) return stop(env, "nothing", "info", "Nothing to commit.", "nothing to commit, the working tree was clean");
|
|
237
|
+
const list = paths?.length ? `\n\n${fileList(paths)}` : "";
|
|
238
|
+
const ok = await env.ui.confirm("Nothing is staged", `Stage all ${plural(n, "changed file")} (git add -A) and commit?${list}`);
|
|
239
|
+
if (!ok) return { kind: "cancelled", text: "cancelled at the stage-everything prompt, nothing committed" };
|
|
240
|
+
const saved = await saveIndex(env.cwd);
|
|
241
|
+
const add = await git(env.cwd, ["add", "-A"]);
|
|
242
|
+
if (add.code !== 0) {
|
|
243
|
+
await saved?.restore();
|
|
244
|
+
return stop(env, "failed", "error", `git add failed:\n${failure(add)}`, "git add failed", keyLine(add));
|
|
245
|
+
}
|
|
246
|
+
await saved?.staged();
|
|
247
|
+
undoStaging = saved?.restore;
|
|
248
|
+
}
|
|
249
|
+
/** Give up before committing, putting back the staging area if this command filled it. */
|
|
250
|
+
const dropCommit = async (kind: OutcomeKind, why: string, text: string): Promise<Outcome> => {
|
|
251
|
+
const undone = await undoStaging?.();
|
|
252
|
+
const note = undoStaging ? (undone ? " Your staging area is back as it was." : " The files it staged are still staged.") : "";
|
|
253
|
+
const told = undoStaging ? (undone ? ", the staging area is back as it was" : ", the files it staged are still staged") : "";
|
|
254
|
+
return stop(env, kind, "info", `${why}${note}`, `${text}${told}`);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
let message = args.trim();
|
|
258
|
+
if (!message) {
|
|
259
|
+
const [stat, diff, recent] = await Promise.all([
|
|
260
|
+
gitOut(env.cwd, ["diff", "--cached", "--stat", ...PLAIN_DIFF]),
|
|
261
|
+
gitOut(env.cwd, ["diff", "--cached", ...PLAIN_DIFF]),
|
|
262
|
+
gitOut(env.cwd, ["log", "-n", "8", "--format=%s"]),
|
|
263
|
+
]);
|
|
264
|
+
env.ui.notify("Writing a commit message…", "info");
|
|
265
|
+
const drafted = await env.draft(commitPrompt({ branch: st.branch, stat: stat ?? "", diff: diff ?? "", recent: recent?.split("\n").filter(Boolean) ?? [] }));
|
|
266
|
+
if (!drafted) env.ui.notify("Could not draft a message. Write one.", "warning");
|
|
267
|
+
const edited = await env.ui.editor("Commit message", drafted ? cleanMessage(drafted) : "");
|
|
268
|
+
if (edited === undefined) return dropCommit("cancelled", "Commit cancelled. Nothing was committed.", "cancelled at the message editor, nothing committed");
|
|
269
|
+
message = edited.trim();
|
|
270
|
+
if (!message) return dropCommit("nothing", "Empty message. Nothing was committed.", "empty message, nothing committed");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const r = await git(env.cwd, ["commit", "-F", "-"], { input: message, timeoutMs: 300_000 });
|
|
274
|
+
if (r.code !== 0) return stop(env, "failed", "error", `git commit failed:\n${failure(r)}`, "git commit failed", keyLine(r));
|
|
275
|
+
const sha = (await gitOut(env.cwd, ["rev-parse", "--short", "HEAD"])) ?? "";
|
|
276
|
+
env.ui.notify(`Committed ${sha}: ${subject(message)}`, "info");
|
|
277
|
+
return { kind: "done", text: `committed ${sha} ${quote(subject(message))}`, sha };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Where a push of the current branch goes, and how that compares with what the remote has. */
|
|
281
|
+
interface PushPlan {
|
|
282
|
+
remote: string;
|
|
283
|
+
branch: string;
|
|
284
|
+
/** The remote branch pushed to, as "origin/feat". */
|
|
285
|
+
target: string;
|
|
286
|
+
/** The branch the local one tracks now, when that is not the target ("origin/main"). */
|
|
287
|
+
otherUpstream?: string;
|
|
288
|
+
/** False when the remote has no branch of that name yet. */
|
|
289
|
+
published: boolean;
|
|
290
|
+
ahead: number;
|
|
291
|
+
behind: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Decide where a push goes. The target is always the remote branch with the local branch's own
|
|
296
|
+
* name. A branch made with `git checkout -b feat origin/main` tracks origin/main, and a bare
|
|
297
|
+
* `git push` would then be refused (push.default=simple) or, worse, push the feature commits to
|
|
298
|
+
* main (push.default=upstream), so the push names its destination and takes the target as the
|
|
299
|
+
* new upstream. When there is no remote, the outcome to end with (after telling the user).
|
|
300
|
+
*/
|
|
301
|
+
async function planPush(env: Env, st: RepoStatus & { branch: string }): Promise<PushPlan | { stopped: Outcome }> {
|
|
302
|
+
const remote = await remoteName(env.cwd, st.upstream ?? st.gone);
|
|
303
|
+
if (!remote) return { stopped: stop(env, "blocked", "warning", "This repository has no remote to push to.", "the repository has no remote") };
|
|
304
|
+
const target = `${remote}/${st.branch}`;
|
|
305
|
+
if (st.upstream === target) return { remote, branch: st.branch, target, published: true, ahead: st.ahead, behind: st.behind };
|
|
306
|
+
const otherUpstream = st.upstream;
|
|
307
|
+
if (!(await refExists(env.cwd, `refs/remotes/${target}`))) return { remote, branch: st.branch, target, otherUpstream, published: false, ahead: 0, behind: 0 };
|
|
308
|
+
const counts = await aheadBehind(env.cwd, `refs/remotes/${target}`);
|
|
309
|
+
return { remote, branch: st.branch, target, otherUpstream, published: true, ahead: counts?.ahead ?? 0, behind: counts?.behind ?? 0 };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The question before a push, naming the exact remote branch it goes to. */
|
|
313
|
+
function pushQuestion(plan: PushPlan): string {
|
|
314
|
+
const what = plan.published ? `Push ${plural(plan.ahead, "commit")} on ${plan.branch} to ${plan.target}?` : `Publish ${plan.branch} to ${plan.target}?`;
|
|
315
|
+
if (!plan.otherUpstream) return what;
|
|
316
|
+
return `${what}\n\n${plan.branch} tracks ${plan.otherUpstream} now. That branch is left alone; ${plan.target} becomes the upstream.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Push the current branch. Returns false when it did not happen (the reason is already shown). */
|
|
320
|
+
export async function push(env: Env, opts: { confirmed?: boolean } = {}): Promise<boolean> {
|
|
321
|
+
const outcome = await reported(env, "push", () => pushBranch(env, opts));
|
|
322
|
+
return outcome.kind === "done";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The push itself, without a report of its own, so /pr can report once for the whole command. */
|
|
326
|
+
async function pushBranch(env: Env, opts: { confirmed?: boolean }): Promise<Outcome> {
|
|
327
|
+
const got = await status(env);
|
|
328
|
+
if ("stopped" in got) return got.stopped;
|
|
329
|
+
const st = got.st;
|
|
330
|
+
if (!st.branch) return stop(env, "blocked", "warning", "HEAD is detached. Check out a branch to push.", "HEAD is detached");
|
|
331
|
+
const plan = await planPush(env, { ...st, branch: st.branch });
|
|
332
|
+
if ("stopped" in plan) return plan.stopped;
|
|
333
|
+
const branchShown = clip(plan.branch);
|
|
334
|
+
const targetShown = clip(plan.target);
|
|
335
|
+
if (plan.behind) {
|
|
336
|
+
return stop(env, "blocked", "warning", `${plan.branch} is ${plural(plan.behind, "commit")} behind ${plan.target}. Merge or rebase first. Nothing was pushed.`, `${branchShown} is ${plural(plan.behind, "commit")} behind ${targetShown}, nothing pushed`);
|
|
337
|
+
}
|
|
338
|
+
if (plan.published && !plan.ahead) {
|
|
339
|
+
return stop(env, "nothing", "info", `Nothing to push: ${plan.branch} is up to date with ${plan.target}.`, `nothing to push, ${branchShown} is up to date with ${targetShown}`);
|
|
340
|
+
}
|
|
341
|
+
if (!opts.confirmed && !(await env.ui.confirm("Push", pushQuestion(plan)))) return { kind: "cancelled", text: "cancelled at the confirmation, nothing pushed" };
|
|
342
|
+
// Named on both sides: never "whatever push.default says", and never another branch.
|
|
343
|
+
const setsUpstream = plan.target !== st.upstream;
|
|
344
|
+
const r = await git(env.cwd, ["push", ...(setsUpstream ? ["-u"] : []), plan.remote, `HEAD:refs/heads/${plan.branch}`], { timeoutMs: 300_000 });
|
|
345
|
+
if (r.code !== 0) return stop(env, "failed", "error", `git push failed:\n${failure(r)}`, "git push failed, nothing pushed", keyLine(r));
|
|
346
|
+
env.ui.notify(`${plan.published ? "Pushed" : "Published"} ${plan.branch} to ${plan.target}.`, "info");
|
|
347
|
+
return { kind: "done", text: `${plan.published ? "pushed" : "published"} ${branchShown} to ${targetShown}${setsUpstream ? " and set it as the upstream" : ""}` };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function merge(env: Env, args: string): Promise<void> {
|
|
351
|
+
await reported(env, "merge", () => runMerge(env, args));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runMerge(env: Env, args: string): Promise<Outcome> {
|
|
355
|
+
const arg = args.trim();
|
|
356
|
+
if (arg === "abort") {
|
|
357
|
+
const r = await git(env.cwd, ["merge", "--abort"]);
|
|
358
|
+
if (r.code === 0) return stop(env, "done", "info", "Merge aborted.", "merge aborted");
|
|
359
|
+
return stop(env, "nothing", "warning", `Nothing to abort:\n${failure(r)}`, "nothing to abort", keyLine(r));
|
|
360
|
+
}
|
|
361
|
+
const got = await status(env);
|
|
362
|
+
if ("stopped" in got) return got.stopped;
|
|
363
|
+
const st = got.st;
|
|
364
|
+
if (st.conflicted) return stop(env, "blocked", "warning", "There are unresolved conflicts. Resolve them and commit, or run /merge abort.", "unresolved conflicts");
|
|
365
|
+
if (st.staged || st.unstaged) return stop(env, "blocked", "warning", "The working tree has uncommitted changes. Commit or stash them before merging.", "the working tree has uncommitted changes");
|
|
366
|
+
const into = st.branch ?? (await currentBranch(env.cwd));
|
|
367
|
+
if (!into) return stop(env, "blocked", "warning", "HEAD is detached. Check out the branch to merge into.", "HEAD is detached");
|
|
368
|
+
|
|
369
|
+
let target = arg;
|
|
370
|
+
if (!target) {
|
|
371
|
+
const options = await localBranches(env.cwd, into);
|
|
372
|
+
if (!options.length) return stop(env, "nothing", "info", "There are no other branches to merge.", "there are no other branches to merge");
|
|
373
|
+
const picked = await env.ui.select(`Merge into ${into}`, options);
|
|
374
|
+
if (!picked) return { kind: "cancelled", text: "cancelled at the branch choice, nothing merged" };
|
|
375
|
+
target = picked;
|
|
376
|
+
}
|
|
377
|
+
const shown = clip(target);
|
|
378
|
+
const intoShown = clip(into);
|
|
379
|
+
if (!(await refExists(env.cwd, target))) return stop(env, "blocked", "warning", `No branch or ref named "${target}".`, `no branch or ref named ${quote(target)}`);
|
|
380
|
+
const counted = await git(env.cwd, ["rev-list", "--count", `${into}..${target}`]);
|
|
381
|
+
const count = Number(counted.stdout.trim());
|
|
382
|
+
if (counted.code !== 0 || !Number.isFinite(count)) {
|
|
383
|
+
return stop(env, "failed", "error", `Could not compare ${into} with ${target}:\n${failure(counted)}`, `could not compare ${intoShown} with ${shown}`, keyLine(counted));
|
|
384
|
+
}
|
|
385
|
+
if (!count) return stop(env, "nothing", "info", `Already up to date: ${into} contains everything in ${target}.`, `already up to date, ${intoShown} contains everything in ${shown}`);
|
|
386
|
+
if (!(await env.ui.confirm("Merge", `Merge ${target} into ${into}? (${plural(count, "commit")})`))) return { kind: "cancelled", text: `cancelled at the confirmation, ${shown} was not merged` };
|
|
387
|
+
|
|
388
|
+
const r = await git(env.cwd, ["merge", "--no-edit", target], { timeoutMs: 300_000 });
|
|
389
|
+
if (r.code === 0) return stop(env, "done", "info", `Merged ${target} into ${into}.`, `merged ${shown} into ${intoShown}`);
|
|
390
|
+
const conflicts = (await gitOut(env.cwd, ["diff", ...PLAIN_DIFF, "--name-only", "--diff-filter=U"]))?.split("\n").filter(Boolean) ?? [];
|
|
391
|
+
if (conflicts.length) {
|
|
392
|
+
const listed = conflicts.slice(0, 15).map((f) => ` ${f}`).join("\n");
|
|
393
|
+
const more = conflicts.length > 15 ? `\n …and ${conflicts.length - 15} more` : "";
|
|
394
|
+
return stop(
|
|
395
|
+
env,
|
|
396
|
+
"failed",
|
|
397
|
+
"warning",
|
|
398
|
+
`Merge stopped on ${plural(conflicts.length, "conflict")}:\n${listed}${more}\nResolve them and commit, or run /merge abort.`,
|
|
399
|
+
`merging ${shown} into ${intoShown} stopped on ${plural(conflicts.length, "conflict")}, the merge is in progress`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return stop(env, "failed", "error", `git merge failed:\n${failure(r)}`, "git merge failed", keyLine(r));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** What /pr has done so far, for a report when it stops by an error. */
|
|
406
|
+
interface PrProgress {
|
|
407
|
+
/** "pushed ...; " once the push inside /pr has happened. */
|
|
408
|
+
pushedFirst: string;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function pullRequest(env: Env, args: string): Promise<void> {
|
|
412
|
+
const progress: PrProgress = { pushedFirst: "" };
|
|
413
|
+
await reported(env, "pr", () => runPullRequest(env, args, progress), () => progress.pushedFirst);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runPullRequest(env: Env, args: string, progress: PrProgress): Promise<Outcome> {
|
|
417
|
+
const opts = parsePrArgs(args);
|
|
418
|
+
const got = await status(env);
|
|
419
|
+
if ("stopped" in got) return got.stopped;
|
|
420
|
+
const st = got.st;
|
|
421
|
+
if (!st.branch) return stop(env, "blocked", "warning", "HEAD is detached. Check out the branch to open a pull request from.", "HEAD is detached");
|
|
422
|
+
if (st.conflicted) return stop(env, "blocked", "warning", "There are unresolved merge conflicts. Resolve them first.", "unresolved merge conflicts");
|
|
423
|
+
const remote = await remoteName(env.cwd, st.upstream);
|
|
424
|
+
if (!remote) return stop(env, "blocked", "warning", "This repository has no remote.", "the repository has no remote");
|
|
425
|
+
|
|
426
|
+
const base = opts.base ?? (await defaultBranch(env.cwd, remote));
|
|
427
|
+
if (!base) return stop(env, "blocked", "warning", "Could not tell the base branch. Pass it: /pr main", "could not tell the base branch");
|
|
428
|
+
const shown = clip(base);
|
|
429
|
+
const branchShown = clip(st.branch);
|
|
430
|
+
if (base === st.branch) return stop(env, "blocked", "warning", `You are on ${base}, the base branch. Switch to a feature branch first.`, `on the base branch ${shown}`);
|
|
431
|
+
const baseRef = await compareRef(env.cwd, remote, base);
|
|
432
|
+
if (!baseRef) return stop(env, "blocked", "warning", `No branch named "${base}".`, `no branch named ${quote(base)}`);
|
|
433
|
+
|
|
434
|
+
const commits = (await gitOut(env.cwd, ["log", `${baseRef}..HEAD`, "--format=%s"]))?.split("\n").filter(Boolean) ?? [];
|
|
435
|
+
if (!commits.length) return stop(env, "nothing", "info", `${st.branch} has no commits that ${base} lacks. Nothing to open.`, `${branchShown} has no commits that ${shown} lacks`);
|
|
436
|
+
if (st.staged || st.unstaged) env.ui.notify("Uncommitted changes are not part of the pull request.", "warning");
|
|
437
|
+
|
|
438
|
+
const url = await gitOut(env.cwd, ["remote", "get-url", remote]);
|
|
439
|
+
const host = url ? remoteHost(url) : undefined;
|
|
440
|
+
const cli = await findForge(env.cwd, host);
|
|
441
|
+
if (!cli) {
|
|
442
|
+
return stop(
|
|
443
|
+
env,
|
|
444
|
+
"blocked",
|
|
445
|
+
"warning",
|
|
446
|
+
`Opening a pull request needs the gh or glab CLI, signed in${host ? ` to ${host}` : ""}. Run "gh auth login" or "glab auth login".`,
|
|
447
|
+
`no gh or glab CLI signed in${host ? ` to ${clip(host)}` : ""}`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const plan = await planPush(env, { ...st, branch: st.branch });
|
|
452
|
+
if ("stopped" in plan) return plan.stopped;
|
|
453
|
+
if (plan.behind || !plan.published || plan.ahead) {
|
|
454
|
+
// Behind is left to the push, which refuses and says why; otherwise name the exact target.
|
|
455
|
+
if (!plan.behind) {
|
|
456
|
+
const what = plan.published ? `${plural(plan.ahead, "commit")} on ${plan.branch} are not pushed` : `${plan.branch} is not published yet`;
|
|
457
|
+
const tracks = plan.otherUpstream ? `\n\n${plan.branch} tracks ${plan.otherUpstream} now. That branch is left alone; ${plan.target} becomes the upstream.` : "";
|
|
458
|
+
if (!(await env.ui.confirm("Push first", `${what}. Push to ${plan.target} and open the pull request?${tracks}`))) {
|
|
459
|
+
return { kind: "cancelled", text: "cancelled at the push prompt, nothing pushed" };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const pushed = await pushBranch(env, { confirmed: true });
|
|
463
|
+
if (pushed.kind !== "done") return { ...pushed, text: `the push before the pull request: ${pushed.text}` };
|
|
464
|
+
progress.pushedFirst = `${pushed.text}; `;
|
|
465
|
+
}
|
|
466
|
+
const before = progress.pushedFirst;
|
|
467
|
+
|
|
468
|
+
const [stat, diff] = await Promise.all([gitOut(env.cwd, ["diff", "--stat", ...PLAIN_DIFF, `${baseRef}...HEAD`]), gitOut(env.cwd, ["diff", ...PLAIN_DIFF, `${baseRef}...HEAD`])]);
|
|
469
|
+
env.ui.notify("Writing the pull request…", "info");
|
|
470
|
+
const drafted = await env.draft(prPrompt({ base, branch: st.branch, commits, stat: stat ?? "", diff: diff ?? "" }));
|
|
471
|
+
if (!drafted) env.ui.notify("Could not draft the description. Write it.", "warning");
|
|
472
|
+
const fallback = commits.length === 1 ? commits[0]! : "";
|
|
473
|
+
const edited = await env.ui.editor(`Pull request into ${base}${opts.draft ? " (draft)" : ""}: the first line is the title`, drafted ? cleanMessage(drafted) : fallback);
|
|
474
|
+
if (edited === undefined) return stop(env, "cancelled", "info", "Pull request cancelled.", `${before}cancelled at the description editor`);
|
|
475
|
+
const { title, body } = splitPr(edited);
|
|
476
|
+
if (!title) return stop(env, "nothing", "info", "No title. Nothing was opened.", `${before}no title`);
|
|
477
|
+
|
|
478
|
+
const r = await createRequest(env.cwd, cli, { base, head: st.branch, title, body, draft: opts.draft });
|
|
479
|
+
const link = findUrl(`${r.stdout}\n${r.stderr}`);
|
|
480
|
+
if (r.code === 0) {
|
|
481
|
+
env.ui.notify(link ? `Opened: ${link}` : "Pull request opened.", "info");
|
|
482
|
+
return { kind: "done", text: `${before}opened ${opts.draft ? "a draft " : "a "}pull request into ${shown}${link ? `: ${link}` : ""}`, url: link };
|
|
483
|
+
}
|
|
484
|
+
return stop(env, "failed", "error", `${cli} could not open the pull request:\n${failure(r)}`, `${before}${cli} could not open the pull request`, keyLine(r));
|
|
485
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a pull or merge request through the forge's own CLI: `gh` for GitHub-style hosts, `glab`
|
|
3
|
+
* for GitLab-style hosts. It works with any host the CLI is signed in to (github.com, GitHub
|
|
4
|
+
* Enterprise, gitlab.com, self-hosted GitLab); nothing here knows a host by name beyond a hint to
|
|
5
|
+
* try the likelier CLI first.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { run } from "./run.ts";
|
|
9
|
+
|
|
10
|
+
export type ForgeCli = "gh" | "glab";
|
|
11
|
+
|
|
12
|
+
export interface PrRequest {
|
|
13
|
+
base: string;
|
|
14
|
+
head: string;
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
draft: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The CLIs to try for a host, likeliest first. */
|
|
21
|
+
export function candidateClis(host: string | undefined): ForgeCli[] {
|
|
22
|
+
return host?.includes("gitlab") ? ["glab", "gh"] : ["gh", "glab"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Arguments (and stdin) that create the request with the given CLI. */
|
|
26
|
+
export function createArgs(cli: ForgeCli, r: PrRequest): { args: string[]; input?: string } {
|
|
27
|
+
if (cli === "gh") {
|
|
28
|
+
const args = ["pr", "create", "--base", r.base, "--head", r.head, "--title", r.title, "--body-file", "-"];
|
|
29
|
+
if (r.draft) args.push("--draft");
|
|
30
|
+
return { args, input: r.body };
|
|
31
|
+
}
|
|
32
|
+
const args = ["mr", "create", "--target-branch", r.base, "--source-branch", r.head, "--title", r.title, "--description", r.body, "--yes"];
|
|
33
|
+
if (r.draft) args.push("--draft");
|
|
34
|
+
return { args };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The first URL in a command's output, if any. */
|
|
38
|
+
export function findUrl(text: string): string | undefined {
|
|
39
|
+
return text.match(/https?:\/\/[^\s<>"')]+/)?.[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The first candidate CLI that is installed and signed in for `host`. */
|
|
43
|
+
export async function findForge(cwd: string, host: string | undefined): Promise<ForgeCli | undefined> {
|
|
44
|
+
for (const cli of candidateClis(host)) {
|
|
45
|
+
const args = host ? ["auth", "status", "--hostname", host] : ["auth", "status"];
|
|
46
|
+
if ((await run(cli, args, cwd, { timeoutMs: 15_000 })).code === 0) return cli;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createRequest(cwd: string, cli: ForgeCli, r: PrRequest) {
|
|
52
|
+
const { args, input } = createArgs(cli, r);
|
|
53
|
+
return run(cli, args, cwd, { input, timeoutMs: 120_000, env: { GH_PROMPT_DISABLED: "1", NO_PROMPT: "1" } });
|
|
54
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git workflows without prompting the model for context:
|
|
3
|
+
*
|
|
4
|
+
* /commit [message] commit what is staged (or stage everything, after asking); with no
|
|
5
|
+
* message the model drafts one from the diff, and you edit or accept it
|
|
6
|
+
* /push push the branch (publishes it with an upstream the first time)
|
|
7
|
+
* /merge [branch|abort] merge a branch into the current one; stops cleanly on conflicts
|
|
8
|
+
* /pr [base] [--draft] push if needed, draft the title and description from the commits and
|
|
9
|
+
* diff, and open the request with gh or glab
|
|
10
|
+
* /git [status|on|off] a menu of the above, the repository status, or the off switch
|
|
11
|
+
*
|
|
12
|
+
* Generic: any git remote, any host the gh or glab CLI is signed in to. The commands show up in
|
|
13
|
+
* the command palette like any slash command. The flows are in flows.ts; this file only connects
|
|
14
|
+
* them to pi. `/git off` switches the commands off (saved in chassis.json) and `/git on` restores them.
|
|
15
|
+
*
|
|
16
|
+
* Results are also passed to the model on its next turn (a hidden message with the outcome and a
|
|
17
|
+
* fresh repository status), so it never relies on a `git status` from before the command.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { commit, type Env, merge, pullRequest, push, showStatus } from "./flows.ts";
|
|
22
|
+
import { loadGitEnabled, saveGitEnabled } from "./settings.ts";
|
|
23
|
+
|
|
24
|
+
const DRAFT_TIMEOUT_MS = 90_000;
|
|
25
|
+
|
|
26
|
+
/** Ask the current model for text with a one-off request outside the conversation. */
|
|
27
|
+
async function draft(ctx: ExtensionContext, prompt: { system: string; user: string }): Promise<string | undefined> {
|
|
28
|
+
const model = ctx.model;
|
|
29
|
+
if (!model) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const reply = await ctx.modelRegistry
|
|
32
|
+
.streamSimple(model, { systemPrompt: prompt.system, messages: [{ role: "user", content: prompt.user, timestamp: Date.now() }] }, { signal: AbortSignal.timeout(DRAFT_TIMEOUT_MS) })
|
|
33
|
+
.result();
|
|
34
|
+
if (reply.stopReason === "error" || reply.stopReason === "aborted") return undefined;
|
|
35
|
+
const text = reply.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("").trim();
|
|
36
|
+
return text || undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The message type of the hidden note that tells the model what a command did. */
|
|
43
|
+
const REPORT_TYPE = "chassis-git";
|
|
44
|
+
|
|
45
|
+
function envFor(pi: ExtensionAPI, ctx: ExtensionContext): Env {
|
|
46
|
+
return {
|
|
47
|
+
cwd: ctx.cwd,
|
|
48
|
+
ui: {
|
|
49
|
+
notify: (m, level) => ctx.ui.notify(m, level),
|
|
50
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
51
|
+
select: (title, options) => ctx.ui.select(title, options),
|
|
52
|
+
editor: (title, prefill) => ctx.ui.editor(title, prefill),
|
|
53
|
+
},
|
|
54
|
+
draft: (p) => draft(ctx, p),
|
|
55
|
+
report: (r) => pi.sendMessage({ customType: REPORT_TYPE, content: r.content, display: false, details: r.details }, { deliverAs: "nextTurn" }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Without a UI (print and JSON modes) notify() shows nothing, so say it on stderr. */
|
|
60
|
+
function needInteractive(name: string): void {
|
|
61
|
+
process.stderr.write(`/${name} needs the interactive UI: it reports to you and asks before it changes anything. Run it in an interactive pi session.\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MENU = ["Commit", "Push", "Merge a branch", "Open a pull request", "Status"] as const;
|
|
65
|
+
|
|
66
|
+
export default function (pi: ExtensionAPI) {
|
|
67
|
+
/** Run a workflow if the UI can talk to the user and the tools are not switched off. */
|
|
68
|
+
const guarded = (fn: (env: Env, args: string) => Promise<unknown>, name: string) => async (args: string, ctx: ExtensionContext) => {
|
|
69
|
+
if (!loadGitEnabled()) return ctx.ui.notify("The git commands are off. /git on turns them back on.", "warning");
|
|
70
|
+
if (!ctx.hasUI) return needInteractive(name);
|
|
71
|
+
try {
|
|
72
|
+
await fn(envFor(pi, ctx), args);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
ctx.ui.notify(`git tools: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const doCommit = guarded((env, a) => commit(env, a), "commit");
|
|
79
|
+
const doPush = guarded((env) => push(env), "push");
|
|
80
|
+
const doMerge = guarded((env, a) => merge(env, a), "merge");
|
|
81
|
+
const doPr = guarded((env, a) => pullRequest(env, a), "pr");
|
|
82
|
+
const doStatus = guarded((env) => showStatus(env), "git status");
|
|
83
|
+
|
|
84
|
+
pi.registerCommand("commit", { description: "Commit staged changes: /commit [message]", handler: doCommit });
|
|
85
|
+
pi.registerCommand("push", { description: "Push the current branch", handler: doPush });
|
|
86
|
+
pi.registerCommand("merge", {
|
|
87
|
+
description: "Merge a branch into this one: /merge [branch|abort]",
|
|
88
|
+
getArgumentCompletions: (prefix) => (["abort"].filter((o) => o.startsWith(prefix.trim())).map((o) => ({ value: o, label: o })) || null),
|
|
89
|
+
handler: doMerge,
|
|
90
|
+
});
|
|
91
|
+
pi.registerCommand("pr", { description: "Open a pull request: /pr [base] [--draft]", handler: doPr });
|
|
92
|
+
pi.registerCommand("git", {
|
|
93
|
+
description: "Git menu, status, or switch: /git [status|on|off]",
|
|
94
|
+
getArgumentCompletions: (prefix) => {
|
|
95
|
+
const opts = ["status", "on", "off"].filter((o) => o.startsWith(prefix.trim()));
|
|
96
|
+
return opts.length ? opts.map((o) => ({ value: o, label: o })) : null;
|
|
97
|
+
},
|
|
98
|
+
handler: async (args, ctx) => {
|
|
99
|
+
const arg = args.trim().toLowerCase();
|
|
100
|
+
if (arg === "on" || arg === "off") {
|
|
101
|
+
const saved = saveGitEnabled(arg === "on");
|
|
102
|
+
const note = arg === "on" ? "Git commands on." : "Git commands off. /git on turns them back on.";
|
|
103
|
+
return ctx.ui.notify(saved ? note : `${note} (could not save the setting, so it only lasts until pi restarts)`, saved ? "info" : "warning");
|
|
104
|
+
}
|
|
105
|
+
if (arg && arg !== "status") return ctx.ui.notify("Usage: /git [status|on|off]", "warning");
|
|
106
|
+
if (!loadGitEnabled()) return ctx.ui.notify("The git commands are off. /git on turns them back on.", "warning");
|
|
107
|
+
if (arg === "status") return doStatus("", ctx);
|
|
108
|
+
if (!ctx.hasUI) return needInteractive("git");
|
|
109
|
+
const picked = await ctx.ui.select("Git", [...MENU]);
|
|
110
|
+
if (picked === "Commit") return doCommit("", ctx);
|
|
111
|
+
if (picked === "Push") return doPush("", ctx);
|
|
112
|
+
if (picked === "Merge a branch") return doMerge("", ctx);
|
|
113
|
+
if (picked === "Open a pull request") return doPr("", ctx);
|
|
114
|
+
if (picked === "Status") return doStatus("", ctx);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|