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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -4
  3. package/bin/pi-forge.mjs +37 -0
  4. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
  5. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
  6. package/dist/client/assets/index-B-529kgJ.css +32 -0
  7. package/dist/client/assets/index-BzKzxXFs.js +392 -0
  8. package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
  9. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
  10. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
  11. package/dist/client/icons/icon-192.png +0 -0
  12. package/dist/client/icons/icon-512.png +0 -0
  13. package/dist/client/icons/icon-maskable-512.png +0 -0
  14. package/dist/client/icons/icon.svg +9 -0
  15. package/dist/client/index.html +24 -0
  16. package/dist/client/manifest.webmanifest +1 -0
  17. package/dist/client/offline.html +142 -0
  18. package/dist/client/sw.js +3 -0
  19. package/dist/client/sw.js.map +1 -0
  20. package/dist/client/workbox-6d7155ed.js +3 -0
  21. package/dist/client/workbox-6d7155ed.js.map +1 -0
  22. package/dist/server/agent-resource-loader.js +126 -0
  23. package/dist/server/agent-resource-loader.js.map +1 -0
  24. package/dist/server/attachment-converters.js +96 -0
  25. package/dist/server/attachment-converters.js.map +1 -0
  26. package/dist/server/auth.js +209 -0
  27. package/dist/server/auth.js.map +1 -0
  28. package/dist/server/compaction-history.js +106 -0
  29. package/dist/server/compaction-history.js.map +1 -0
  30. package/dist/server/concurrency.js +49 -0
  31. package/dist/server/concurrency.js.map +1 -0
  32. package/dist/server/config-export.js +220 -0
  33. package/dist/server/config-export.js.map +1 -0
  34. package/dist/server/config-manager.js +528 -0
  35. package/dist/server/config-manager.js.map +1 -0
  36. package/dist/server/config.js +326 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/conversion-worker.mjs +90 -0
  39. package/dist/server/diagnostics.js +137 -0
  40. package/dist/server/diagnostics.js.map +1 -0
  41. package/dist/server/extensions-discovery.js +147 -0
  42. package/dist/server/extensions-discovery.js.map +1 -0
  43. package/dist/server/file-manager.js +734 -0
  44. package/dist/server/file-manager.js.map +1 -0
  45. package/dist/server/file-references.js +215 -0
  46. package/dist/server/file-references.js.map +1 -0
  47. package/dist/server/file-searcher.js +385 -0
  48. package/dist/server/file-searcher.js.map +1 -0
  49. package/dist/server/git-runner.js +684 -0
  50. package/dist/server/git-runner.js.map +1 -0
  51. package/dist/server/index.js +468 -0
  52. package/dist/server/index.js.map +1 -0
  53. package/dist/server/mcp/config.js +133 -0
  54. package/dist/server/mcp/config.js.map +1 -0
  55. package/dist/server/mcp/manager.js +351 -0
  56. package/dist/server/mcp/manager.js.map +1 -0
  57. package/dist/server/mcp/tool-bridge.js +173 -0
  58. package/dist/server/mcp/tool-bridge.js.map +1 -0
  59. package/dist/server/project-manager.js +301 -0
  60. package/dist/server/project-manager.js.map +1 -0
  61. package/dist/server/pty-manager.js +354 -0
  62. package/dist/server/pty-manager.js.map +1 -0
  63. package/dist/server/routes/_schemas.js +73 -0
  64. package/dist/server/routes/_schemas.js.map +1 -0
  65. package/dist/server/routes/auth.js +164 -0
  66. package/dist/server/routes/auth.js.map +1 -0
  67. package/dist/server/routes/config.js +1163 -0
  68. package/dist/server/routes/config.js.map +1 -0
  69. package/dist/server/routes/control.js +464 -0
  70. package/dist/server/routes/control.js.map +1 -0
  71. package/dist/server/routes/exec.js +217 -0
  72. package/dist/server/routes/exec.js.map +1 -0
  73. package/dist/server/routes/files.js +847 -0
  74. package/dist/server/routes/files.js.map +1 -0
  75. package/dist/server/routes/git.js +837 -0
  76. package/dist/server/routes/git.js.map +1 -0
  77. package/dist/server/routes/health.js +97 -0
  78. package/dist/server/routes/health.js.map +1 -0
  79. package/dist/server/routes/mcp.js +300 -0
  80. package/dist/server/routes/mcp.js.map +1 -0
  81. package/dist/server/routes/projects.js +259 -0
  82. package/dist/server/routes/projects.js.map +1 -0
  83. package/dist/server/routes/prompt.js +496 -0
  84. package/dist/server/routes/prompt.js.map +1 -0
  85. package/dist/server/routes/sessions.js +783 -0
  86. package/dist/server/routes/sessions.js.map +1 -0
  87. package/dist/server/routes/stream.js +69 -0
  88. package/dist/server/routes/stream.js.map +1 -0
  89. package/dist/server/routes/terminal.js +335 -0
  90. package/dist/server/routes/terminal.js.map +1 -0
  91. package/dist/server/session-registry.js +1197 -0
  92. package/dist/server/session-registry.js.map +1 -0
  93. package/dist/server/skill-overrides.js +151 -0
  94. package/dist/server/skill-overrides.js.map +1 -0
  95. package/dist/server/skills-export.js +257 -0
  96. package/dist/server/skills-export.js.map +1 -0
  97. package/dist/server/sse-bridge.js +220 -0
  98. package/dist/server/sse-bridge.js.map +1 -0
  99. package/dist/server/tool-overrides.js +277 -0
  100. package/dist/server/tool-overrides.js.map +1 -0
  101. package/dist/server/turn-diff-builder.js +280 -0
  102. package/dist/server/turn-diff-builder.js.map +1 -0
  103. 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