@scelar/nodepod 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +43 -0
  2. package/README.md +240 -0
  3. package/dist/child_process-BJOMsZje.js +8233 -0
  4. package/dist/child_process-BJOMsZje.js.map +1 -0
  5. package/dist/child_process-Cj8vOcuc.cjs +7434 -0
  6. package/dist/child_process-Cj8vOcuc.cjs.map +1 -0
  7. package/dist/index-Cb1Cgdnd.js +35308 -0
  8. package/dist/index-Cb1Cgdnd.js.map +1 -0
  9. package/dist/index-DsMGS-xc.cjs +37195 -0
  10. package/dist/index-DsMGS-xc.cjs.map +1 -0
  11. package/dist/index.cjs +65 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.mjs +59 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +95 -0
  16. package/src/__tests__/smoke.test.ts +11 -0
  17. package/src/constants/cdn-urls.ts +18 -0
  18. package/src/constants/config.ts +236 -0
  19. package/src/cross-origin.ts +26 -0
  20. package/src/engine-factory.ts +176 -0
  21. package/src/engine-types.ts +56 -0
  22. package/src/helpers/byte-encoding.ts +39 -0
  23. package/src/helpers/digest.ts +9 -0
  24. package/src/helpers/event-loop.ts +96 -0
  25. package/src/helpers/wasm-cache.ts +133 -0
  26. package/src/iframe-sandbox.ts +141 -0
  27. package/src/index.ts +192 -0
  28. package/src/isolation-helpers.ts +148 -0
  29. package/src/memory-volume.ts +941 -0
  30. package/src/module-transformer.ts +368 -0
  31. package/src/packages/archive-extractor.ts +248 -0
  32. package/src/packages/browser-bundler.ts +284 -0
  33. package/src/packages/installer.ts +396 -0
  34. package/src/packages/registry-client.ts +131 -0
  35. package/src/packages/version-resolver.ts +411 -0
  36. package/src/polyfills/assert.ts +384 -0
  37. package/src/polyfills/async_hooks.ts +144 -0
  38. package/src/polyfills/buffer.ts +628 -0
  39. package/src/polyfills/child_process.ts +2288 -0
  40. package/src/polyfills/chokidar.ts +336 -0
  41. package/src/polyfills/cluster.ts +106 -0
  42. package/src/polyfills/console.ts +136 -0
  43. package/src/polyfills/constants.ts +123 -0
  44. package/src/polyfills/crypto.ts +885 -0
  45. package/src/polyfills/dgram.ts +87 -0
  46. package/src/polyfills/diagnostics_channel.ts +76 -0
  47. package/src/polyfills/dns.ts +134 -0
  48. package/src/polyfills/domain.ts +68 -0
  49. package/src/polyfills/esbuild.ts +854 -0
  50. package/src/polyfills/events.ts +276 -0
  51. package/src/polyfills/fs.ts +2888 -0
  52. package/src/polyfills/fsevents.ts +79 -0
  53. package/src/polyfills/http.ts +1449 -0
  54. package/src/polyfills/http2.ts +199 -0
  55. package/src/polyfills/https.ts +76 -0
  56. package/src/polyfills/inspector.ts +62 -0
  57. package/src/polyfills/lightningcss.ts +105 -0
  58. package/src/polyfills/module.ts +191 -0
  59. package/src/polyfills/net.ts +353 -0
  60. package/src/polyfills/os.ts +238 -0
  61. package/src/polyfills/path.ts +206 -0
  62. package/src/polyfills/perf_hooks.ts +102 -0
  63. package/src/polyfills/process.ts +690 -0
  64. package/src/polyfills/punycode.ts +159 -0
  65. package/src/polyfills/querystring.ts +93 -0
  66. package/src/polyfills/quic.ts +118 -0
  67. package/src/polyfills/readdirp.ts +229 -0
  68. package/src/polyfills/readline.ts +692 -0
  69. package/src/polyfills/repl.ts +134 -0
  70. package/src/polyfills/rollup.ts +119 -0
  71. package/src/polyfills/sea.ts +33 -0
  72. package/src/polyfills/sqlite.ts +78 -0
  73. package/src/polyfills/stream.ts +1620 -0
  74. package/src/polyfills/string_decoder.ts +25 -0
  75. package/src/polyfills/tailwindcss-oxide.ts +309 -0
  76. package/src/polyfills/test.ts +197 -0
  77. package/src/polyfills/timers.ts +32 -0
  78. package/src/polyfills/tls.ts +105 -0
  79. package/src/polyfills/trace_events.ts +50 -0
  80. package/src/polyfills/tty.ts +71 -0
  81. package/src/polyfills/url.ts +174 -0
  82. package/src/polyfills/util.ts +559 -0
  83. package/src/polyfills/v8.ts +126 -0
  84. package/src/polyfills/vm.ts +132 -0
  85. package/src/polyfills/volume-registry.ts +15 -0
  86. package/src/polyfills/wasi.ts +44 -0
  87. package/src/polyfills/worker_threads.ts +326 -0
  88. package/src/polyfills/ws.ts +595 -0
  89. package/src/polyfills/zlib.ts +881 -0
  90. package/src/request-proxy.ts +716 -0
  91. package/src/script-engine.ts +3375 -0
  92. package/src/sdk/nodepod-fs.ts +93 -0
  93. package/src/sdk/nodepod-process.ts +86 -0
  94. package/src/sdk/nodepod-terminal.ts +350 -0
  95. package/src/sdk/nodepod.ts +509 -0
  96. package/src/sdk/types.ts +70 -0
  97. package/src/shell/commands/bun.ts +121 -0
  98. package/src/shell/commands/directory.ts +297 -0
  99. package/src/shell/commands/file-ops.ts +525 -0
  100. package/src/shell/commands/git.ts +2142 -0
  101. package/src/shell/commands/node.ts +80 -0
  102. package/src/shell/commands/npm.ts +198 -0
  103. package/src/shell/commands/pm-types.ts +45 -0
  104. package/src/shell/commands/pnpm.ts +82 -0
  105. package/src/shell/commands/search.ts +264 -0
  106. package/src/shell/commands/shell-env.ts +352 -0
  107. package/src/shell/commands/text-processing.ts +1152 -0
  108. package/src/shell/commands/yarn.ts +84 -0
  109. package/src/shell/shell-builtins.ts +19 -0
  110. package/src/shell/shell-helpers.ts +250 -0
  111. package/src/shell/shell-interpreter.ts +514 -0
  112. package/src/shell/shell-parser.ts +429 -0
  113. package/src/shell/shell-types.ts +85 -0
  114. package/src/syntax-transforms.ts +561 -0
  115. package/src/threading/engine-worker.ts +64 -0
  116. package/src/threading/inline-worker.ts +372 -0
  117. package/src/threading/offload-types.ts +112 -0
  118. package/src/threading/offload-worker.ts +383 -0
  119. package/src/threading/offload.ts +271 -0
  120. package/src/threading/process-context.ts +92 -0
  121. package/src/threading/process-handle.ts +275 -0
  122. package/src/threading/process-manager.ts +956 -0
  123. package/src/threading/process-worker-entry.ts +854 -0
  124. package/src/threading/shared-vfs.ts +352 -0
  125. package/src/threading/sync-channel.ts +135 -0
  126. package/src/threading/task-queue.ts +177 -0
  127. package/src/threading/vfs-bridge.ts +231 -0
  128. package/src/threading/worker-pool.ts +233 -0
  129. package/src/threading/worker-protocol.ts +358 -0
  130. package/src/threading/worker-vfs.ts +218 -0
  131. package/src/types/externals.d.ts +38 -0
  132. package/src/types/fs-streams.ts +142 -0
  133. package/src/types/manifest.ts +17 -0
  134. package/src/worker-sandbox.ts +90 -0
@@ -0,0 +1,2142 @@
1
+ // Virtual git backed by the VFS (.git/ directories).
2
+ // GitHub API support when GITHUB_TOKEN is set.
3
+
4
+ import type { ShellCommand, ShellContext, ShellResult } from "../shell-types";
5
+ import type { MemoryVolume } from "../../memory-volume";
6
+ import { ok, fail, RESET, DIM, GREEN, BOLD_RED, CYAN } from "../shell-helpers";
7
+ import { VERSIONS } from "../../constants/config";
8
+ import { proxiedFetch } from "../../cross-origin";
9
+ import * as pathModule from "../../polyfills/path";
10
+ import { createHash } from "../../polyfills/crypto";
11
+
12
+ /* ------------------------------------------------------------------ */
13
+ /* ANSI helpers */
14
+ /* ------------------------------------------------------------------ */
15
+
16
+ const RED = "\x1b[31m";
17
+ const YELLOW = "\x1b[33m";
18
+ const BOLD = "\x1b[1m";
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* Types */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ interface IndexEntry {
25
+ path: string;
26
+ hash: string;
27
+ mode: number;
28
+ mtime: number;
29
+ }
30
+
31
+ interface CommitData {
32
+ tree: string;
33
+ parent: string | null;
34
+ parent2?: string | null;
35
+ author: string;
36
+ committer: string;
37
+ timestamp: number;
38
+ message: string;
39
+ }
40
+
41
+ interface TreeEntry {
42
+ name: string;
43
+ mode: string;
44
+ type: "blob" | "tree";
45
+ hash: string;
46
+ }
47
+
48
+ interface DiffEntry {
49
+ path: string;
50
+ status: "added" | "modified" | "deleted";
51
+ oldContent?: string;
52
+ newContent?: string;
53
+ }
54
+
55
+ interface EditOp {
56
+ kind: "equal" | "insert" | "delete";
57
+ oldIdx: number; // 0-based, -1 for inserts
58
+ newIdx: number; // 0-based, -1 for deletes
59
+ line: string;
60
+ }
61
+
62
+ interface DiffHunk {
63
+ oldStart: number; // 1-based
64
+ oldCount: number;
65
+ newStart: number; // 1-based
66
+ newCount: number;
67
+ lines: EditOp[];
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ */
71
+ /* Myers diff algorithm */
72
+ /* ------------------------------------------------------------------ */
73
+
74
+ // Myers O(ND) diff with backtracking
75
+ function myersDiff(oldLines: string[], newLines: string[]): EditOp[] {
76
+ const N = oldLines.length;
77
+ const M = newLines.length;
78
+ const MAX = N + M;
79
+
80
+ if (MAX === 0) return [];
81
+
82
+ const vHistory: Map<number, number>[] = [];
83
+ const v = new Map<number, number>();
84
+ v.set(1, 0);
85
+
86
+ let foundD = -1;
87
+
88
+ outer:
89
+ for (let d = 0; d <= MAX; d++) {
90
+ vHistory.push(new Map(v));
91
+
92
+ for (let k = -d; k <= d; k += 2) {
93
+ let x: number;
94
+ if (k === -d || (k !== d && (v.get(k - 1) ?? 0) < (v.get(k + 1) ?? 0))) {
95
+ x = v.get(k + 1) ?? 0;
96
+ } else {
97
+ x = (v.get(k - 1) ?? 0) + 1;
98
+ }
99
+ let y = x - k;
100
+
101
+ while (x < N && y < M && oldLines[x] === newLines[y]) {
102
+ x++;
103
+ y++;
104
+ }
105
+
106
+ v.set(k, x);
107
+
108
+ if (x >= N && y >= M) {
109
+ foundD = d;
110
+ break outer;
111
+ }
112
+ }
113
+ }
114
+
115
+ if (foundD < 0) foundD = MAX;
116
+
117
+ let x = N;
118
+ let y = M;
119
+ const ops: EditOp[] = [];
120
+
121
+ for (let d = foundD; d > 0; d--) {
122
+ const vPrev = vHistory[d];
123
+ const k = x - y;
124
+ let prevK: number;
125
+
126
+ if (k === -d || (k !== d && (vPrev.get(k - 1) ?? 0) < (vPrev.get(k + 1) ?? 0))) {
127
+ prevK = k + 1;
128
+ } else {
129
+ prevK = k - 1;
130
+ }
131
+
132
+ const prevX = vPrev.get(prevK) ?? 0;
133
+ const prevY = prevX - prevK;
134
+
135
+ while (x > prevX && y > prevY) {
136
+ x--;
137
+ y--;
138
+ ops.push({ kind: "equal", oldIdx: x, newIdx: y, line: oldLines[x] });
139
+ }
140
+
141
+ if (d > 0) {
142
+ if (x === prevX) {
143
+ y--;
144
+ ops.push({ kind: "insert", oldIdx: -1, newIdx: y, line: newLines[y] });
145
+ } else {
146
+ x--;
147
+ ops.push({ kind: "delete", oldIdx: x, newIdx: -1, line: oldLines[x] });
148
+ }
149
+ }
150
+ }
151
+
152
+ while (x > 0 && y > 0) {
153
+ x--;
154
+ y--;
155
+ ops.push({ kind: "equal", oldIdx: x, newIdx: y, line: oldLines[x] });
156
+ }
157
+
158
+ ops.reverse();
159
+ return ops;
160
+ }
161
+
162
+ function buildHunks(ops: EditOp[], contextLines = 3): DiffHunk[] {
163
+ const hunks: DiffHunk[] = [];
164
+ if (ops.length === 0) return hunks;
165
+
166
+ const changeIndices: number[] = [];
167
+ for (let i = 0; i < ops.length; i++) {
168
+ if (ops[i].kind !== "equal") changeIndices.push(i);
169
+ }
170
+ if (changeIndices.length === 0) return hunks;
171
+
172
+ let groupStart = changeIndices[0];
173
+ let groupEnd = changeIndices[0];
174
+
175
+ const groups: Array<[number, number]> = [];
176
+
177
+ for (let i = 1; i < changeIndices.length; i++) {
178
+ if (changeIndices[i] - groupEnd <= contextLines * 2) {
179
+ groupEnd = changeIndices[i];
180
+ } else {
181
+ groups.push([groupStart, groupEnd]);
182
+ groupStart = changeIndices[i];
183
+ groupEnd = changeIndices[i];
184
+ }
185
+ }
186
+ groups.push([groupStart, groupEnd]);
187
+
188
+ for (const [gStart, gEnd] of groups) {
189
+ const hunkOpsStart = Math.max(0, gStart - contextLines);
190
+ const hunkOpsEnd = Math.min(ops.length - 1, gEnd + contextLines);
191
+
192
+ const hunkOps = ops.slice(hunkOpsStart, hunkOpsEnd + 1);
193
+
194
+ let oldStart = Infinity;
195
+ let newStart = Infinity;
196
+ let oldCount = 0;
197
+ let newCount = 0;
198
+
199
+ for (const op of hunkOps) {
200
+ if (op.kind === "equal") {
201
+ if (op.oldIdx < oldStart) oldStart = op.oldIdx;
202
+ if (op.newIdx < newStart) newStart = op.newIdx;
203
+ oldCount++;
204
+ newCount++;
205
+ } else if (op.kind === "delete") {
206
+ if (op.oldIdx < oldStart) oldStart = op.oldIdx;
207
+ oldCount++;
208
+ } else {
209
+ if (op.newIdx < newStart) newStart = op.newIdx;
210
+ newCount++;
211
+ }
212
+ }
213
+
214
+ hunks.push({
215
+ oldStart: (oldStart === Infinity ? 0 : oldStart) + 1,
216
+ oldCount,
217
+ newStart: (newStart === Infinity ? 0 : newStart) + 1,
218
+ newCount,
219
+ lines: hunkOps,
220
+ });
221
+ }
222
+
223
+ return hunks;
224
+ }
225
+
226
+ function countChanges(ops: EditOp[]): { insertions: number; deletions: number } {
227
+ let insertions = 0;
228
+ let deletions = 0;
229
+ for (const op of ops) {
230
+ if (op.kind === "insert") insertions++;
231
+ else if (op.kind === "delete") deletions++;
232
+ }
233
+ return { insertions, deletions };
234
+ }
235
+
236
+ interface StashEntry {
237
+ message: string;
238
+ commitHash: string;
239
+ }
240
+
241
+ /* ------------------------------------------------------------------ */
242
+ /* GitRepo — core operations against the VFS */
243
+ /* ------------------------------------------------------------------ */
244
+
245
+ class GitRepo {
246
+ private vol: MemoryVolume;
247
+ readonly gitDir: string;
248
+ readonly workDir: string;
249
+
250
+ constructor(vol: MemoryVolume, workDir: string, gitDir: string) {
251
+ this.vol = vol;
252
+ this.workDir = workDir;
253
+ this.gitDir = gitDir;
254
+ }
255
+
256
+ /* -- object store -- */
257
+
258
+ private readStore(): Record<string, { type: string; data: string }> {
259
+ try {
260
+ const raw = this.vol.readFileSync(this.gitDir + "/objects/store.json", "utf8" as any) as string;
261
+ return JSON.parse(raw);
262
+ } catch {
263
+ return {};
264
+ }
265
+ }
266
+
267
+ private writeStore(store: Record<string, { type: string; data: string }>): void {
268
+ this.vol.writeFileSync(this.gitDir + "/objects/store.json", JSON.stringify(store));
269
+ }
270
+
271
+ hashContent(type: string, content: string): string {
272
+ const header = `${type} ${content.length}\0`;
273
+ return createHash("sha1").update(header + content).digest("hex") as string;
274
+ }
275
+
276
+ writeObject(type: string, content: string): string {
277
+ const hash = this.hashContent(type, content);
278
+ const store = this.readStore();
279
+ if (!store[hash]) {
280
+ store[hash] = { type, data: content };
281
+ this.writeStore(store);
282
+ }
283
+ return hash;
284
+ }
285
+
286
+ readObject(hash: string): { type: string; data: string } | null {
287
+ const store = this.readStore();
288
+ return store[hash] ?? null;
289
+ }
290
+
291
+ /* -- index (staging area) -- */
292
+
293
+ readIndex(): IndexEntry[] {
294
+ try {
295
+ const raw = this.vol.readFileSync(this.gitDir + "/index", "utf8" as any) as string;
296
+ return JSON.parse(raw).entries ?? [];
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+
302
+ writeIndex(entries: IndexEntry[]): void {
303
+ this.vol.writeFileSync(this.gitDir + "/index", JSON.stringify({ entries }));
304
+ }
305
+
306
+ addToIndex(relPath: string, content: string): void {
307
+ const hash = this.writeObject("blob", content);
308
+ const entries = this.readIndex();
309
+ const idx = entries.findIndex((e) => e.path === relPath);
310
+ const entry: IndexEntry = { path: relPath, hash, mode: 100644, mtime: Date.now() };
311
+ if (idx >= 0) entries[idx] = entry;
312
+ else entries.push(entry);
313
+ entries.sort((a, b) => a.path.localeCompare(b.path));
314
+ this.writeIndex(entries);
315
+ }
316
+
317
+ removeFromIndex(relPath: string): void {
318
+ const entries = this.readIndex().filter((e) => e.path !== relPath);
319
+ this.writeIndex(entries);
320
+ }
321
+
322
+ /* -- refs -- */
323
+
324
+ getHEAD(): string {
325
+ try {
326
+ return (this.vol.readFileSync(this.gitDir + "/HEAD", "utf8" as any) as string).trim();
327
+ } catch {
328
+ return "ref: refs/heads/main";
329
+ }
330
+ }
331
+
332
+ setHEAD(value: string): void {
333
+ this.vol.writeFileSync(this.gitDir + "/HEAD", value + "\n");
334
+ }
335
+
336
+ getCurrentBranch(): string | null {
337
+ const head = this.getHEAD();
338
+ if (head.startsWith("ref: refs/heads/")) return head.slice(16);
339
+ return null;
340
+ }
341
+
342
+ resolveRef(ref: string): string | null {
343
+ if (/^[0-9a-f]{40}$/.test(ref)) return ref;
344
+ if (ref.startsWith("ref: ")) {
345
+ const target = ref.slice(5);
346
+ try {
347
+ return (this.vol.readFileSync(this.gitDir + "/" + target, "utf8" as any) as string).trim();
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+ // try branch, then tag
353
+ try {
354
+ return (this.vol.readFileSync(this.gitDir + "/refs/heads/" + ref, "utf8" as any) as string).trim();
355
+ } catch { /* */ }
356
+ try {
357
+ return (this.vol.readFileSync(this.gitDir + "/refs/tags/" + ref, "utf8" as any) as string).trim();
358
+ } catch { /* */ }
359
+ return null;
360
+ }
361
+
362
+ resolveHEAD(): string | null {
363
+ return this.resolveRef(this.getHEAD());
364
+ }
365
+
366
+ updateBranchRef(branch: string, hash: string): void {
367
+ const refPath = this.gitDir + "/refs/heads/" + branch;
368
+ const dir = refPath.substring(0, refPath.lastIndexOf("/"));
369
+ if (!this.vol.existsSync(dir)) this.vol.mkdirSync(dir, { recursive: true });
370
+ this.vol.writeFileSync(refPath, hash + "\n");
371
+ }
372
+
373
+ listBranches(): string[] {
374
+ try {
375
+ return this.vol.readdirSync(this.gitDir + "/refs/heads") as string[];
376
+ } catch {
377
+ return [];
378
+ }
379
+ }
380
+
381
+ deleteBranch(name: string): boolean {
382
+ try {
383
+ const refPath = this.gitDir + "/refs/heads/" + name;
384
+ if (this.vol.existsSync(refPath)) {
385
+ this.vol.unlinkSync(refPath);
386
+ return true;
387
+ }
388
+ } catch { /* */ }
389
+ return false;
390
+ }
391
+
392
+ /* -- config -- */
393
+
394
+ readConfig(): string {
395
+ try {
396
+ return this.vol.readFileSync(this.gitDir + "/config", "utf8" as any) as string;
397
+ } catch {
398
+ return "";
399
+ }
400
+ }
401
+
402
+ writeConfig(content: string): void {
403
+ this.vol.writeFileSync(this.gitDir + "/config", content);
404
+ }
405
+
406
+ getConfigValue(key: string): string | null {
407
+ const config = this.readConfig();
408
+ const parts = key.split(".");
409
+ if (parts.length < 2) return null;
410
+
411
+ // e.g. remote.origin.url → [remote "origin"] / url
412
+ let sectionName: string;
413
+ let subSection: string | null = null;
414
+ let propName: string;
415
+
416
+ if (parts.length === 3) {
417
+ sectionName = parts[0];
418
+ subSection = parts[1];
419
+ propName = parts[2];
420
+ } else {
421
+ sectionName = parts[0];
422
+ propName = parts[1];
423
+ }
424
+
425
+ const lines = config.split("\n");
426
+ let inSection = false;
427
+ for (const line of lines) {
428
+ const trimmed = line.trim();
429
+ if (trimmed.startsWith("[")) {
430
+ if (subSection) {
431
+ const pat = `[${sectionName} "${subSection}"]`;
432
+ inSection = trimmed === pat;
433
+ } else {
434
+ inSection = trimmed === `[${sectionName}]`;
435
+ }
436
+ continue;
437
+ }
438
+ if (inSection) {
439
+ const match = trimmed.match(/^(\w+)\s*=\s*(.*)$/);
440
+ if (match && match[1] === propName) return match[2].trim();
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
446
+ setConfigValue(key: string, value: string): void {
447
+ const parts = key.split(".");
448
+ let sectionHeader: string;
449
+ let propName: string;
450
+
451
+ if (parts.length === 3) {
452
+ sectionHeader = `[${parts[0]} "${parts[1]}"]`;
453
+ propName = parts[2];
454
+ } else if (parts.length === 2) {
455
+ sectionHeader = `[${parts[0]}]`;
456
+ propName = parts[1];
457
+ } else {
458
+ return;
459
+ }
460
+
461
+ const config = this.readConfig();
462
+ const lines = config.split("\n");
463
+ let sectionIdx = -1;
464
+ let propIdx = -1;
465
+ let inSection = false;
466
+ let lastLineInSection = -1;
467
+
468
+ for (let i = 0; i < lines.length; i++) {
469
+ const trimmed = lines[i].trim();
470
+ if (trimmed.startsWith("[")) {
471
+ if (inSection && propIdx === -1) lastLineInSection = i - 1;
472
+ inSection = trimmed === sectionHeader;
473
+ if (inSection) sectionIdx = i;
474
+ continue;
475
+ }
476
+ if (inSection) {
477
+ lastLineInSection = i;
478
+ const match = trimmed.match(/^(\w+)\s*=\s*(.*)$/);
479
+ if (match && match[1] === propName) propIdx = i;
480
+ }
481
+ }
482
+ if (inSection && lastLineInSection === -1) lastLineInSection = sectionIdx;
483
+
484
+ if (propIdx >= 0) {
485
+ lines[propIdx] = `\t${propName} = ${value}`;
486
+ } else if (sectionIdx >= 0) {
487
+ lines.splice(lastLineInSection + 1, 0, `\t${propName} = ${value}`);
488
+ } else {
489
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
490
+ lines.push(sectionHeader);
491
+ lines.push(`\t${propName} = ${value}`);
492
+ }
493
+
494
+ this.writeConfig(lines.join("\n"));
495
+ }
496
+
497
+ /* -- tree building -- */
498
+
499
+ buildTree(entries: IndexEntry[]): string {
500
+ const root: TreeEntry[] = [];
501
+ const subdirs = new Map<string, IndexEntry[]>();
502
+
503
+ for (const e of entries) {
504
+ const slashIdx = e.path.indexOf("/");
505
+ if (slashIdx === -1) {
506
+ root.push({ name: e.path, mode: String(e.mode), type: "blob", hash: e.hash });
507
+ } else {
508
+ const dir = e.path.substring(0, slashIdx);
509
+ const rest: IndexEntry = { ...e, path: e.path.substring(slashIdx + 1) };
510
+ if (!subdirs.has(dir)) subdirs.set(dir, []);
511
+ subdirs.get(dir)!.push(rest);
512
+ }
513
+ }
514
+
515
+ for (const [dir, subEntries] of subdirs) {
516
+ const treeHash = this.buildTree(subEntries);
517
+ root.push({ name: dir, mode: "40000", type: "tree", hash: treeHash });
518
+ }
519
+
520
+ root.sort((a, b) => a.name.localeCompare(b.name));
521
+ const treeContent = JSON.stringify(root);
522
+ return this.writeObject("tree", treeContent);
523
+ }
524
+
525
+ /* -- commits -- */
526
+
527
+ createCommit(message: string, parent: string | null, tree: string, parent2?: string | null): string {
528
+ const author = `${this.getConfigValue("user.name") ?? "nodepod-user"} <${this.getConfigValue("user.email") ?? "user@nodepod.dev"}>`;
529
+ const data: CommitData = {
530
+ tree,
531
+ parent,
532
+ author,
533
+ committer: author,
534
+ timestamp: Date.now(),
535
+ message,
536
+ };
537
+ if (parent2) data.parent2 = parent2;
538
+ return this.writeObject("commit", JSON.stringify(data));
539
+ }
540
+
541
+ readCommit(hash: string): CommitData | null {
542
+ const obj = this.readObject(hash);
543
+ if (!obj || obj.type !== "commit") return null;
544
+ return JSON.parse(obj.data);
545
+ }
546
+
547
+ walkLog(startHash: string | null, limit: number): Array<{ hash: string } & CommitData> {
548
+ const result: Array<{ hash: string } & CommitData> = [];
549
+ let current = startHash;
550
+ while (current && result.length < limit) {
551
+ const commit = this.readCommit(current);
552
+ if (!commit) break;
553
+ result.push({ hash: current, ...commit });
554
+ current = commit.parent;
555
+ }
556
+ return result;
557
+ }
558
+
559
+ /* -- working tree helpers -- */
560
+
561
+ getCommitTree(commitHash: string): Map<string, string> {
562
+ const commit = this.readCommit(commitHash);
563
+ if (!commit) return new Map();
564
+ return this.flattenTree(commit.tree, "");
565
+ }
566
+
567
+ private flattenTree(treeHash: string, prefix: string): Map<string, string> {
568
+ const obj = this.readObject(treeHash);
569
+ if (!obj || obj.type !== "tree") return new Map();
570
+ const entries: TreeEntry[] = JSON.parse(obj.data);
571
+ const result = new Map<string, string>();
572
+ for (const e of entries) {
573
+ const fullPath = prefix ? prefix + "/" + e.name : e.name;
574
+ if (e.type === "blob") {
575
+ result.set(fullPath, e.hash);
576
+ } else {
577
+ for (const [k, v] of this.flattenTree(e.hash, fullPath)) {
578
+ result.set(k, v);
579
+ }
580
+ }
581
+ }
582
+ return result;
583
+ }
584
+
585
+ getBlobContent(hash: string): string | null {
586
+ const obj = this.readObject(hash);
587
+ if (!obj || obj.type !== "blob") return null;
588
+ return obj.data;
589
+ }
590
+
591
+ /* -- diff helpers -- */
592
+
593
+ diffWorkingVsIndex(): DiffEntry[] {
594
+ const index = this.readIndex();
595
+ const indexMap = new Map(index.map((e) => [e.path, e.hash]));
596
+ const result: DiffEntry[] = [];
597
+ const seen = new Set<string>();
598
+
599
+ this.walkWorkTree(this.workDir, "", (relPath, content) => {
600
+ seen.add(relPath);
601
+ const currentHash = this.hashContent("blob", content);
602
+ const indexHash = indexMap.get(relPath);
603
+ if (!indexHash) {
604
+ // untracked, handled separately
605
+ } else if (currentHash !== indexHash) {
606
+ result.push({ path: relPath, status: "modified", oldContent: this.getBlobContent(indexHash) ?? "", newContent: content });
607
+ }
608
+ });
609
+
610
+ for (const e of index) {
611
+ if (!seen.has(e.path)) {
612
+ result.push({ path: e.path, status: "deleted", oldContent: this.getBlobContent(e.hash) ?? "" });
613
+ }
614
+ }
615
+
616
+ return result;
617
+ }
618
+
619
+ diffIndexVsHEAD(): DiffEntry[] {
620
+ const index = this.readIndex();
621
+ const headHash = this.resolveHEAD();
622
+ const headTree = headHash ? this.getCommitTree(headHash) : new Map<string, string>();
623
+ const result: DiffEntry[] = [];
624
+
625
+ const indexMap = new Map(index.map((e) => [e.path, e.hash]));
626
+
627
+ for (const e of index) {
628
+ const headBlobHash = headTree.get(e.path);
629
+ if (!headBlobHash) {
630
+ result.push({ path: e.path, status: "added" });
631
+ } else if (headBlobHash !== e.hash) {
632
+ result.push({ path: e.path, status: "modified" });
633
+ }
634
+ }
635
+
636
+ for (const [path] of headTree) {
637
+ if (!indexMap.has(path)) {
638
+ result.push({ path, status: "deleted" });
639
+ }
640
+ }
641
+
642
+ return result;
643
+ }
644
+
645
+ getUntrackedFiles(): string[] {
646
+ const index = this.readIndex();
647
+ const indexPaths = new Set(index.map((e) => e.path));
648
+ const untracked: string[] = [];
649
+
650
+ this.walkWorkTree(this.workDir, "", (relPath) => {
651
+ if (!indexPaths.has(relPath)) untracked.push(relPath);
652
+ });
653
+
654
+ return untracked.sort();
655
+ }
656
+
657
+ /* -- file tree walker -- */
658
+
659
+ walkWorkTree(dir: string, prefix: string, cb: (relPath: string, content: string) => void): void {
660
+ let entries: string[];
661
+ try {
662
+ entries = this.vol.readdirSync(dir) as string[];
663
+ } catch {
664
+ return;
665
+ }
666
+ for (const name of entries) {
667
+ if (name === ".git" || name === "node_modules") continue;
668
+ const fullPath = dir + "/" + name;
669
+ try {
670
+ const stat = this.vol.statSync(fullPath);
671
+ if (stat.isDirectory()) {
672
+ this.walkWorkTree(fullPath, prefix ? prefix + "/" + name : name, cb);
673
+ } else if (stat.isFile()) {
674
+ const relPath = prefix ? prefix + "/" + name : name;
675
+ const content = this.vol.readFileSync(fullPath, "utf8" as any) as string;
676
+ cb(relPath, content);
677
+ }
678
+ } catch { /* skip unreadable */ }
679
+ }
680
+ }
681
+
682
+ /* -- stash -- */
683
+
684
+ readStashList(): StashEntry[] {
685
+ try {
686
+ const raw = this.vol.readFileSync(this.gitDir + "/refs/stash", "utf8" as any) as string;
687
+ return JSON.parse(raw);
688
+ } catch {
689
+ return [];
690
+ }
691
+ }
692
+
693
+ writeStashList(list: StashEntry[]): void {
694
+ const dir = this.gitDir + "/refs";
695
+ if (!this.vol.existsSync(dir)) this.vol.mkdirSync(dir, { recursive: true });
696
+ this.vol.writeFileSync(this.gitDir + "/refs/stash", JSON.stringify(list));
697
+ }
698
+
699
+ /* -- remote helpers -- */
700
+
701
+ getRemoteUrl(name: string): string | null {
702
+ return this.getConfigValue(`remote.${name}.url`);
703
+ }
704
+
705
+ parseGitHubUrl(url: string): { owner: string; repo: string } | null {
706
+ let m = url.match(/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
707
+ if (m) return { owner: m[1], repo: m[2] };
708
+ m = url.match(/github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
709
+ if (m) return { owner: m[1], repo: m[2] };
710
+ return null;
711
+ }
712
+ }
713
+
714
+ /* ------------------------------------------------------------------ */
715
+ /* GitHub API helper */
716
+ /* ------------------------------------------------------------------ */
717
+
718
+ async function githubApi(
719
+ path: string,
720
+ token: string,
721
+ method = "GET",
722
+ body?: any,
723
+ ): Promise<{ ok: boolean; status: number; data: any }> {
724
+ const url = `https://api.github.com${path}`;
725
+ const headers: Record<string, string> = {
726
+ Accept: "application/vnd.github.v3+json",
727
+ Authorization: `token ${token}`,
728
+ "User-Agent": "nodepod-git",
729
+ };
730
+ if (body) headers["Content-Type"] = "application/json";
731
+
732
+ const resp = await proxiedFetch(url, {
733
+ method,
734
+ headers,
735
+ body: body ? JSON.stringify(body) : undefined,
736
+ });
737
+
738
+ let data: any;
739
+ try {
740
+ data = await resp.json();
741
+ } catch {
742
+ data = null;
743
+ }
744
+ return { ok: resp.ok, status: resp.status, data };
745
+ }
746
+
747
+ /* ------------------------------------------------------------------ */
748
+ /* Find .git directory */
749
+ /* ------------------------------------------------------------------ */
750
+
751
+ function findGitDir(vol: MemoryVolume, cwd: string): { gitDir: string; workDir: string } | null {
752
+ let dir = cwd;
753
+ while (true) {
754
+ const gitPath = dir + "/.git";
755
+ try {
756
+ if (vol.existsSync(gitPath)) return { gitDir: gitPath, workDir: dir };
757
+ } catch { /* */ }
758
+ const parent = dir.substring(0, dir.lastIndexOf("/")) || "/";
759
+ if (parent === dir) break;
760
+ dir = parent;
761
+ }
762
+ if (vol.existsSync("/.git")) return { gitDir: "/.git", workDir: "/" };
763
+ return null;
764
+ }
765
+
766
+ function requireRepo(vol: MemoryVolume, cwd: string): { repo: GitRepo } | { error: ShellResult } {
767
+ const found = findGitDir(vol, cwd);
768
+ if (!found) {
769
+ return { error: fail("fatal: not a git repository (or any of the parent directories): .git\n", 128) };
770
+ }
771
+ return { repo: new GitRepo(vol, found.workDir, found.gitDir) };
772
+ }
773
+
774
+ /* ------------------------------------------------------------------ */
775
+ /* Subcommand handlers */
776
+ /* ------------------------------------------------------------------ */
777
+
778
+ function gitInit(args: string[], ctx: ShellContext): ShellResult {
779
+ let target = ctx.cwd;
780
+ for (const a of args) {
781
+ if (!a.startsWith("-")) {
782
+ target = pathModule.resolve(ctx.cwd, a);
783
+ break;
784
+ }
785
+ }
786
+
787
+ const gitDir = target + "/.git";
788
+ if (ctx.volume.existsSync(gitDir)) {
789
+ return ok(`Reinitialized existing Git repository in ${gitDir}/\n`);
790
+ }
791
+
792
+ const dirs = [
793
+ gitDir,
794
+ gitDir + "/objects",
795
+ gitDir + "/refs",
796
+ gitDir + "/refs/heads",
797
+ gitDir + "/refs/tags",
798
+ ];
799
+ for (const d of dirs) {
800
+ if (!ctx.volume.existsSync(d)) ctx.volume.mkdirSync(d, { recursive: true });
801
+ }
802
+
803
+ ctx.volume.writeFileSync(gitDir + "/HEAD", "ref: refs/heads/main\n");
804
+ ctx.volume.writeFileSync(
805
+ gitDir + "/config",
806
+ "[core]\n\tbare = false\n\tfilemode = false\n[user]\n\tname = nodepod-user\n\temail = user@nodepod.dev\n",
807
+ );
808
+ ctx.volume.writeFileSync(gitDir + "/objects/store.json", "{}");
809
+ ctx.volume.writeFileSync(gitDir + "/index", '{"entries":[]}');
810
+
811
+ if (!ctx.volume.existsSync(target)) ctx.volume.mkdirSync(target, { recursive: true });
812
+
813
+ return ok(`Initialized empty Git repository in ${gitDir}/\n`);
814
+ }
815
+
816
+ function gitAdd(args: string[], ctx: ShellContext): ShellResult {
817
+ const r = requireRepo(ctx.volume, ctx.cwd);
818
+ if ("error" in r) return r.error;
819
+ const { repo } = r;
820
+
821
+ const addAll = args.includes("-A") || args.includes("--all") || args.includes(".");
822
+ const pathspecs = addAll ? [] : args.filter((a) => !a.startsWith("-"));
823
+
824
+ if (!addAll && pathspecs.length === 0) {
825
+ return fail("Nothing specified, nothing added.\n");
826
+ }
827
+
828
+ if (addAll) {
829
+ repo.walkWorkTree(repo.workDir, "", (relPath, content) => {
830
+ repo.addToIndex(relPath, content);
831
+ });
832
+ const index = repo.readIndex();
833
+ const toRemove: string[] = [];
834
+ for (const e of index) {
835
+ const fullPath = repo.workDir + "/" + e.path;
836
+ if (!ctx.volume.existsSync(fullPath)) toRemove.push(e.path);
837
+ }
838
+ for (const p of toRemove) repo.removeFromIndex(p);
839
+ } else {
840
+ for (const spec of pathspecs) {
841
+ const absPath = pathModule.resolve(ctx.cwd, spec);
842
+ const relPath = pathModule.relative(repo.workDir, absPath);
843
+ if (relPath.startsWith("..")) continue;
844
+
845
+ if (ctx.volume.existsSync(absPath)) {
846
+ try {
847
+ const stat = ctx.volume.statSync(absPath);
848
+ if (stat.isDirectory()) {
849
+ repo.walkWorkTree(absPath, relPath, (rp, content) => {
850
+ repo.addToIndex(rp, content);
851
+ });
852
+ } else {
853
+ const content = ctx.volume.readFileSync(absPath, "utf8" as any) as string;
854
+ repo.addToIndex(relPath, content);
855
+ }
856
+ } catch { /* skip */ }
857
+ } else {
858
+ repo.removeFromIndex(relPath);
859
+ }
860
+ }
861
+ }
862
+
863
+ return ok("");
864
+ }
865
+
866
+ function gitStatus(args: string[], ctx: ShellContext): ShellResult {
867
+ const r = requireRepo(ctx.volume, ctx.cwd);
868
+ if ("error" in r) return r.error;
869
+ const { repo } = r;
870
+
871
+ const short = args.includes("-s") || args.includes("--short");
872
+ const porcelain = args.includes("--porcelain");
873
+
874
+ const staged = repo.diffIndexVsHEAD();
875
+ const unstaged = repo.diffWorkingVsIndex();
876
+ const untracked = repo.getUntrackedFiles();
877
+
878
+ if (short || porcelain) {
879
+ let out = "";
880
+ for (const d of staged) {
881
+ const code = d.status === "added" ? "A" : d.status === "deleted" ? "D" : "M";
882
+ out += `${code} ${d.path}\n`;
883
+ }
884
+ for (const d of unstaged) {
885
+ const code = d.status === "deleted" ? "D" : "M";
886
+ out += ` ${code} ${d.path}\n`;
887
+ }
888
+ for (const p of untracked) {
889
+ out += `?? ${p}\n`;
890
+ }
891
+ return ok(out);
892
+ }
893
+
894
+ const branch = repo.getCurrentBranch() ?? "(HEAD detached)";
895
+ let out = `On branch ${branch}\n`;
896
+
897
+ if (staged.length === 0 && unstaged.length === 0 && untracked.length === 0) {
898
+ out += "nothing to commit, working tree clean\n";
899
+ return ok(out);
900
+ }
901
+
902
+ if (staged.length > 0) {
903
+ out += `\nChanges to be committed:\n`;
904
+ out += ` (use "git restore --staged <file>..." to unstage)\n`;
905
+ for (const d of staged) {
906
+ const label = d.status === "added" ? "new file" : d.status === "deleted" ? "deleted" : "modified";
907
+ out += `\t${GREEN}${label}: ${d.path}${RESET}\n`;
908
+ }
909
+ }
910
+
911
+ if (unstaged.length > 0) {
912
+ out += `\nChanges not staged for commit:\n`;
913
+ out += ` (use "git add <file>..." to update what will be committed)\n`;
914
+ for (const d of unstaged) {
915
+ const label = d.status === "deleted" ? "deleted" : "modified";
916
+ out += `\t${RED}${label}: ${d.path}${RESET}\n`;
917
+ }
918
+ }
919
+
920
+ if (untracked.length > 0) {
921
+ out += `\nUntracked files:\n`;
922
+ out += ` (use "git add <file>..." to include in what will be committed)\n`;
923
+ for (const p of untracked) {
924
+ out += `\t${RED}${p}${RESET}\n`;
925
+ }
926
+ }
927
+
928
+ return ok(out);
929
+ }
930
+
931
+ function gitCommit(args: string[], ctx: ShellContext): ShellResult {
932
+ const r = requireRepo(ctx.volume, ctx.cwd);
933
+ if ("error" in r) return r.error;
934
+ const { repo } = r;
935
+
936
+ let message: string | null = null;
937
+ let autoStage = false;
938
+ let allowEmpty = false;
939
+
940
+ for (let i = 0; i < args.length; i++) {
941
+ if (args[i] === "-m" || args[i] === "--message") {
942
+ message = args[++i] ?? "";
943
+ } else if (args[i] === "-a" || args[i] === "--all") {
944
+ autoStage = true;
945
+ } else if (args[i] === "--allow-empty") {
946
+ allowEmpty = true;
947
+ } else if (args[i] === "--allow-empty-message") {
948
+ /* */
949
+ } else if (args[i].startsWith("-m")) {
950
+ message = args[i].slice(2);
951
+ }
952
+ }
953
+
954
+ if (message === null) {
955
+ return fail("error: switch `m' requires a value\n");
956
+ }
957
+
958
+ if (autoStage) {
959
+ const index = repo.readIndex();
960
+ for (const e of index) {
961
+ const fullPath = repo.workDir + "/" + e.path;
962
+ if (ctx.volume.existsSync(fullPath)) {
963
+ try {
964
+ const content = ctx.volume.readFileSync(fullPath, "utf8" as any) as string;
965
+ repo.addToIndex(e.path, content);
966
+ } catch { /* */ }
967
+ } else {
968
+ repo.removeFromIndex(e.path);
969
+ }
970
+ }
971
+ }
972
+
973
+ const entries = repo.readIndex();
974
+ const staged = repo.diffIndexVsHEAD();
975
+
976
+ if (staged.length === 0 && !allowEmpty) {
977
+ return fail("nothing to commit, working tree clean\n");
978
+ }
979
+
980
+ const treeHash = repo.buildTree(entries);
981
+ const parent = repo.resolveHEAD();
982
+ const commitHash = repo.createCommit(message, parent, treeHash);
983
+
984
+ const branch = repo.getCurrentBranch();
985
+ if (branch) {
986
+ repo.updateBranchRef(branch, commitHash);
987
+ } else {
988
+ repo.setHEAD(commitHash);
989
+ }
990
+
991
+ const shortHash = commitHash.slice(0, 7);
992
+ const branchLabel = branch ?? "HEAD";
993
+ const isRoot = !parent;
994
+ const out = `[${branchLabel}${isRoot ? " (root-commit)" : ""} ${shortHash}] ${message}\n` +
995
+ ` ${staged.length} file${staged.length !== 1 ? "s" : ""} changed\n`;
996
+
997
+ return ok(out);
998
+ }
999
+
1000
+ function gitLog(args: string[], ctx: ShellContext): ShellResult {
1001
+ const r = requireRepo(ctx.volume, ctx.cwd);
1002
+ if ("error" in r) return r.error;
1003
+ const { repo } = r;
1004
+
1005
+ let limit = 50;
1006
+ let oneline = false;
1007
+ let format: string | null = null;
1008
+
1009
+ for (let i = 0; i < args.length; i++) {
1010
+ if (args[i] === "--oneline") {
1011
+ oneline = true;
1012
+ } else if (args[i] === "-n" || args[i] === "--max-count") {
1013
+ limit = parseInt(args[++i], 10) || 50;
1014
+ } else if (args[i].startsWith("-") && /^-\d+$/.test(args[i])) {
1015
+ limit = parseInt(args[i].slice(1), 10) || 50;
1016
+ } else if (args[i].startsWith("--format=")) {
1017
+ format = args[i].slice(9);
1018
+ } else if (args[i].startsWith("--pretty=format:")) {
1019
+ format = args[i].slice(16);
1020
+ } else if (args[i].startsWith("--pretty=")) {
1021
+ format = args[i].slice(9);
1022
+ }
1023
+ }
1024
+
1025
+ const head = repo.resolveHEAD();
1026
+ if (!head) return ok("");
1027
+
1028
+ const commits = repo.walkLog(head, limit);
1029
+ if (commits.length === 0) return ok("");
1030
+
1031
+ const currentBranch = repo.getCurrentBranch();
1032
+ let out = "";
1033
+
1034
+ for (const c of commits) {
1035
+ if (format !== null) {
1036
+ let line = format
1037
+ .replace(/%H/g, c.hash)
1038
+ .replace(/%h/g, c.hash.slice(0, 7))
1039
+ .replace(/%s/g, c.message.split("\n")[0])
1040
+ .replace(/%an/g, c.author.split(" <")[0])
1041
+ .replace(/%ae/g, (c.author.match(/<(.+?)>/) ?? ["", ""])[1])
1042
+ .replace(/%d/g, c.hash === head && currentBranch ? ` (HEAD -> ${currentBranch})` : "")
1043
+ .replace(/%n/g, "\n");
1044
+ out += line + "\n";
1045
+ } else if (oneline) {
1046
+ const decoration = c.hash === head && currentBranch ? ` ${YELLOW}(HEAD -> ${CYAN}${currentBranch}${YELLOW})${RESET}` : "";
1047
+ out += `${YELLOW}${c.hash.slice(0, 7)}${RESET}${decoration} ${c.message.split("\n")[0]}\n`;
1048
+ } else {
1049
+ const decoration = c.hash === head && currentBranch ? ` ${YELLOW}(HEAD -> ${CYAN}${currentBranch}${YELLOW})${RESET}` : "";
1050
+ out += `${YELLOW}commit ${c.hash}${RESET}${decoration}\n`;
1051
+ out += `Author: ${c.author}\n`;
1052
+ out += `Date: ${new Date(c.timestamp).toUTCString()}\n`;
1053
+ out += `\n ${c.message}\n\n`;
1054
+ }
1055
+ }
1056
+
1057
+ return ok(out);
1058
+ }
1059
+
1060
+ function gitDiff(args: string[], ctx: ShellContext): ShellResult {
1061
+ const r = requireRepo(ctx.volume, ctx.cwd);
1062
+ if ("error" in r) return r.error;
1063
+ const { repo } = r;
1064
+
1065
+ const staged = args.includes("--staged") || args.includes("--cached");
1066
+ const stat = args.includes("--stat");
1067
+
1068
+ let diffs: DiffEntry[];
1069
+ if (staged) {
1070
+ diffs = repo.diffIndexVsHEAD();
1071
+ const headHash = repo.resolveHEAD();
1072
+ const headTree = headHash ? repo.getCommitTree(headHash) : new Map<string, string>();
1073
+ const index = repo.readIndex();
1074
+ const indexMap = new Map(index.map((e) => [e.path, e.hash]));
1075
+ for (const d of diffs) {
1076
+ const oldHash = headTree.get(d.path);
1077
+ const newHash = indexMap.get(d.path);
1078
+ d.oldContent = oldHash ? repo.getBlobContent(oldHash) ?? "" : "";
1079
+ d.newContent = newHash ? repo.getBlobContent(newHash) ?? "" : "";
1080
+ }
1081
+ } else {
1082
+ diffs = repo.diffWorkingVsIndex();
1083
+ }
1084
+
1085
+ if (diffs.length === 0) return ok("");
1086
+
1087
+ if (stat) {
1088
+ let out = "";
1089
+ let totalIns = 0;
1090
+ let totalDel = 0;
1091
+ for (const d of diffs) {
1092
+ const oldLines = d.oldContent ? d.oldContent.split("\n") : [];
1093
+ const newLines = d.newContent ? d.newContent.split("\n") : [];
1094
+ const ops = myersDiff(oldLines, newLines);
1095
+ const { insertions: ins, deletions: del } = countChanges(ops);
1096
+ totalIns += ins;
1097
+ totalDel += del;
1098
+ out += ` ${d.path} | ${ins + del} ${GREEN}${"+"
1099
+ .repeat(ins)}${RED}${"-".repeat(del)}${RESET}\n`;
1100
+ }
1101
+ out += ` ${diffs.length} file${diffs.length !== 1 ? "s" : ""} changed`;
1102
+ if (totalIns > 0) out += `, ${totalIns} insertion${totalIns !== 1 ? "s" : ""}(+)`;
1103
+ if (totalDel > 0) out += `, ${totalDel} deletion${totalDel !== 1 ? "s" : ""}(-)`;
1104
+ out += "\n";
1105
+ return ok(out);
1106
+ }
1107
+
1108
+ let out = "";
1109
+ for (const d of diffs) {
1110
+ const oldLines = d.oldContent ? d.oldContent.split("\n") : [];
1111
+ const newLines = d.newContent ? d.newContent.split("\n") : [];
1112
+
1113
+ out += `${BOLD}diff --git a/${d.path} b/${d.path}${RESET}\n`;
1114
+ if (d.status === "added") out += "new file mode 100644\n";
1115
+ if (d.status === "deleted") out += "deleted file mode 100644\n";
1116
+ out += `--- ${d.status === "added" ? "/dev/null" : "a/" + d.path}\n`;
1117
+ out += `+++ ${d.status === "deleted" ? "/dev/null" : "b/" + d.path}\n`;
1118
+
1119
+ const ops = myersDiff(oldLines, newLines);
1120
+ const hunks = buildHunks(ops, 3);
1121
+
1122
+ for (const hunk of hunks) {
1123
+ out += `${CYAN}@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@${RESET}\n`;
1124
+ for (const op of hunk.lines) {
1125
+ if (op.kind === "equal") {
1126
+ out += ` ${op.line}\n`;
1127
+ } else if (op.kind === "delete") {
1128
+ out += `${RED}-${op.line}${RESET}\n`;
1129
+ } else {
1130
+ out += `${GREEN}+${op.line}${RESET}\n`;
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ return ok(out);
1137
+ }
1138
+
1139
+ function gitBranch(args: string[], ctx: ShellContext): ShellResult {
1140
+ const r = requireRepo(ctx.volume, ctx.cwd);
1141
+ if ("error" in r) return r.error;
1142
+ const { repo } = r;
1143
+
1144
+ if (args.includes("--show-current")) {
1145
+ const branch = repo.getCurrentBranch();
1146
+ return ok(branch ? branch + "\n" : "\n");
1147
+ }
1148
+
1149
+ const deleteIdx = args.indexOf("-d") !== -1 ? args.indexOf("-d") : args.indexOf("-D");
1150
+ if (deleteIdx >= 0) {
1151
+ const name = args[deleteIdx + 1];
1152
+ if (!name) return fail("error: branch name required\n");
1153
+ if (name === repo.getCurrentBranch()) {
1154
+ return fail(`error: Cannot delete branch '${name}' checked out.\n`);
1155
+ }
1156
+ if (repo.deleteBranch(name)) {
1157
+ return ok(`Deleted branch ${name}.\n`);
1158
+ }
1159
+ return fail(`error: branch '${name}' not found.\n`);
1160
+ }
1161
+
1162
+ const renameIdx = args.indexOf("-m");
1163
+ if (renameIdx >= 0) {
1164
+ const oldName = args[renameIdx + 1];
1165
+ const newName = args[renameIdx + 2];
1166
+ if (!oldName || !newName) return fail("error: too few arguments to rename\n");
1167
+ const hash = repo.resolveRef(oldName);
1168
+ if (!hash) return fail(`error: refname ${oldName} not a valid ref\n`);
1169
+ repo.updateBranchRef(newName, hash);
1170
+ repo.deleteBranch(oldName);
1171
+ if (repo.getCurrentBranch() === oldName) {
1172
+ repo.setHEAD("ref: refs/heads/" + newName);
1173
+ }
1174
+ return ok(`Branch '${oldName}' renamed to '${newName}'.\n`);
1175
+ }
1176
+
1177
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1178
+ if (nonFlags.length > 0) {
1179
+ const name = nonFlags[0];
1180
+ const startPoint = nonFlags[1];
1181
+ const hash = startPoint ? repo.resolveRef(startPoint) : repo.resolveHEAD();
1182
+ if (!hash) return fail(`fatal: not a valid object name: '${startPoint ?? "HEAD"}'\n`, 128);
1183
+ repo.updateBranchRef(name, hash);
1184
+ return ok("");
1185
+ }
1186
+
1187
+ const branches = repo.listBranches();
1188
+ const current = repo.getCurrentBranch();
1189
+ let out = "";
1190
+ for (const b of branches.sort()) {
1191
+ if (b === current) {
1192
+ out += `* ${GREEN}${b}${RESET}\n`;
1193
+ } else {
1194
+ out += ` ${b}\n`;
1195
+ }
1196
+ }
1197
+ return ok(out);
1198
+ }
1199
+
1200
+ function gitCheckout(args: string[], ctx: ShellContext): ShellResult {
1201
+ const r = requireRepo(ctx.volume, ctx.cwd);
1202
+ if ("error" in r) return r.error;
1203
+ const { repo } = r;
1204
+
1205
+ const createNew = args.includes("-b");
1206
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1207
+
1208
+ if (nonFlags.length === 0) return fail("error: you must specify a branch to checkout\n");
1209
+
1210
+ let target: string;
1211
+ let newBranchName: string | null = null;
1212
+
1213
+ if (createNew) {
1214
+ const bIdx = args.indexOf("-b");
1215
+ newBranchName = args[bIdx + 1];
1216
+ if (!newBranchName) return fail("error: switch 'b' requires a value\n");
1217
+ target = args[bIdx + 2] ?? repo.getCurrentBranch() ?? "HEAD";
1218
+ } else {
1219
+ target = nonFlags[0];
1220
+ }
1221
+
1222
+ let commitHash = repo.resolveRef(target);
1223
+
1224
+ if (createNew) {
1225
+ if (!commitHash) commitHash = repo.resolveHEAD();
1226
+ if (!commitHash) return fail(`fatal: not a valid object name: '${target}'\n`, 128);
1227
+ repo.updateBranchRef(newBranchName!, commitHash);
1228
+ repo.setHEAD("ref: refs/heads/" + newBranchName!);
1229
+ return ok(`Switched to a new branch '${newBranchName}'\n`);
1230
+ }
1231
+
1232
+ const branches = repo.listBranches();
1233
+ const isBranch = branches.includes(target);
1234
+
1235
+ if (!commitHash) {
1236
+ return fail(`error: pathspec '${target}' did not match any file(s) known to git.\n`);
1237
+ }
1238
+
1239
+ const currentHead = repo.resolveHEAD();
1240
+ if (commitHash !== currentHead) {
1241
+ const targetTree = repo.getCommitTree(commitHash);
1242
+ const currentTree = currentHead ? repo.getCommitTree(currentHead) : new Map<string, string>();
1243
+
1244
+ for (const [path, blobHash] of targetTree) {
1245
+ const content = repo.getBlobContent(blobHash);
1246
+ if (content !== null) {
1247
+ const fullPath = repo.workDir + "/" + path;
1248
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
1249
+ if (dir && !ctx.volume.existsSync(dir)) {
1250
+ ctx.volume.mkdirSync(dir, { recursive: true });
1251
+ }
1252
+ ctx.volume.writeFileSync(fullPath, content);
1253
+ }
1254
+ }
1255
+
1256
+ for (const [path] of currentTree) {
1257
+ if (!targetTree.has(path)) {
1258
+ const fullPath = repo.workDir + "/" + path;
1259
+ try {
1260
+ ctx.volume.unlinkSync(fullPath);
1261
+ } catch { /* */ }
1262
+ }
1263
+ }
1264
+
1265
+ const newIndex: IndexEntry[] = [];
1266
+ for (const [path, blobHash] of targetTree) {
1267
+ newIndex.push({ path, hash: blobHash, mode: 100644, mtime: Date.now() });
1268
+ }
1269
+ newIndex.sort((a, b) => a.path.localeCompare(b.path));
1270
+ repo.writeIndex(newIndex);
1271
+ }
1272
+
1273
+ if (isBranch) {
1274
+ repo.setHEAD("ref: refs/heads/" + target);
1275
+ return ok(`Switched to branch '${target}'\n`);
1276
+ } else {
1277
+ repo.setHEAD(commitHash);
1278
+ return ok(`HEAD is now at ${commitHash.slice(0, 7)}\n`);
1279
+ }
1280
+ }
1281
+
1282
+ function gitSwitch(args: string[], ctx: ShellContext): ShellResult {
1283
+ const newArgs: string[] = [];
1284
+ for (let i = 0; i < args.length; i++) {
1285
+ if (args[i] === "-c" || args[i] === "--create") {
1286
+ newArgs.push("-b");
1287
+ } else {
1288
+ newArgs.push(args[i]);
1289
+ }
1290
+ }
1291
+ return gitCheckout(newArgs, ctx);
1292
+ }
1293
+
1294
+ function gitRevParse(args: string[], ctx: ShellContext): ShellResult {
1295
+ const found = findGitDir(ctx.volume, ctx.cwd);
1296
+
1297
+ for (const arg of args) {
1298
+ switch (arg) {
1299
+ case "--show-toplevel":
1300
+ if (!found) return fail("fatal: not a git repository\n", 128);
1301
+ return ok(found.workDir + "\n");
1302
+ case "--is-inside-work-tree":
1303
+ return ok(found ? "true\n" : "false\n");
1304
+ case "--git-dir":
1305
+ if (!found) return fail("fatal: not a git repository\n", 128);
1306
+ return ok(".git\n");
1307
+ case "--is-bare-repository":
1308
+ return ok("false\n");
1309
+ case "--abbrev-ref": {
1310
+ const refArg = args[args.indexOf(arg) + 1];
1311
+ if (refArg === "HEAD" && found) {
1312
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1313
+ const branch = repo.getCurrentBranch();
1314
+ return ok((branch ?? "HEAD") + "\n");
1315
+ }
1316
+ return ok("HEAD\n");
1317
+ }
1318
+ case "--short": {
1319
+ const refArg = args[args.indexOf(arg) + 1];
1320
+ if (refArg === "HEAD" && found) {
1321
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1322
+ const hash = repo.resolveHEAD();
1323
+ return ok(hash ? hash.slice(0, 7) + "\n" : "\n");
1324
+ }
1325
+ return ok("\n");
1326
+ }
1327
+ case "--verify": {
1328
+ const refArg = args[args.indexOf(arg) + 1];
1329
+ if (!found) return fail("fatal: not a git repository\n", 128);
1330
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1331
+ if (refArg === "HEAD") {
1332
+ const hash = repo.resolveHEAD();
1333
+ if (hash) return ok(hash + "\n");
1334
+ return fail("fatal: Needed a single revision\n", 128);
1335
+ }
1336
+ const resolved = repo.resolveRef(refArg ?? "");
1337
+ if (resolved) return ok(resolved + "\n");
1338
+ return fail(`fatal: Needed a single revision\n`, 128);
1339
+ }
1340
+ default:
1341
+ break;
1342
+ }
1343
+ }
1344
+
1345
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1346
+ if (nonFlags.length > 0 && found) {
1347
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1348
+ for (const ref of nonFlags) {
1349
+ if (ref === "HEAD") {
1350
+ const hash = repo.resolveHEAD();
1351
+ if (hash) return ok(hash + "\n");
1352
+ } else {
1353
+ const hash = repo.resolveRef(ref);
1354
+ if (hash) return ok(hash + "\n");
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ return ok("\n");
1360
+ }
1361
+
1362
+ function gitConfig(args: string[], ctx: ShellContext): ShellResult {
1363
+ const found = findGitDir(ctx.volume, ctx.cwd);
1364
+
1365
+ if (args.includes("--list") || args.includes("-l")) {
1366
+ if (!found) return fail("fatal: not a git repository\n", 128);
1367
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1368
+ const config = repo.readConfig();
1369
+ const lines = config.split("\n");
1370
+ let section = "";
1371
+ let subSection = "";
1372
+ let out = "";
1373
+ for (const line of lines) {
1374
+ const trimmed = line.trim();
1375
+ const secMatch = trimmed.match(/^\[(\w+)\s*(?:"([^"]*)")?\]$/);
1376
+ if (secMatch) {
1377
+ section = secMatch[1];
1378
+ subSection = secMatch[2] ?? "";
1379
+ continue;
1380
+ }
1381
+ const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.*)$/);
1382
+ if (kvMatch && section) {
1383
+ const key = subSection
1384
+ ? `${section}.${subSection}.${kvMatch[1]}`
1385
+ : `${section}.${kvMatch[1]}`;
1386
+ out += `${key}=${kvMatch[2].trim()}\n`;
1387
+ }
1388
+ }
1389
+ return ok(out);
1390
+ }
1391
+
1392
+ // no distinction between --global/--local in nodepod
1393
+ const filtered = args.filter((a) => a !== "--global" && a !== "--local" && a !== "--get");
1394
+
1395
+ if (filtered.length === 0) return fail("error: key required\n");
1396
+
1397
+ const key = filtered[0];
1398
+ const value = filtered[1];
1399
+
1400
+ if (!found) {
1401
+ if (value !== undefined) return fail("fatal: not a git repository\n", 128);
1402
+ return ok("\n");
1403
+ }
1404
+
1405
+ const repo = new GitRepo(ctx.volume, found.workDir, found.gitDir);
1406
+
1407
+ if (value !== undefined) {
1408
+ repo.setConfigValue(key, value);
1409
+ return ok("");
1410
+ }
1411
+
1412
+ const val = repo.getConfigValue(key);
1413
+ if (val !== null) return ok(val + "\n");
1414
+ return fail("");
1415
+ }
1416
+
1417
+ function gitRemote(args: string[], ctx: ShellContext): ShellResult {
1418
+ const r = requireRepo(ctx.volume, ctx.cwd);
1419
+ if ("error" in r) return r.error;
1420
+ const { repo } = r;
1421
+
1422
+ const sub = args[0];
1423
+
1424
+ if (sub === "add") {
1425
+ const name = args[1];
1426
+ const url = args[2];
1427
+ if (!name || !url) return fail("usage: git remote add <name> <url>\n");
1428
+ repo.setConfigValue(`remote.${name}.url`, url);
1429
+ repo.setConfigValue(`remote.${name}.fetch`, `+refs/heads/*:refs/remotes/${name}/*`);
1430
+ return ok("");
1431
+ }
1432
+
1433
+ if (sub === "remove" || sub === "rm") {
1434
+ const name = args[1];
1435
+ if (!name) return fail("usage: git remote remove <name>\n");
1436
+ const config = repo.readConfig();
1437
+ const lines = config.split("\n");
1438
+ const out: string[] = [];
1439
+ let skip = false;
1440
+ for (const line of lines) {
1441
+ if (line.trim() === `[remote "${name}"]`) {
1442
+ skip = true;
1443
+ continue;
1444
+ }
1445
+ if (line.trim().startsWith("[") && skip) skip = false;
1446
+ if (!skip) out.push(line);
1447
+ }
1448
+ repo.writeConfig(out.join("\n"));
1449
+ return ok("");
1450
+ }
1451
+
1452
+ if (sub === "get-url") {
1453
+ const name = args[1] ?? "origin";
1454
+ const url = repo.getRemoteUrl(name);
1455
+ if (url) return ok(url + "\n");
1456
+ return fail(`fatal: No such remote '${name}'\n`, 2);
1457
+ }
1458
+
1459
+ const verbose = args.includes("-v") || args.includes("--verbose");
1460
+ const config = repo.readConfig();
1461
+ const remotes: Array<{ name: string; url: string }> = [];
1462
+ const lines = config.split("\n");
1463
+ let currentRemote = "";
1464
+ for (const line of lines) {
1465
+ const match = line.trim().match(/^\[remote\s+"([^"]+)"\]$/);
1466
+ if (match) {
1467
+ currentRemote = match[1];
1468
+ continue;
1469
+ }
1470
+ if (currentRemote) {
1471
+ const kvMatch = line.trim().match(/^url\s*=\s*(.+)$/);
1472
+ if (kvMatch) {
1473
+ remotes.push({ name: currentRemote, url: kvMatch[1].trim() });
1474
+ currentRemote = "";
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ let out = "";
1480
+ for (const r of remotes) {
1481
+ if (verbose) {
1482
+ out += `${r.name}\t${r.url} (fetch)\n`;
1483
+ out += `${r.name}\t${r.url} (push)\n`;
1484
+ } else {
1485
+ out += r.name + "\n";
1486
+ }
1487
+ }
1488
+ return ok(out);
1489
+ }
1490
+
1491
+ function gitMerge(args: string[], ctx: ShellContext): ShellResult {
1492
+ const r = requireRepo(ctx.volume, ctx.cwd);
1493
+ if ("error" in r) return r.error;
1494
+ const { repo } = r;
1495
+
1496
+ if (args.includes("--abort")) {
1497
+ try {
1498
+ ctx.volume.unlinkSync(repo.gitDir + "/MERGE_HEAD");
1499
+ ctx.volume.unlinkSync(repo.gitDir + "/MERGE_MSG");
1500
+ } catch { /* */ }
1501
+ return ok("Merge aborted.\n");
1502
+ }
1503
+
1504
+ const target = args.filter((a) => !a.startsWith("-"))[0];
1505
+ if (!target) return fail("error: specify a branch to merge\n");
1506
+
1507
+ const targetHash = repo.resolveRef(target);
1508
+ if (!targetHash) return fail(`merge: ${target} - not something we can merge\n`);
1509
+
1510
+ const currentHash = repo.resolveHEAD();
1511
+ if (!currentHash) {
1512
+ const branch = repo.getCurrentBranch();
1513
+ if (branch) repo.updateBranchRef(branch, targetHash);
1514
+ return ok(`Fast-forward\n`);
1515
+ }
1516
+
1517
+ if (currentHash === targetHash) {
1518
+ return ok("Already up to date.\n");
1519
+ }
1520
+
1521
+ // check for fast-forward
1522
+ let walker: string | null = targetHash;
1523
+ let isFF = false;
1524
+ for (let i = 0; i < 1000; i++) {
1525
+ if (walker === currentHash) { isFF = true; break; }
1526
+ const commit = repo.readCommit(walker!);
1527
+ if (!commit || !commit.parent) break;
1528
+ walker = commit.parent;
1529
+ }
1530
+
1531
+ if (isFF) {
1532
+ const branch = repo.getCurrentBranch();
1533
+ if (branch) repo.updateBranchRef(branch, targetHash);
1534
+ else repo.setHEAD(targetHash);
1535
+
1536
+ const targetTree = repo.getCommitTree(targetHash);
1537
+ for (const [path, blobHash] of targetTree) {
1538
+ const content = repo.getBlobContent(blobHash);
1539
+ if (content !== null) {
1540
+ const fullPath = repo.workDir + "/" + path;
1541
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
1542
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1543
+ ctx.volume.writeFileSync(fullPath, content);
1544
+ }
1545
+ }
1546
+
1547
+ const newIndex: IndexEntry[] = [];
1548
+ for (const [path, blobHash] of targetTree) {
1549
+ newIndex.push({ path, hash: blobHash, mode: 100644, mtime: Date.now() });
1550
+ }
1551
+ repo.writeIndex(newIndex);
1552
+
1553
+ return ok(`Updating ${currentHash.slice(0, 7)}..${targetHash.slice(0, 7)}\nFast-forward\n`);
1554
+ }
1555
+
1556
+ const entries = repo.readIndex();
1557
+ const treeHash = repo.buildTree(entries);
1558
+ const mergeMessage = `Merge branch '${target}'`;
1559
+ const mergeHash = repo.createCommit(mergeMessage, currentHash, treeHash, targetHash);
1560
+
1561
+ const branch = repo.getCurrentBranch();
1562
+ if (branch) repo.updateBranchRef(branch, mergeHash);
1563
+ else repo.setHEAD(mergeHash);
1564
+
1565
+ return ok(`Merge made by the 'recursive' strategy.\n`);
1566
+ }
1567
+
1568
+ function gitStash(args: string[], ctx: ShellContext): ShellResult {
1569
+ const r = requireRepo(ctx.volume, ctx.cwd);
1570
+ if ("error" in r) return r.error;
1571
+ const { repo } = r;
1572
+
1573
+ const sub = args[0] ?? "push";
1574
+
1575
+ if (sub === "list") {
1576
+ const list = repo.readStashList();
1577
+ let out = "";
1578
+ for (let i = 0; i < list.length; i++) {
1579
+ out += `stash@{${i}}: ${list[i].message}\n`;
1580
+ }
1581
+ return ok(out);
1582
+ }
1583
+
1584
+ if (sub === "push" || sub === "save" || !args[0]) {
1585
+ const message = args.find((a) => !a.startsWith("-")) && args[0] !== "push" && args[0] !== "save"
1586
+ ? args.join(" ")
1587
+ : "WIP on " + (repo.getCurrentBranch() ?? "HEAD");
1588
+
1589
+ const unstaged = repo.diffWorkingVsIndex();
1590
+ const staged = repo.diffIndexVsHEAD();
1591
+ if (unstaged.length === 0 && staged.length === 0) {
1592
+ return ok("No local changes to save\n");
1593
+ }
1594
+
1595
+ const entries = repo.readIndex();
1596
+ repo.walkWorkTree(repo.workDir, "", (relPath, content) => {
1597
+ repo.addToIndex(relPath, content);
1598
+ });
1599
+ const allEntries = repo.readIndex();
1600
+ const treeHash = repo.buildTree(allEntries);
1601
+ const parent = repo.resolveHEAD();
1602
+ const stashHash = repo.createCommit("stash: " + message, parent, treeHash);
1603
+
1604
+ repo.writeIndex(entries);
1605
+
1606
+ const headHash = repo.resolveHEAD();
1607
+ if (headHash) {
1608
+ const headTree = repo.getCommitTree(headHash);
1609
+ for (const [path, blobHash] of headTree) {
1610
+ const content = repo.getBlobContent(blobHash);
1611
+ if (content !== null) {
1612
+ const fullPath = repo.workDir + "/" + path;
1613
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
1614
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1615
+ ctx.volume.writeFileSync(fullPath, content);
1616
+ }
1617
+ }
1618
+ }
1619
+
1620
+ const list = repo.readStashList();
1621
+ list.unshift({ message, commitHash: stashHash });
1622
+ repo.writeStashList(list);
1623
+
1624
+ return ok(`Saved working directory and index state ${message}\n`);
1625
+ }
1626
+
1627
+ if (sub === "pop" || sub === "apply") {
1628
+ const idxArg = args[1] ? parseInt(args[1], 10) : 0;
1629
+ const list = repo.readStashList();
1630
+ if (idxArg >= list.length) return fail(`error: stash@{${idxArg}} does not exist\n`);
1631
+
1632
+ const entry = list[idxArg];
1633
+ const stashTree = repo.getCommitTree(entry.commitHash);
1634
+
1635
+ for (const [path, blobHash] of stashTree) {
1636
+ const content = repo.getBlobContent(blobHash);
1637
+ if (content !== null) {
1638
+ const fullPath = repo.workDir + "/" + path;
1639
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
1640
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1641
+ ctx.volume.writeFileSync(fullPath, content);
1642
+ }
1643
+ }
1644
+
1645
+ if (sub === "pop") {
1646
+ list.splice(idxArg, 1);
1647
+ repo.writeStashList(list);
1648
+ }
1649
+
1650
+ return ok(`Applied stash@{${idxArg}}\n`);
1651
+ }
1652
+
1653
+ if (sub === "drop") {
1654
+ const idxArg = args[1] ? parseInt(args[1], 10) : 0;
1655
+ const list = repo.readStashList();
1656
+ if (idxArg >= list.length) return fail(`error: stash@{${idxArg}} does not exist\n`);
1657
+ list.splice(idxArg, 1);
1658
+ repo.writeStashList(list);
1659
+ return ok(`Dropped stash@{${idxArg}}\n`);
1660
+ }
1661
+
1662
+ return fail(`error: unknown stash subcommand '${sub}'\n`);
1663
+ }
1664
+
1665
+ function gitRm(args: string[], ctx: ShellContext): ShellResult {
1666
+ const r = requireRepo(ctx.volume, ctx.cwd);
1667
+ if ("error" in r) return r.error;
1668
+ const { repo } = r;
1669
+
1670
+ const cached = args.includes("--cached");
1671
+ const paths = args.filter((a) => !a.startsWith("-"));
1672
+
1673
+ if (paths.length === 0) return fail("usage: git rm [--cached] <file>...\n");
1674
+
1675
+ for (const p of paths) {
1676
+ const absPath = pathModule.resolve(ctx.cwd, p);
1677
+ const relPath = pathModule.relative(repo.workDir, absPath);
1678
+ repo.removeFromIndex(relPath);
1679
+ if (!cached) {
1680
+ try { ctx.volume.unlinkSync(absPath); } catch { /* */ }
1681
+ }
1682
+ }
1683
+
1684
+ return ok("");
1685
+ }
1686
+
1687
+ function gitReset(args: string[], ctx: ShellContext): ShellResult {
1688
+ const r = requireRepo(ctx.volume, ctx.cwd);
1689
+ if ("error" in r) return r.error;
1690
+ const { repo } = r;
1691
+
1692
+ const hard = args.includes("--hard");
1693
+ const soft = args.includes("--soft");
1694
+ const paths = args.filter((a) => !a.startsWith("-"));
1695
+
1696
+ if (paths.length > 0 && !hard && !soft) {
1697
+ const headHash = repo.resolveHEAD();
1698
+ const headTree = headHash ? repo.getCommitTree(headHash) : new Map<string, string>();
1699
+
1700
+ for (const p of paths) {
1701
+ const absPath = pathModule.resolve(ctx.cwd, p);
1702
+ const relPath = pathModule.relative(repo.workDir, absPath);
1703
+ const headBlobHash = headTree.get(relPath);
1704
+ if (headBlobHash) {
1705
+ const entries = repo.readIndex();
1706
+ const idx = entries.findIndex((e) => e.path === relPath);
1707
+ if (idx >= 0) {
1708
+ entries[idx].hash = headBlobHash;
1709
+ repo.writeIndex(entries);
1710
+ }
1711
+ } else {
1712
+ repo.removeFromIndex(relPath);
1713
+ }
1714
+ }
1715
+ return ok("");
1716
+ }
1717
+
1718
+ const targetRef = paths[0] ?? "HEAD";
1719
+ const targetHash = repo.resolveRef(targetRef) ?? repo.resolveHEAD();
1720
+ if (!targetHash) return fail("fatal: Failed to resolve HEAD\n", 128);
1721
+
1722
+ if (!soft) {
1723
+ const tree = repo.getCommitTree(targetHash);
1724
+ const newIndex: IndexEntry[] = [];
1725
+ for (const [path, blobHash] of tree) {
1726
+ newIndex.push({ path, hash: blobHash, mode: 100644, mtime: Date.now() });
1727
+ }
1728
+ newIndex.sort((a, b) => a.path.localeCompare(b.path));
1729
+ repo.writeIndex(newIndex);
1730
+ }
1731
+
1732
+ if (hard) {
1733
+ const tree = repo.getCommitTree(targetHash);
1734
+ for (const [path, blobHash] of tree) {
1735
+ const content = repo.getBlobContent(blobHash);
1736
+ if (content !== null) {
1737
+ const fullPath = repo.workDir + "/" + path;
1738
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
1739
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1740
+ ctx.volume.writeFileSync(fullPath, content);
1741
+ }
1742
+ }
1743
+ }
1744
+
1745
+ const branch = repo.getCurrentBranch();
1746
+ if (branch) repo.updateBranchRef(branch, targetHash);
1747
+
1748
+ return ok(`HEAD is now at ${targetHash.slice(0, 7)}\n`);
1749
+ }
1750
+
1751
+ /* ------------------------------------------------------------------ */
1752
+ /* Remote commands (GitHub API) */
1753
+ /* ------------------------------------------------------------------ */
1754
+
1755
+ function requireToken(env: Record<string, string>): string | null {
1756
+ return env.GITHUB_TOKEN || env.GH_TOKEN || null;
1757
+ }
1758
+
1759
+ async function gitClone(args: string[], ctx: ShellContext): Promise<ShellResult> {
1760
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1761
+ const url = nonFlags[0];
1762
+ if (!url) return fail("usage: git clone <repository> [<directory>]\n");
1763
+
1764
+ let branch = "main";
1765
+ const bIdx = args.indexOf("-b");
1766
+ if (bIdx >= 0 && args[bIdx + 1]) branch = args[bIdx + 1];
1767
+
1768
+ const tmpRepo = new GitRepo(ctx.volume, "/", "/");
1769
+ const gh = tmpRepo.parseGitHubUrl(url);
1770
+ if (!gh) {
1771
+ return fail(`fatal: repository '${url}' is not a GitHub URL\n`, 128);
1772
+ }
1773
+
1774
+ const token = requireToken(ctx.env);
1775
+ if (!token) {
1776
+ return fail("fatal: authentication required. Set GITHUB_TOKEN environment variable.\n", 128);
1777
+ }
1778
+
1779
+ let targetDir = nonFlags[1] ?? gh.repo;
1780
+ if (!targetDir.startsWith("/")) targetDir = pathModule.resolve(ctx.cwd, targetDir);
1781
+
1782
+ const repoInfo = await githubApi(`/repos/${gh.owner}/${gh.repo}`, token);
1783
+ if (!repoInfo.ok) {
1784
+ if (repoInfo.status === 404) return fail(`fatal: repository '${url}' not found\n`, 128);
1785
+ return fail(`fatal: GitHub API error: ${repoInfo.status} ${repoInfo.data?.message ?? ""}\n`, 128);
1786
+ }
1787
+ const defaultBranch = repoInfo.data.default_branch ?? "main";
1788
+ if (branch === "main" && defaultBranch !== "main") branch = defaultBranch;
1789
+
1790
+ const refResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/ref/heads/${branch}`, token);
1791
+ if (!refResp.ok) {
1792
+ return fail(`fatal: Remote branch '${branch}' not found\n`, 128);
1793
+ }
1794
+ const commitSha = refResp.data.object.sha;
1795
+
1796
+ const commitResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/commits/${commitSha}`, token);
1797
+ if (!commitResp.ok) return fail(`fatal: could not fetch commit\n`, 128);
1798
+ const treeSha = commitResp.data.tree.sha;
1799
+
1800
+ const treeResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/trees/${treeSha}?recursive=1`, token);
1801
+ if (!treeResp.ok) return fail(`fatal: could not fetch tree\n`, 128);
1802
+
1803
+ if (!ctx.volume.existsSync(targetDir)) ctx.volume.mkdirSync(targetDir, { recursive: true });
1804
+
1805
+ let fileCount = 0;
1806
+ const blobs: Array<{ path: string; sha: string }> = [];
1807
+ for (const item of treeResp.data.tree) {
1808
+ if (item.type === "blob") {
1809
+ blobs.push({ path: item.path, sha: item.sha });
1810
+ }
1811
+ }
1812
+
1813
+ const BATCH_SIZE = 10;
1814
+ for (let i = 0; i < blobs.length; i += BATCH_SIZE) {
1815
+ const batch = blobs.slice(i, i + BATCH_SIZE);
1816
+ const results = await Promise.all(
1817
+ batch.map((b) => githubApi(`/repos/${gh.owner}/${gh.repo}/git/blobs/${b.sha}`, token)),
1818
+ );
1819
+ for (let j = 0; j < batch.length; j++) {
1820
+ const blobResp = results[j];
1821
+ if (!blobResp.ok) continue;
1822
+ const filePath = targetDir + "/" + batch[j].path;
1823
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
1824
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1825
+ let content: string;
1826
+ if (blobResp.data.encoding === "base64") {
1827
+ content = atob(blobResp.data.content.replace(/\n/g, ""));
1828
+ } else {
1829
+ content = blobResp.data.content;
1830
+ }
1831
+ ctx.volume.writeFileSync(filePath, content);
1832
+ fileCount++;
1833
+ }
1834
+ }
1835
+
1836
+ const initResult = gitInit([], { ...ctx, cwd: targetDir });
1837
+
1838
+ const clonedRepo = new GitRepo(ctx.volume, targetDir, targetDir + "/.git");
1839
+ clonedRepo.setConfigValue("remote.origin.url", url);
1840
+ clonedRepo.setConfigValue("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*");
1841
+ clonedRepo.setConfigValue("branch." + branch + ".remote", "origin");
1842
+ clonedRepo.setConfigValue("branch." + branch + ".merge", "refs/heads/" + branch);
1843
+
1844
+ clonedRepo.setHEAD("ref: refs/heads/" + branch);
1845
+
1846
+ clonedRepo.walkWorkTree(targetDir, "", (relPath, content) => {
1847
+ clonedRepo.addToIndex(relPath, content);
1848
+ });
1849
+ const entries = clonedRepo.readIndex();
1850
+ const treeHash = clonedRepo.buildTree(entries);
1851
+ const cloneCommitHash = clonedRepo.createCommit(`Clone of ${url}`, null, treeHash);
1852
+ clonedRepo.updateBranchRef(branch, cloneCommitHash);
1853
+
1854
+ return ok(`Cloning into '${nonFlags[1] ?? gh.repo}'...\nremote: Enumerating objects: ${fileCount}\nReceiving objects: 100% (${fileCount}/${fileCount}), done.\n`);
1855
+ }
1856
+
1857
+ async function gitPush(args: string[], ctx: ShellContext): Promise<ShellResult> {
1858
+ const r = requireRepo(ctx.volume, ctx.cwd);
1859
+ if ("error" in r) return r.error;
1860
+ const { repo } = r;
1861
+
1862
+ const token = requireToken(ctx.env);
1863
+ if (!token) return fail("fatal: authentication required. Set GITHUB_TOKEN environment variable.\n", 128);
1864
+
1865
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1866
+ const remoteName = nonFlags[0] ?? "origin";
1867
+ const localBranch = repo.getCurrentBranch();
1868
+ if (!localBranch) return fail("fatal: not on a branch\n", 128);
1869
+ const remoteBranch = nonFlags[1] ?? localBranch;
1870
+
1871
+ const remoteUrl = repo.getRemoteUrl(remoteName);
1872
+ if (!remoteUrl) return fail(`fatal: '${remoteName}' does not appear to be a git repository\n`, 128);
1873
+
1874
+ const gh = repo.parseGitHubUrl(remoteUrl);
1875
+ if (!gh) return fail(`fatal: remote '${remoteName}' is not a GitHub URL\n`, 128);
1876
+
1877
+ const headHash = repo.resolveHEAD();
1878
+ if (!headHash) return fail("fatal: nothing to push\n", 128);
1879
+
1880
+ const commitTree = repo.getCommitTree(headHash);
1881
+ const blobShas: Map<string, string> = new Map();
1882
+
1883
+ for (const [path, localHash] of commitTree) {
1884
+ const content = repo.getBlobContent(localHash);
1885
+ if (content === null) continue;
1886
+ const blobResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/blobs`, token, "POST", {
1887
+ content: btoa(content),
1888
+ encoding: "base64",
1889
+ });
1890
+ if (!blobResp.ok) return fail(`fatal: failed to create blob for ${path}: ${blobResp.data?.message}\n`, 128);
1891
+ blobShas.set(path, blobResp.data.sha);
1892
+ }
1893
+
1894
+ const treeEntries = Array.from(blobShas).map(([path, sha]) => ({
1895
+ path,
1896
+ mode: "100644",
1897
+ type: "blob",
1898
+ sha,
1899
+ }));
1900
+ const treeResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/trees`, token, "POST", {
1901
+ tree: treeEntries,
1902
+ });
1903
+ if (!treeResp.ok) return fail(`fatal: failed to create tree: ${treeResp.data?.message}\n`, 128);
1904
+
1905
+ let parentSha: string | null = null;
1906
+ const refResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/ref/heads/${remoteBranch}`, token);
1907
+ if (refResp.ok) parentSha = refResp.data.object.sha;
1908
+
1909
+ const commit = repo.readCommit(headHash);
1910
+ const commitBody: any = {
1911
+ message: commit?.message ?? "Push from nodepod",
1912
+ tree: treeResp.data.sha,
1913
+ };
1914
+ if (parentSha) commitBody.parents = [parentSha];
1915
+
1916
+ const commitResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/commits`, token, "POST", commitBody);
1917
+ if (!commitResp.ok) return fail(`fatal: failed to create commit: ${commitResp.data?.message}\n`, 128);
1918
+
1919
+ if (parentSha) {
1920
+ const force = args.includes("-f") || args.includes("--force");
1921
+ const updateResp = await githubApi(
1922
+ `/repos/${gh.owner}/${gh.repo}/git/refs/heads/${remoteBranch}`,
1923
+ token,
1924
+ "PATCH",
1925
+ { sha: commitResp.data.sha, force },
1926
+ );
1927
+ if (!updateResp.ok) return fail(`fatal: failed to update ref: ${updateResp.data?.message}\n`, 128);
1928
+ } else {
1929
+ const createResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/refs`, token, "POST", {
1930
+ ref: `refs/heads/${remoteBranch}`,
1931
+ sha: commitResp.data.sha,
1932
+ });
1933
+ if (!createResp.ok) return fail(`fatal: failed to create ref: ${createResp.data?.message}\n`, 128);
1934
+ }
1935
+
1936
+ return ok(`To ${remoteUrl}\n ${(parentSha ?? "000000").slice(0, 7)}..${commitResp.data.sha.slice(0, 7)} ${localBranch} -> ${remoteBranch}\n`);
1937
+ }
1938
+
1939
+ async function gitPull(args: string[], ctx: ShellContext): Promise<ShellResult> {
1940
+ const r = requireRepo(ctx.volume, ctx.cwd);
1941
+ if ("error" in r) return r.error;
1942
+ const { repo } = r;
1943
+
1944
+ const token = requireToken(ctx.env);
1945
+ if (!token) return fail("fatal: authentication required. Set GITHUB_TOKEN environment variable.\n", 128);
1946
+
1947
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
1948
+ const remoteName = nonFlags[0] ?? "origin";
1949
+ const currentBranch = repo.getCurrentBranch();
1950
+ if (!currentBranch) return fail("fatal: not on a branch\n", 128);
1951
+ const remoteBranch = nonFlags[1] ?? currentBranch;
1952
+
1953
+ const remoteUrl = repo.getRemoteUrl(remoteName);
1954
+ if (!remoteUrl) return fail(`fatal: '${remoteName}' does not appear to be a git repository\n`, 128);
1955
+
1956
+ const gh = repo.parseGitHubUrl(remoteUrl);
1957
+ if (!gh) return fail(`fatal: remote '${remoteName}' is not a GitHub URL\n`, 128);
1958
+
1959
+ const refResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/ref/heads/${remoteBranch}`, token);
1960
+ if (!refResp.ok) return fail(`fatal: couldn't find remote ref refs/heads/${remoteBranch}\n`, 128);
1961
+ const remoteCommitSha = refResp.data.object.sha;
1962
+
1963
+ const localHead = repo.resolveHEAD();
1964
+ if (localHead === remoteCommitSha) return ok("Already up to date.\n");
1965
+
1966
+ const commitResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/commits/${remoteCommitSha}`, token);
1967
+ if (!commitResp.ok) return fail("fatal: could not fetch remote commit\n", 128);
1968
+ const treeSha = commitResp.data.tree.sha;
1969
+
1970
+ const treeResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/git/trees/${treeSha}?recursive=1`, token);
1971
+ if (!treeResp.ok) return fail("fatal: could not fetch tree\n", 128);
1972
+
1973
+ let updated = 0;
1974
+ const blobs: Array<{ path: string; sha: string }> = [];
1975
+ for (const item of treeResp.data.tree) {
1976
+ if (item.type === "blob") blobs.push({ path: item.path, sha: item.sha });
1977
+ }
1978
+
1979
+ const BATCH_SIZE = 10;
1980
+ for (let i = 0; i < blobs.length; i += BATCH_SIZE) {
1981
+ const batch = blobs.slice(i, i + BATCH_SIZE);
1982
+ const results = await Promise.all(
1983
+ batch.map((b) => githubApi(`/repos/${gh.owner}/${gh.repo}/git/blobs/${b.sha}`, token)),
1984
+ );
1985
+ for (let j = 0; j < batch.length; j++) {
1986
+ const blobResp = results[j];
1987
+ if (!blobResp.ok) continue;
1988
+ const filePath = repo.workDir + "/" + batch[j].path;
1989
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
1990
+ if (dir && !ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
1991
+ let content: string;
1992
+ if (blobResp.data.encoding === "base64") {
1993
+ content = atob(blobResp.data.content.replace(/\n/g, ""));
1994
+ } else {
1995
+ content = blobResp.data.content;
1996
+ }
1997
+ ctx.volume.writeFileSync(filePath, content);
1998
+ updated++;
1999
+ }
2000
+ }
2001
+
2002
+ repo.walkWorkTree(repo.workDir, "", (relPath, content) => {
2003
+ repo.addToIndex(relPath, content);
2004
+ });
2005
+ const entries = repo.readIndex();
2006
+ const treeHash = repo.buildTree(entries);
2007
+ const pullCommit = repo.createCommit(`Pull from ${remoteName}/${remoteBranch}`, localHead, treeHash);
2008
+ repo.updateBranchRef(currentBranch, pullCommit);
2009
+
2010
+ return ok(`From ${remoteUrl}\nUpdating ${(localHead ?? "000000").slice(0, 7)}..${remoteCommitSha.slice(0, 7)}\nFast-forward\n ${updated} file${updated !== 1 ? "s" : ""} changed\n`);
2011
+ }
2012
+
2013
+ async function gitFetch(args: string[], ctx: ShellContext): Promise<ShellResult> {
2014
+ const r = requireRepo(ctx.volume, ctx.cwd);
2015
+ if ("error" in r) return r.error;
2016
+ const { repo } = r;
2017
+
2018
+ const token = requireToken(ctx.env);
2019
+ if (!token) return fail("fatal: authentication required. Set GITHUB_TOKEN environment variable.\n", 128);
2020
+
2021
+ const nonFlags = args.filter((a) => !a.startsWith("-"));
2022
+ const remoteName = nonFlags[0] ?? "origin";
2023
+
2024
+ const remoteUrl = repo.getRemoteUrl(remoteName);
2025
+ if (!remoteUrl) return fail(`fatal: '${remoteName}' does not appear to be a git repository\n`, 128);
2026
+
2027
+ const gh = repo.parseGitHubUrl(remoteUrl);
2028
+ if (!gh) return fail(`fatal: remote '${remoteName}' is not a GitHub URL\n`, 128);
2029
+
2030
+ const branchesResp = await githubApi(`/repos/${gh.owner}/${gh.repo}/branches`, token);
2031
+ if (!branchesResp.ok) return fail(`fatal: could not list remote branches\n`, 128);
2032
+
2033
+ let out = `From ${remoteUrl}\n`;
2034
+ for (const b of branchesResp.data) {
2035
+ const refPath = repo.gitDir + "/refs/remotes/" + remoteName + "/" + b.name;
2036
+ const dir = refPath.substring(0, refPath.lastIndexOf("/"));
2037
+ if (!ctx.volume.existsSync(dir)) ctx.volume.mkdirSync(dir, { recursive: true });
2038
+ ctx.volume.writeFileSync(refPath, b.commit.sha + "\n");
2039
+ out += ` * [updated] ${b.name} -> ${remoteName}/${b.name}\n`;
2040
+ }
2041
+
2042
+ return ok(out);
2043
+ }
2044
+
2045
+ /* ------------------------------------------------------------------ */
2046
+ /* Command factory */
2047
+ /* ------------------------------------------------------------------ */
2048
+
2049
+ export function createGitCommand(): ShellCommand {
2050
+ return {
2051
+ name: "git",
2052
+ async execute(args: string[], ctx: ShellContext): Promise<ShellResult> {
2053
+ if (args.length === 0) {
2054
+ return ok(`usage: git [--version] <command> [<args>]\n`);
2055
+ }
2056
+
2057
+ let effectiveCtx = ctx;
2058
+ if (args[0] === "-C" && args[1]) {
2059
+ const newCwd = pathModule.resolve(ctx.cwd, args[1]);
2060
+ effectiveCtx = { ...ctx, cwd: newCwd };
2061
+ args = args.slice(2);
2062
+ }
2063
+
2064
+ const sub = args[0];
2065
+ const subArgs = args.slice(1);
2066
+
2067
+ switch (sub) {
2068
+ case "--version":
2069
+ case "-v":
2070
+ return ok(`git version ${VERSIONS.GIT}\n`);
2071
+ case "--help":
2072
+ case "help":
2073
+ return ok(
2074
+ `usage: git <command> [<args>]\n\n` +
2075
+ `Available commands:\n` +
2076
+ ` init Create an empty Git repository\n` +
2077
+ ` clone Clone a repository from GitHub\n` +
2078
+ ` add Add file contents to the index\n` +
2079
+ ` status Show the working tree status\n` +
2080
+ ` commit Record changes to the repository\n` +
2081
+ ` log Show commit logs\n` +
2082
+ ` diff Show changes\n` +
2083
+ ` branch List, create, or delete branches\n` +
2084
+ ` checkout Switch branches or restore files\n` +
2085
+ ` switch Switch branches\n` +
2086
+ ` merge Join two development histories together\n` +
2087
+ ` remote Manage set of tracked repositories\n` +
2088
+ ` push Update remote refs (GitHub)\n` +
2089
+ ` pull Fetch and integrate remote changes (GitHub)\n` +
2090
+ ` fetch Download objects from remote (GitHub)\n` +
2091
+ ` stash Stash the changes in a dirty working directory\n` +
2092
+ ` reset Reset current HEAD to the specified state\n` +
2093
+ ` rm Remove files from the working tree and index\n` +
2094
+ ` rev-parse Ancillary plumbing command\n` +
2095
+ ` config Get and set repository options\n`,
2096
+ );
2097
+ case "init":
2098
+ return gitInit(subArgs, effectiveCtx);
2099
+ case "clone":
2100
+ return gitClone(subArgs, effectiveCtx);
2101
+ case "add":
2102
+ return gitAdd(subArgs, effectiveCtx);
2103
+ case "status":
2104
+ return gitStatus(subArgs, effectiveCtx);
2105
+ case "commit":
2106
+ return gitCommit(subArgs, effectiveCtx);
2107
+ case "log":
2108
+ return gitLog(subArgs, effectiveCtx);
2109
+ case "diff":
2110
+ return gitDiff(subArgs, effectiveCtx);
2111
+ case "branch":
2112
+ return gitBranch(subArgs, effectiveCtx);
2113
+ case "checkout":
2114
+ return gitCheckout(subArgs, effectiveCtx);
2115
+ case "switch":
2116
+ return gitSwitch(subArgs, effectiveCtx);
2117
+ case "merge":
2118
+ return gitMerge(subArgs, effectiveCtx);
2119
+ case "remote":
2120
+ return gitRemote(subArgs, effectiveCtx);
2121
+ case "push":
2122
+ return gitPush(subArgs, effectiveCtx);
2123
+ case "pull":
2124
+ return gitPull(subArgs, effectiveCtx);
2125
+ case "fetch":
2126
+ return gitFetch(subArgs, effectiveCtx);
2127
+ case "stash":
2128
+ return gitStash(subArgs, effectiveCtx);
2129
+ case "reset":
2130
+ return gitReset(subArgs, effectiveCtx);
2131
+ case "rm":
2132
+ return gitRm(subArgs, effectiveCtx);
2133
+ case "rev-parse":
2134
+ return gitRevParse(subArgs, effectiveCtx);
2135
+ case "config":
2136
+ return gitConfig(subArgs, effectiveCtx);
2137
+ default:
2138
+ return fail(`git: '${sub}' is not a git command. See 'git --help'.\n`);
2139
+ }
2140
+ },
2141
+ };
2142
+ }