pi-forge 0.0.0 → 1.1.4
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/LICENSE +21 -0
- package/README.md +48 -4
- package/bin/pi-forge.mjs +37 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
- package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
- package/dist/client/assets/index-B-529kgJ.css +32 -0
- package/dist/client/assets/index-BzKzxXFs.js +392 -0
- package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
- package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
- package/dist/client/icons/icon-192.png +0 -0
- package/dist/client/icons/icon-512.png +0 -0
- package/dist/client/icons/icon-maskable-512.png +0 -0
- package/dist/client/icons/icon.svg +9 -0
- package/dist/client/index.html +24 -0
- package/dist/client/manifest.webmanifest +1 -0
- package/dist/client/offline.html +142 -0
- package/dist/client/sw.js +3 -0
- package/dist/client/sw.js.map +1 -0
- package/dist/client/workbox-6d7155ed.js +3 -0
- package/dist/client/workbox-6d7155ed.js.map +1 -0
- package/dist/server/agent-resource-loader.js +126 -0
- package/dist/server/agent-resource-loader.js.map +1 -0
- package/dist/server/attachment-converters.js +96 -0
- package/dist/server/attachment-converters.js.map +1 -0
- package/dist/server/auth.js +209 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/compaction-history.js +106 -0
- package/dist/server/compaction-history.js.map +1 -0
- package/dist/server/concurrency.js +49 -0
- package/dist/server/concurrency.js.map +1 -0
- package/dist/server/config-export.js +220 -0
- package/dist/server/config-export.js.map +1 -0
- package/dist/server/config-manager.js +528 -0
- package/dist/server/config-manager.js.map +1 -0
- package/dist/server/config.js +326 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/conversion-worker.mjs +90 -0
- package/dist/server/diagnostics.js +137 -0
- package/dist/server/diagnostics.js.map +1 -0
- package/dist/server/extensions-discovery.js +147 -0
- package/dist/server/extensions-discovery.js.map +1 -0
- package/dist/server/file-manager.js +734 -0
- package/dist/server/file-manager.js.map +1 -0
- package/dist/server/file-references.js +215 -0
- package/dist/server/file-references.js.map +1 -0
- package/dist/server/file-searcher.js +385 -0
- package/dist/server/file-searcher.js.map +1 -0
- package/dist/server/git-runner.js +684 -0
- package/dist/server/git-runner.js.map +1 -0
- package/dist/server/index.js +468 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp/config.js +133 -0
- package/dist/server/mcp/config.js.map +1 -0
- package/dist/server/mcp/manager.js +351 -0
- package/dist/server/mcp/manager.js.map +1 -0
- package/dist/server/mcp/tool-bridge.js +173 -0
- package/dist/server/mcp/tool-bridge.js.map +1 -0
- package/dist/server/project-manager.js +301 -0
- package/dist/server/project-manager.js.map +1 -0
- package/dist/server/pty-manager.js +354 -0
- package/dist/server/pty-manager.js.map +1 -0
- package/dist/server/routes/_schemas.js +73 -0
- package/dist/server/routes/_schemas.js.map +1 -0
- package/dist/server/routes/auth.js +164 -0
- package/dist/server/routes/auth.js.map +1 -0
- package/dist/server/routes/config.js +1163 -0
- package/dist/server/routes/config.js.map +1 -0
- package/dist/server/routes/control.js +464 -0
- package/dist/server/routes/control.js.map +1 -0
- package/dist/server/routes/exec.js +217 -0
- package/dist/server/routes/exec.js.map +1 -0
- package/dist/server/routes/files.js +847 -0
- package/dist/server/routes/files.js.map +1 -0
- package/dist/server/routes/git.js +837 -0
- package/dist/server/routes/git.js.map +1 -0
- package/dist/server/routes/health.js +97 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/mcp.js +300 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/projects.js +259 -0
- package/dist/server/routes/projects.js.map +1 -0
- package/dist/server/routes/prompt.js +496 -0
- package/dist/server/routes/prompt.js.map +1 -0
- package/dist/server/routes/sessions.js +783 -0
- package/dist/server/routes/sessions.js.map +1 -0
- package/dist/server/routes/stream.js +69 -0
- package/dist/server/routes/stream.js.map +1 -0
- package/dist/server/routes/terminal.js +335 -0
- package/dist/server/routes/terminal.js.map +1 -0
- package/dist/server/session-registry.js +1197 -0
- package/dist/server/session-registry.js.map +1 -0
- package/dist/server/skill-overrides.js +151 -0
- package/dist/server/skill-overrides.js.map +1 -0
- package/dist/server/skills-export.js +257 -0
- package/dist/server/skills-export.js.map +1 -0
- package/dist/server/sse-bridge.js +220 -0
- package/dist/server/sse-bridge.js.map +1 -0
- package/dist/server/tool-overrides.js +277 -0
- package/dist/server/tool-overrides.js.map +1 -0
- package/dist/server/turn-diff-builder.js +280 -0
- package/dist/server/turn-diff-builder.js.map +1 -0
- package/package.json +53 -12
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { assertInsideRoot } from "./file-manager.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Thin wrapper around `git` for the pi-forge's git panel.
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* - NEVER `exec` with string interpolation. Always `execFile` with
|
|
12
|
+
* an args array. The project path comes from our own
|
|
13
|
+
* `project-manager` (validated against WORKSPACE_PATH); commit
|
|
14
|
+
* messages and remote/branch names come from user input — args
|
|
15
|
+
* arrays make shell-quoting moot regardless of content.
|
|
16
|
+
* - "Not a git repo" → return empty / sensible default, NEVER 500.
|
|
17
|
+
* Users can have non-git project folders and the panel should
|
|
18
|
+
* just sit quiet, not error.
|
|
19
|
+
* - User-visible errors carry a short message we synthesize from
|
|
20
|
+
* the stderr; we never blast raw stderr at the client (would
|
|
21
|
+
* leak fs paths + git plumbing detail).
|
|
22
|
+
*
|
|
23
|
+
* Output buffer: 16 MB on every call. Plenty for `diff` and `log
|
|
24
|
+
* --oneline -30` even on monorepos; if a future `log` query needs
|
|
25
|
+
* more, we'll cap it explicitly.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
28
|
+
/* ----------------------------- errors ----------------------------- */
|
|
29
|
+
export class GitNotInstalledError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("git binary not found on PATH");
|
|
32
|
+
this.name = "GitNotInstalledError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class GitCommandError extends Error {
|
|
36
|
+
exitCode;
|
|
37
|
+
/** Sanitized first line of stderr — safe to surface to the user. */
|
|
38
|
+
userMessage;
|
|
39
|
+
constructor(exitCode, userMessage, fullMessage) {
|
|
40
|
+
super(fullMessage);
|
|
41
|
+
this.name = "GitCommandError";
|
|
42
|
+
this.exitCode = exitCode;
|
|
43
|
+
this.userMessage = userMessage;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run `git <args>` in `cwd`. Resolves on exit code 0; rejects with
|
|
48
|
+
* `GitCommandError` (with sanitized userMessage) otherwise.
|
|
49
|
+
*
|
|
50
|
+
* `GIT_TERMINAL_PROMPT=0` keeps git from blocking on interactive
|
|
51
|
+
* credential prompts when the user pushes without configured creds —
|
|
52
|
+
* we want a fast 4xx instead of a hung process. Same for
|
|
53
|
+
* `GIT_ASKPASS` set to `true` (the no-op binary).
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* `-c` flags prepended to every git invocation so a hostile per-repo
|
|
57
|
+
* `.git/config` can't get the pi-forge to execute arbitrary commands
|
|
58
|
+
* via `core.fsmonitor` / `core.editor` / `core.pager` /
|
|
59
|
+
* `core.sshCommand` / `core.askPass`. Cloning a third-party repo is a
|
|
60
|
+
* normal flow; the cloned repo's local config CAN ship hostile values
|
|
61
|
+
* for these keys (the initial clone is from upstream and doesn't apply
|
|
62
|
+
* the local config, but every subsequent `git status` etc. does).
|
|
63
|
+
*
|
|
64
|
+
* Setting these to safe defaults at invocation time overrides any
|
|
65
|
+
* value the repo's `.git/config` set. Reference:
|
|
66
|
+
* https://github.blog/2022-04-12-git-security-vulnerability-announced/
|
|
67
|
+
*/
|
|
68
|
+
const HARDENING_ARGS = [
|
|
69
|
+
"-c",
|
|
70
|
+
"core.fsmonitor=",
|
|
71
|
+
"-c",
|
|
72
|
+
"core.askPass=",
|
|
73
|
+
"-c",
|
|
74
|
+
"core.sshCommand=ssh",
|
|
75
|
+
"-c",
|
|
76
|
+
"core.editor=true",
|
|
77
|
+
"-c",
|
|
78
|
+
"core.pager=cat",
|
|
79
|
+
];
|
|
80
|
+
async function runGit(cwd, args) {
|
|
81
|
+
try {
|
|
82
|
+
const { stdout, stderr } = await execFileAsync("git", [...HARDENING_ARGS, ...args], {
|
|
83
|
+
cwd,
|
|
84
|
+
maxBuffer: MAX_BUFFER,
|
|
85
|
+
env: gitEnv(),
|
|
86
|
+
});
|
|
87
|
+
return { stdout, stderr, exitCode: 0 };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const e = err;
|
|
91
|
+
if (e.code === "ENOENT")
|
|
92
|
+
throw new GitNotInstalledError();
|
|
93
|
+
const stderr = (e.stderr ?? "").toString();
|
|
94
|
+
const userMessage = sanitizeStderr(stderr, cwd);
|
|
95
|
+
const exitCode = typeof e.code === "number" ? e.code : null;
|
|
96
|
+
throw new GitCommandError(exitCode, userMessage, stderr || (e.message ?? "git failed"));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build the env object every git invocation should use. Centralising it
|
|
101
|
+
* here lets `runGit` (the typed-error wrapper) and `runGitRaw` (the
|
|
102
|
+
* permissive escape hatch used by `turn-diff-builder`) share the same
|
|
103
|
+
* GIT_TERMINAL_PROMPT / GIT_ASKPASS / LC_ALL / HOME scrubbing.
|
|
104
|
+
*
|
|
105
|
+
* `git config` consults $HOME for the user's global config. If the
|
|
106
|
+
* parent process has no HOME (some container init flows, certain
|
|
107
|
+
* systemd/launchd configurations), git falls back to /etc/passwd
|
|
108
|
+
* lookup which can fail opaquely. Force a sensible default from
|
|
109
|
+
* `os.homedir()` (which itself checks USERPROFILE on Windows + falls
|
|
110
|
+
* back to the passwd entry).
|
|
111
|
+
*/
|
|
112
|
+
function gitEnv() {
|
|
113
|
+
return {
|
|
114
|
+
...process.env,
|
|
115
|
+
HOME: process.env.HOME ?? homedir(),
|
|
116
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
117
|
+
GIT_ASKPASS: "true",
|
|
118
|
+
LC_ALL: "C",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Permissive runner for callers that need a custom maxBuffer or
|
|
123
|
+
* non-typed-error semantics. Returns the raw stdout/stderr/exitCode
|
|
124
|
+
* tuple WITHOUT mapping non-zero exit codes to GitCommandError —
|
|
125
|
+
* callers handle exit codes themselves. Inherits the same env
|
|
126
|
+
* scrubbing as `runGit`.
|
|
127
|
+
*
|
|
128
|
+
* Currently used by `turn-diff-builder.ts` which wants `git diff` to
|
|
129
|
+
* succeed even when the path is untracked (exit 0 with empty output)
|
|
130
|
+
* and accepts a 16 MB diff buffer.
|
|
131
|
+
*/
|
|
132
|
+
export async function runGitRaw(cwd, args, opts = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const { stdout, stderr } = await execFileAsync("git", [...HARDENING_ARGS, ...args], {
|
|
135
|
+
cwd,
|
|
136
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
137
|
+
env: gitEnv(),
|
|
138
|
+
});
|
|
139
|
+
return { stdout, stderr };
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err.code === "ENOENT")
|
|
143
|
+
throw new GitNotInstalledError();
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Trim git's stderr to a one-line user-visible message. Strips the
|
|
149
|
+
* project root path (would otherwise leak filesystem layout — `git
|
|
150
|
+
* push` failing with "fatal: unable to access '/Users/.../foo/.git/'"
|
|
151
|
+
* would echo the host path back to the browser). Also drops the
|
|
152
|
+
* common "fatal: "/"error: "/"warning: " prefix and clamps to 200
|
|
153
|
+
* chars. Single-tenant doesn't make this a security issue per se,
|
|
154
|
+
* but the previous comment claimed scrubbing happened when it didn't
|
|
155
|
+
* — bringing the implementation in line with the documented behavior.
|
|
156
|
+
*/
|
|
157
|
+
function sanitizeStderr(stderr, cwd) {
|
|
158
|
+
let firstLine = stderr.split("\n").find((l) => l.trim().length > 0) ?? "git error";
|
|
159
|
+
if (cwd !== undefined && cwd.length > 0) {
|
|
160
|
+
firstLine = firstLine.split(cwd).join("<project>");
|
|
161
|
+
}
|
|
162
|
+
// Drop common "fatal: " / "error: " prefixes that confuse users.
|
|
163
|
+
const stripped = firstLine.replace(/^(fatal|error|warning):\s*/i, "");
|
|
164
|
+
return stripped.length > 200 ? stripped.slice(0, 197) + "…" : stripped;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Initialize a fresh git repo at `cwd` with `main` as the initial
|
|
168
|
+
* branch. `git init -b main` requires git ≥ 2.28; below that the
|
|
169
|
+
* `--initial-branch` flag is unrecognized and we fall back to a
|
|
170
|
+
* plain `git init` — caller can still rename to main on the first
|
|
171
|
+
* commit if desired. Idempotent: if `cwd` is already a repo, this
|
|
172
|
+
* resolves without changing anything (git's own `init` is a no-op
|
|
173
|
+
* on an existing repo).
|
|
174
|
+
*/
|
|
175
|
+
export async function initRepo(cwd) {
|
|
176
|
+
try {
|
|
177
|
+
await runGit(cwd, ["init", "-b", "main"]);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
// Older git versions (< 2.28) don't recognise `-b`. Detect via
|
|
181
|
+
// the stderr message and retry without it. Other errors propagate.
|
|
182
|
+
const msg = err instanceof Error ? err.message : "";
|
|
183
|
+
if (/unknown (option|switch).*-b|invalid option.*initial-branch/i.test(msg)) {
|
|
184
|
+
await runGit(cwd, ["init"]);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* True iff `cwd` is inside a git working tree. Cheap probe used by
|
|
192
|
+
* every public function so "not a repo" can return the empty default
|
|
193
|
+
* rather than throw. Exported so route helpers (e.g. for the diff
|
|
194
|
+
* endpoints' `isGitRepo` flag) don't have to re-implement it.
|
|
195
|
+
*/
|
|
196
|
+
export async function isGitRepo(cwd) {
|
|
197
|
+
try {
|
|
198
|
+
await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// GitCommandError (non-zero exit, common: "not a git repository"),
|
|
203
|
+
// GitNotInstalledError, or fs error. All collapse to "not a repo"
|
|
204
|
+
// — the panel renders an empty state without distinguishing.
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/* ----------------------------- status ----------------------------- */
|
|
209
|
+
/**
|
|
210
|
+
* `git status --porcelain=v1 -uall -z` output. Records are NUL-
|
|
211
|
+
* terminated (no quoting / escaping), so paths containing literal
|
|
212
|
+
* newlines, quotes, or other special chars round-trip cleanly.
|
|
213
|
+
*
|
|
214
|
+
* Each record is `XY <path>` (length ≥ 4 with the leading XY + space).
|
|
215
|
+
* Renames and copies are special: `XY <newpath>` is followed by a
|
|
216
|
+
* SECOND NUL-terminated record containing only the original path. We
|
|
217
|
+
* peek at the next token in that case rather than splitting on " -> ".
|
|
218
|
+
*/
|
|
219
|
+
function parseStatus(stdout) {
|
|
220
|
+
const out = [];
|
|
221
|
+
// Trailing NUL produces an empty final element — drop it.
|
|
222
|
+
const records = stdout.split("\0").filter((r) => r.length > 0);
|
|
223
|
+
for (let i = 0; i < records.length; i++) {
|
|
224
|
+
const rec = records[i] ?? "";
|
|
225
|
+
if (rec.length < 4)
|
|
226
|
+
continue;
|
|
227
|
+
const code = rec.slice(0, 2);
|
|
228
|
+
const path = rec.slice(3);
|
|
229
|
+
const x = code[0] ?? " ";
|
|
230
|
+
const y = code[1] ?? " ";
|
|
231
|
+
let originalPath;
|
|
232
|
+
// For renames/copies, the next record is the ORIGINAL path. Peek
|
|
233
|
+
// and consume.
|
|
234
|
+
if (x === "R" || x === "C" || y === "R" || y === "C") {
|
|
235
|
+
const next = records[i + 1];
|
|
236
|
+
if (next !== undefined) {
|
|
237
|
+
originalPath = next;
|
|
238
|
+
i++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (path.length === 0)
|
|
242
|
+
continue;
|
|
243
|
+
const staged = x !== " " && x !== "?";
|
|
244
|
+
const unstaged = y !== " " && y !== "?";
|
|
245
|
+
const entry = {
|
|
246
|
+
path,
|
|
247
|
+
staged,
|
|
248
|
+
unstaged,
|
|
249
|
+
kind: classifyStatus(x, y),
|
|
250
|
+
code,
|
|
251
|
+
};
|
|
252
|
+
if (originalPath !== undefined)
|
|
253
|
+
entry.originalPath = originalPath;
|
|
254
|
+
out.push(entry);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
function classifyStatus(x, y) {
|
|
259
|
+
if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
|
|
260
|
+
return "conflicted";
|
|
261
|
+
}
|
|
262
|
+
if (x === "?" && y === "?")
|
|
263
|
+
return "untracked";
|
|
264
|
+
if (x === "!" || y === "!")
|
|
265
|
+
return "ignored";
|
|
266
|
+
// Prefer the staged side's classification — it's "what will be
|
|
267
|
+
// committed". Fall back to the unstaged side.
|
|
268
|
+
const c = x !== " " && x !== "?" ? x : y;
|
|
269
|
+
switch (c) {
|
|
270
|
+
case "M":
|
|
271
|
+
return "modified";
|
|
272
|
+
case "A":
|
|
273
|
+
return "added";
|
|
274
|
+
case "D":
|
|
275
|
+
return "deleted";
|
|
276
|
+
case "R":
|
|
277
|
+
return "renamed";
|
|
278
|
+
case "C":
|
|
279
|
+
return "copied";
|
|
280
|
+
default:
|
|
281
|
+
return "unknown";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
export async function getStatus(cwd) {
|
|
285
|
+
if (!(await isGitRepo(cwd))) {
|
|
286
|
+
return { isGitRepo: false, branch: undefined, files: [] };
|
|
287
|
+
}
|
|
288
|
+
const [branchRes, statusRes] = await Promise.all([
|
|
289
|
+
runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => undefined),
|
|
290
|
+
runGit(cwd, ["status", "--porcelain=v1", "-uall", "-z"]),
|
|
291
|
+
]);
|
|
292
|
+
// `--abbrev-ref HEAD` returns "HEAD" on a detached checkout; surface
|
|
293
|
+
// that verbatim so the UI can render it.
|
|
294
|
+
const branch = branchRes?.stdout.trim();
|
|
295
|
+
return {
|
|
296
|
+
isGitRepo: true,
|
|
297
|
+
branch: branch !== undefined && branch.length > 0 ? branch : undefined,
|
|
298
|
+
files: parseStatus(statusRes.stdout),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
async function diffArgs(cwd, args) {
|
|
302
|
+
if (!(await isGitRepo(cwd)))
|
|
303
|
+
return { isGitRepo: false, diff: "" };
|
|
304
|
+
const baseArgs = ["diff", "--no-color", "--no-ext-diff", ...args];
|
|
305
|
+
const { stdout } = await runGit(cwd, baseArgs);
|
|
306
|
+
return { isGitRepo: true, diff: stdout };
|
|
307
|
+
}
|
|
308
|
+
export function getDiff(cwd) {
|
|
309
|
+
return diffArgs(cwd, []);
|
|
310
|
+
}
|
|
311
|
+
export function getStagedDiff(cwd) {
|
|
312
|
+
return diffArgs(cwd, ["--cached"]);
|
|
313
|
+
}
|
|
314
|
+
export async function getFileDiff(cwd, path, staged) {
|
|
315
|
+
if (!(await isGitRepo(cwd)))
|
|
316
|
+
return { isGitRepo: false, diff: "" };
|
|
317
|
+
// Belt-and-suspenders lexical guard. git itself rejects paths outside
|
|
318
|
+
// the working tree, but routing every path through the same check
|
|
319
|
+
// file-manager uses keeps the boundary obvious in one place. `path`
|
|
320
|
+
// arrives relative-to-project from the route, so resolve against cwd
|
|
321
|
+
// before checking.
|
|
322
|
+
assertInsideRoot(resolve(cwd, path), cwd);
|
|
323
|
+
const args = ["diff", "--no-color", "--no-ext-diff"];
|
|
324
|
+
if (staged)
|
|
325
|
+
args.push("--cached");
|
|
326
|
+
args.push("--", path);
|
|
327
|
+
const { stdout } = await runGit(cwd, args);
|
|
328
|
+
return { isGitRepo: true, diff: stdout };
|
|
329
|
+
}
|
|
330
|
+
export async function getLog(cwd, limit = 30) {
|
|
331
|
+
if (!(await isGitRepo(cwd)))
|
|
332
|
+
return { isGitRepo: false, commits: [] };
|
|
333
|
+
// Custom format with NUL field separators and RS record separator.
|
|
334
|
+
// Avoids ambiguity if a commit message has any character we'd
|
|
335
|
+
// otherwise pick as a delimiter. New fields:
|
|
336
|
+
// %P → space-separated parent hashes (empty for root, 2+ for merges)
|
|
337
|
+
// %D → ref decorations like "HEAD -> main, origin/main, tag: v1"
|
|
338
|
+
// We pass `HEAD --branches --tags --remotes` so branches that
|
|
339
|
+
// aren't ancestors of HEAD still surface — the graph renderer wants
|
|
340
|
+
// the full topology, not just first-parent ancestry from the
|
|
341
|
+
// current branch. Explicit `HEAD` keeps the checked-out branch in
|
|
342
|
+
// the result even if many older branch tips would otherwise crowd
|
|
343
|
+
// it out at the `--max-count` boundary. `--topo-order` keeps
|
|
344
|
+
// commits from disjoint branches grouped instead of date-interleaved
|
|
345
|
+
// so the graph reads cleanly.
|
|
346
|
+
const FS = "\x1F";
|
|
347
|
+
const RS = "\x1E";
|
|
348
|
+
const fmt = `%H${FS}%s${FS}%an${FS}%aI${FS}%P${FS}%D${RS}`;
|
|
349
|
+
const { stdout } = await runGit(cwd, [
|
|
350
|
+
"log",
|
|
351
|
+
"--topo-order",
|
|
352
|
+
"HEAD",
|
|
353
|
+
"--branches",
|
|
354
|
+
"--tags",
|
|
355
|
+
"--remotes",
|
|
356
|
+
`--max-count=${Math.max(1, Math.min(limit, 1000))}`,
|
|
357
|
+
`--pretty=format:${fmt}`,
|
|
358
|
+
]);
|
|
359
|
+
if (stdout.length === 0)
|
|
360
|
+
return { isGitRepo: true, commits: [] };
|
|
361
|
+
const commits = stdout
|
|
362
|
+
.split(RS)
|
|
363
|
+
.map((rec) => rec.replace(/^\n/, ""))
|
|
364
|
+
.filter((rec) => rec.length > 0)
|
|
365
|
+
.map((rec) => {
|
|
366
|
+
const [hash = "", message = "", author = "", date = "", parentsRaw = "", refsRaw = ""] = rec.split(FS);
|
|
367
|
+
const parents = parentsRaw.length > 0 ? parentsRaw.split(" ").filter((p) => p.length > 0) : [];
|
|
368
|
+
const refs = refsRaw.length > 0
|
|
369
|
+
? refsRaw
|
|
370
|
+
.split(",")
|
|
371
|
+
.map((r) => r.trim())
|
|
372
|
+
.filter((r) => r.length > 0)
|
|
373
|
+
: [];
|
|
374
|
+
return { hash, message, author, date, parents, refs };
|
|
375
|
+
});
|
|
376
|
+
return { isGitRepo: true, commits };
|
|
377
|
+
}
|
|
378
|
+
export async function getRemotes(cwd) {
|
|
379
|
+
if (!(await isGitRepo(cwd)))
|
|
380
|
+
return { isGitRepo: false, remotes: [] };
|
|
381
|
+
const { stdout } = await runGit(cwd, ["remote", "-v"]);
|
|
382
|
+
const map = new Map();
|
|
383
|
+
for (const line of stdout.split("\n")) {
|
|
384
|
+
if (line.length === 0)
|
|
385
|
+
continue;
|
|
386
|
+
// `name<TAB>url (fetch|push)`
|
|
387
|
+
const m = /^(\S+)\s+(.+?)\s+\((fetch|push)\)$/.exec(line);
|
|
388
|
+
if (m === null)
|
|
389
|
+
continue;
|
|
390
|
+
const name = m[1] ?? "";
|
|
391
|
+
const url = m[2] ?? "";
|
|
392
|
+
const dir = m[3] === "push" ? "push" : "fetch";
|
|
393
|
+
if (name.length === 0)
|
|
394
|
+
continue;
|
|
395
|
+
const existing = map.get(name);
|
|
396
|
+
if (existing === undefined) {
|
|
397
|
+
// Pre-fill the OTHER URL with the same value; if the second
|
|
398
|
+
// line for this remote contradicts, we overwrite below.
|
|
399
|
+
map.set(name, { name, fetchUrl: url, pushUrl: url });
|
|
400
|
+
}
|
|
401
|
+
else if (dir === "push") {
|
|
402
|
+
existing.pushUrl = url;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
existing.fetchUrl = url;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const remotes = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
409
|
+
return { isGitRepo: true, remotes };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Add a remote. Same name validator as branch creation reused via
|
|
413
|
+
* `assertRemoteName`. The URL is passed verbatim to git as a
|
|
414
|
+
* positional arg; `execFile` (no shell) means there's no command-
|
|
415
|
+
* injection surface even if the URL contains spaces or shell
|
|
416
|
+
* metachars. Common shapes: `https://github.com/foo/bar.git`,
|
|
417
|
+
* `git@github.com:foo/bar.git`, `file:///abs/path`.
|
|
418
|
+
*/
|
|
419
|
+
export async function addRemote(cwd, name, url) {
|
|
420
|
+
assertRemoteName(name);
|
|
421
|
+
if (url.length === 0 || url.length > 1024) {
|
|
422
|
+
throw new InvalidBranchNameError(`invalid remote URL`);
|
|
423
|
+
}
|
|
424
|
+
// Reject leading dash so the URL can't be parsed as a flag if a
|
|
425
|
+
// future code path drops the `--` separator. `git remote add`
|
|
426
|
+
// ignores `--` in current versions, but defensive.
|
|
427
|
+
if (url.startsWith("-")) {
|
|
428
|
+
throw new InvalidBranchNameError(`invalid remote URL`);
|
|
429
|
+
}
|
|
430
|
+
await runGit(cwd, ["remote", "add", name, url]);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Remove a remote. Idempotent at the route layer — git emits
|
|
434
|
+
* "fatal: No such remote" if the name is unknown, which surfaces as
|
|
435
|
+
* the existing 400 git_failed; the route layer can choose to map
|
|
436
|
+
* that to 404 if needed.
|
|
437
|
+
*/
|
|
438
|
+
export async function removeRemote(cwd, name) {
|
|
439
|
+
assertRemoteName(name);
|
|
440
|
+
await runGit(cwd, ["remote", "remove", name]);
|
|
441
|
+
}
|
|
442
|
+
/* ----------------------------- branches ----------------------------- */
|
|
443
|
+
export async function getBranches(cwd) {
|
|
444
|
+
if (!(await isGitRepo(cwd)))
|
|
445
|
+
return { isGitRepo: false, current: undefined, branches: [] };
|
|
446
|
+
const { stdout } = await runGit(cwd, ["branch", "-a", "--format=%(HEAD)\x1F%(refname:short)"]);
|
|
447
|
+
const branches = [];
|
|
448
|
+
let current;
|
|
449
|
+
for (const line of stdout.split("\n")) {
|
|
450
|
+
if (line.length === 0)
|
|
451
|
+
continue;
|
|
452
|
+
const [headFlag = "", name = ""] = line.split("\x1F");
|
|
453
|
+
if (name.length === 0)
|
|
454
|
+
continue;
|
|
455
|
+
// git emits "(HEAD detached at ...)" as a pseudo-ref; skip.
|
|
456
|
+
if (name.startsWith("("))
|
|
457
|
+
continue;
|
|
458
|
+
const isCurrent = headFlag === "*";
|
|
459
|
+
// git's --format always prefixes remote-tracking branches with
|
|
460
|
+
// `remotes/`. The earlier `origin/` heuristic mis-classified a
|
|
461
|
+
// local branch literally named `origin/feature` as remote.
|
|
462
|
+
const remote = name.startsWith("remotes/");
|
|
463
|
+
const cleanName = remote ? name.slice("remotes/".length) : name;
|
|
464
|
+
branches.push({ name: cleanName, current: isCurrent, remote });
|
|
465
|
+
if (isCurrent)
|
|
466
|
+
current = cleanName;
|
|
467
|
+
}
|
|
468
|
+
return { isGitRepo: true, current, branches };
|
|
469
|
+
}
|
|
470
|
+
/* ----------------------------- mutations ----------------------------- */
|
|
471
|
+
export async function stagePaths(cwd, paths) {
|
|
472
|
+
if (paths.length === 0)
|
|
473
|
+
return;
|
|
474
|
+
await runGit(cwd, ["add", "--", ...paths]);
|
|
475
|
+
}
|
|
476
|
+
export async function unstagePaths(cwd, paths) {
|
|
477
|
+
if (paths.length === 0)
|
|
478
|
+
return;
|
|
479
|
+
await runGit(cwd, ["restore", "--staged", "--", ...paths]);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Discard local changes for the given files: restores both the
|
|
483
|
+
* index AND the working tree to HEAD via `git restore --staged
|
|
484
|
+
* --worktree --source=HEAD -- <paths>`. The user-visible "Revert"
|
|
485
|
+
* action.
|
|
486
|
+
*
|
|
487
|
+
* For untracked files, `git restore` errors with "pathspec did
|
|
488
|
+
* not match any file(s) known to git". The route surfaces this
|
|
489
|
+
* via `GitCommandError` so the UI can display "untracked files
|
|
490
|
+
* can't be reverted; delete them via the file browser instead."
|
|
491
|
+
*
|
|
492
|
+
* Destructive — the caller is expected to gate this behind a
|
|
493
|
+
* confirmation in the UI (the click-twice-to-confirm pattern in
|
|
494
|
+
* GitPanel).
|
|
495
|
+
*/
|
|
496
|
+
export async function revertPaths(cwd, paths) {
|
|
497
|
+
if (paths.length === 0)
|
|
498
|
+
return;
|
|
499
|
+
await runGit(cwd, ["restore", "--staged", "--worktree", "--source=HEAD", "--", ...paths]);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Commit the currently-staged changes. Empty / whitespace-only
|
|
503
|
+
* messages are rejected at the route layer; this just runs the
|
|
504
|
+
* command. `--no-verify` is NOT used — we want pre-commit hooks to
|
|
505
|
+
* fire so the user's lint/test/format checks gate browser commits
|
|
506
|
+
* the same way they gate terminal commits.
|
|
507
|
+
*/
|
|
508
|
+
export async function commit(cwd, message) {
|
|
509
|
+
await runGit(cwd, ["commit", "-m", message]);
|
|
510
|
+
// Capture the new HEAD's hash so the route can echo it back —
|
|
511
|
+
// useful for the UI to highlight "your commit" in the log section.
|
|
512
|
+
const { stdout } = await runGit(cwd, ["rev-parse", "HEAD"]);
|
|
513
|
+
return { hash: stdout.trim() };
|
|
514
|
+
}
|
|
515
|
+
/* ----------------------------- branch ops ----------------------------- */
|
|
516
|
+
/**
|
|
517
|
+
* Restrict branch names to the same character set git itself accepts in
|
|
518
|
+
* common usage — letters, digits, dot, dash, underscore, slash. Reject
|
|
519
|
+
* anything else (spaces, control chars, leading dash that could be
|
|
520
|
+
* mistaken for a flag, dot-only segments, double slashes, etc.) with a
|
|
521
|
+
* single error code so the route can return a stable 400.
|
|
522
|
+
*/
|
|
523
|
+
export class InvalidBranchNameError extends Error {
|
|
524
|
+
constructor(name) {
|
|
525
|
+
super(`invalid branch name: ${JSON.stringify(name)}`);
|
|
526
|
+
this.name = "InvalidBranchNameError";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function assertBranchName(name) {
|
|
530
|
+
if (name.length === 0 || name.length > 200)
|
|
531
|
+
throw new InvalidBranchNameError(name);
|
|
532
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(name))
|
|
533
|
+
throw new InvalidBranchNameError(name);
|
|
534
|
+
if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) {
|
|
535
|
+
throw new InvalidBranchNameError(name);
|
|
536
|
+
}
|
|
537
|
+
if (name.includes("//") || name.includes("..") || name.includes("@{")) {
|
|
538
|
+
throw new InvalidBranchNameError(name);
|
|
539
|
+
}
|
|
540
|
+
// git reserves `HEAD` and a few similar single-token refs.
|
|
541
|
+
if (name === "HEAD" || name === "FETCH_HEAD" || name === "ORIG_HEAD" || name === "MERGE_HEAD") {
|
|
542
|
+
throw new InvalidBranchNameError(name);
|
|
543
|
+
}
|
|
544
|
+
// git's check-ref-format rules we replicate explicitly so the user
|
|
545
|
+
// gets the cleaner `invalid_branch_name` 400 instead of `git_failed`:
|
|
546
|
+
// - no segment may begin with `.` (so `.foo` and `bar/.baz` reject)
|
|
547
|
+
// - no segment may end with `.lock` (git uses .lock files for ref locks)
|
|
548
|
+
// - the whole name may not end with `.`
|
|
549
|
+
if (name.endsWith("."))
|
|
550
|
+
throw new InvalidBranchNameError(name);
|
|
551
|
+
for (const segment of name.split("/")) {
|
|
552
|
+
if (segment.startsWith("."))
|
|
553
|
+
throw new InvalidBranchNameError(name);
|
|
554
|
+
if (segment.endsWith(".lock"))
|
|
555
|
+
throw new InvalidBranchNameError(name);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Switch the working tree to `branch`. Refuses on a dirty tree (git's
|
|
560
|
+
* default) — the caller is expected to surface the resulting
|
|
561
|
+
* `GitCommandError` to the user, who can stash or revert first.
|
|
562
|
+
*
|
|
563
|
+
* No `--` separator: `git checkout -- <name>` interprets <name> as a
|
|
564
|
+
* pathspec and ALWAYS fails with "did not match any file(s) known to
|
|
565
|
+
* git". The branch-name validator (assertBranchName) already rejects
|
|
566
|
+
* leading dashes, so flag injection isn't a concern here.
|
|
567
|
+
*/
|
|
568
|
+
export async function checkoutBranch(cwd, branch) {
|
|
569
|
+
assertBranchName(branch);
|
|
570
|
+
await runGit(cwd, ["checkout", branch]);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Create a new local branch. `startPoint` defaults to HEAD; pass
|
|
574
|
+
* `origin/main` (etc.) to branch off a tracking ref. When `checkout`
|
|
575
|
+
* is true, uses `git checkout -b` to create + switch in one step.
|
|
576
|
+
*/
|
|
577
|
+
export async function createBranch(cwd, name, opts = {}) {
|
|
578
|
+
assertBranchName(name);
|
|
579
|
+
if (opts.startPoint !== undefined)
|
|
580
|
+
assertBranchName(opts.startPoint);
|
|
581
|
+
if (opts.checkout === true) {
|
|
582
|
+
const args = ["checkout", "-b", name];
|
|
583
|
+
if (opts.startPoint !== undefined)
|
|
584
|
+
args.push(opts.startPoint);
|
|
585
|
+
await runGit(cwd, args);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
const args = ["branch", name];
|
|
589
|
+
if (opts.startPoint !== undefined)
|
|
590
|
+
args.push(opts.startPoint);
|
|
591
|
+
await runGit(cwd, args);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Delete a local branch. Default uses `-d` (refuses to delete an
|
|
596
|
+
* unmerged branch); `force: true` switches to `-D`. Refuses to delete
|
|
597
|
+
* the currently-checked-out branch (git's default behavior surfaces a
|
|
598
|
+
* `GitCommandError`).
|
|
599
|
+
*/
|
|
600
|
+
export async function deleteBranch(cwd, name, opts = {}) {
|
|
601
|
+
assertBranchName(name);
|
|
602
|
+
await runGit(cwd, ["branch", opts.force === true ? "-D" : "-d", name]);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* `git fetch [<remote>]` — never touches the working tree, so safe to
|
|
606
|
+
* call regardless of dirty state. Returns the captured output (mostly
|
|
607
|
+
* stderr — git's "Fetching origin\nFrom github.com:foo/bar..." text).
|
|
608
|
+
*/
|
|
609
|
+
export async function fetch(cwd, opts = {}) {
|
|
610
|
+
const args = ["fetch"];
|
|
611
|
+
if (opts.prune === true)
|
|
612
|
+
args.push("--prune");
|
|
613
|
+
if (opts.remote !== undefined) {
|
|
614
|
+
assertRemoteName(opts.remote);
|
|
615
|
+
args.push(opts.remote);
|
|
616
|
+
}
|
|
617
|
+
const { stdout, stderr } = await runGit(cwd, args);
|
|
618
|
+
return { stdout: stdout.length > 0 ? stdout : stderr };
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* `git pull [<remote> [<branch>]]` — fetches AND merges (or rebases).
|
|
622
|
+
* Conflicts are NOT resolved by us — the underlying GitCommandError's
|
|
623
|
+
* stderr is surfaced verbatim (e.g. "CONFLICT (content): Merge
|
|
624
|
+
* conflict in foo.ts") so the user can drop to the integrated
|
|
625
|
+
* terminal to fix.
|
|
626
|
+
*
|
|
627
|
+
* Argument grammar: the second positional is a branch ONLY when the
|
|
628
|
+
* first positional is also given. `git pull <name>` is interpreted
|
|
629
|
+
* as a remote, not a branch — so when the caller passes only `branch`
|
|
630
|
+
* we default `remote` to `"origin"` rather than producing a
|
|
631
|
+
* misleading "remote not found" error.
|
|
632
|
+
*/
|
|
633
|
+
export async function pull(cwd, opts = {}) {
|
|
634
|
+
const args = ["pull"];
|
|
635
|
+
if (opts.rebase === true)
|
|
636
|
+
args.push("--rebase");
|
|
637
|
+
const remote = opts.remote ?? (opts.branch !== undefined ? "origin" : undefined);
|
|
638
|
+
if (remote !== undefined) {
|
|
639
|
+
assertRemoteName(remote);
|
|
640
|
+
args.push(remote);
|
|
641
|
+
}
|
|
642
|
+
if (opts.branch !== undefined) {
|
|
643
|
+
assertBranchName(opts.branch);
|
|
644
|
+
args.push(opts.branch);
|
|
645
|
+
}
|
|
646
|
+
const { stdout, stderr } = await runGit(cwd, args);
|
|
647
|
+
return { stdout: stdout.length > 0 ? stdout : stderr };
|
|
648
|
+
}
|
|
649
|
+
export async function push(cwd, opts = {}) {
|
|
650
|
+
const args = ["push"];
|
|
651
|
+
if (opts.setUpstream === true)
|
|
652
|
+
args.push("--set-upstream");
|
|
653
|
+
if (opts.remote !== undefined) {
|
|
654
|
+
assertRemoteName(opts.remote);
|
|
655
|
+
args.push(opts.remote);
|
|
656
|
+
}
|
|
657
|
+
if (opts.branch !== undefined) {
|
|
658
|
+
assertBranchName(opts.branch);
|
|
659
|
+
args.push(opts.branch);
|
|
660
|
+
}
|
|
661
|
+
// Push status info goes to stderr by default; we capture both.
|
|
662
|
+
const { stdout, stderr } = await runGit(cwd, args);
|
|
663
|
+
return { stdout: stdout.length > 0 ? stdout : stderr };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Validate a git remote name. Rules are looser than branch names:
|
|
667
|
+
* remotes don't reserve `HEAD`/`FETCH_HEAD`/etc., and the `.lock`
|
|
668
|
+
* suffix only matters for ref files. We keep the same character
|
|
669
|
+
* set + leading-dash + traversal guards (the security-relevant
|
|
670
|
+
* ones), but skip the ref-reserved-word and `.lock`/dot-segment
|
|
671
|
+
* checks. A user with a remote literally named `HEAD` (unusual but
|
|
672
|
+
* legal) won't get a 400.
|
|
673
|
+
*/
|
|
674
|
+
function assertRemoteName(name) {
|
|
675
|
+
if (name.length === 0 || name.length > 200)
|
|
676
|
+
throw new InvalidBranchNameError(name);
|
|
677
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(name))
|
|
678
|
+
throw new InvalidBranchNameError(name);
|
|
679
|
+
if (name.startsWith("-"))
|
|
680
|
+
throw new InvalidBranchNameError(name);
|
|
681
|
+
if (name.includes("..") || name.includes("@{"))
|
|
682
|
+
throw new InvalidBranchNameError(name);
|
|
683
|
+
}
|
|
684
|
+
//# sourceMappingURL=git-runner.js.map
|