guito 0.4.0 → 0.5.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.
@@ -0,0 +1,727 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { execFile } from 'node:child_process';
3
+ import { readFile, rm } from 'node:fs/promises';
4
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import fastify from 'fastify';
6
+ import fastifyStatic from '@fastify/static';
7
+ import fastifyCors from '@fastify/cors';
8
+ import { simpleGit } from 'simple-git';
9
+ import { promisify } from 'node:util';
10
+ const execFileAsync = promisify(execFile);
11
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, }) {
12
+ // Initialize server
13
+ const app = fastify({
14
+ logger: false,
15
+ });
16
+ // Register cors
17
+ await app.register(fastifyCors);
18
+ if (apiToken) {
19
+ app.addHook('onRequest', async (request, reply) => {
20
+ const pathname = request.url.split('?', 1)[0];
21
+ if (!pathname.startsWith('/api/')) {
22
+ return;
23
+ }
24
+ const headerToken = request.headers['x-guito-token'];
25
+ const queryToken = request.query?.guitoToken;
26
+ const archiveToken = pathname === '/api/archive' ? queryToken : undefined;
27
+ if (headerToken !== apiToken && archiveToken !== apiToken) {
28
+ await reply.status(401).type('application/json').send({ error: 'unauthorized' });
29
+ }
30
+ });
31
+ }
32
+ // Register static file provider (supports both `ui/` and `ui/browser/` layouts)
33
+ const staticRoot = existsSync(join(uiRoot, 'browser', 'index.html'))
34
+ ? join(uiRoot, 'browser')
35
+ : uiRoot;
36
+ if (existsSync(staticRoot)) {
37
+ await app.register(fastifyStatic, { root: staticRoot });
38
+ // Serve UI
39
+ app.get('/', (_req, resp) => resp.sendFile('index.html'));
40
+ }
41
+ else {
42
+ app.get('/', (_req, resp) => resp.send('Guito API is running. Build the UI with `npm run build:ui`.'));
43
+ }
44
+ // Initialize git lib
45
+ const git = simpleGit(repositoryPath);
46
+ // ==================== Diff parsing ====================
47
+ function parseUnifiedDiff(rawDiff) {
48
+ const files = [];
49
+ const lines = rawDiff.split('\n');
50
+ let current = null;
51
+ let inHunk = false;
52
+ let oldLine = 0;
53
+ let newLine = 0;
54
+ const startFile = (line) => {
55
+ current = {
56
+ path: '',
57
+ oldPath: '',
58
+ status: 'modified',
59
+ lines: [],
60
+ additions: 0,
61
+ deletions: 0,
62
+ };
63
+ const match = line.match(/^diff --git a\/(.*) b\/(.*)$/);
64
+ if (match) {
65
+ current.oldPath = match[1];
66
+ current.path = match[2];
67
+ }
68
+ inHunk = false;
69
+ };
70
+ for (const line of lines) {
71
+ if (line.startsWith('diff --git ')) {
72
+ if (current)
73
+ files.push(current);
74
+ startFile(line);
75
+ continue;
76
+ }
77
+ if (!current)
78
+ continue;
79
+ if (line.startsWith('@@')) {
80
+ inHunk = true;
81
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
82
+ if (match) {
83
+ oldLine = parseInt(match[1], 10);
84
+ newLine = parseInt(match[2], 10);
85
+ }
86
+ current.lines.push({ type: 'hunk', text: line });
87
+ continue;
88
+ }
89
+ if (!inHunk) {
90
+ // File meta headers (mode, index, rename, binary...)
91
+ if (line.startsWith('new file mode'))
92
+ current.status = 'added';
93
+ else if (line.startsWith('deleted file mode'))
94
+ current.status = 'deleted';
95
+ else if (line.startsWith('rename from '))
96
+ current.status = 'renamed';
97
+ else if (line.startsWith('rename to '))
98
+ current.path = line.slice('rename to '.length);
99
+ else if (line.startsWith('Binary files') || line.startsWith('GIT binary patch'))
100
+ current.status = 'binary';
101
+ continue;
102
+ }
103
+ if (line.startsWith('\\')) {
104
+ // ""
105
+ continue;
106
+ }
107
+ if (line.startsWith('+')) {
108
+ current.lines.push({ type: 'add', newLine: newLine++, text: line.slice(1) });
109
+ current.additions++;
110
+ }
111
+ else if (line.startsWith('-')) {
112
+ current.lines.push({ type: 'del', oldLine: oldLine++, text: line.slice(1) });
113
+ current.deletions++;
114
+ }
115
+ else {
116
+ current.lines.push({
117
+ type: 'context',
118
+ oldLine: oldLine++,
119
+ newLine: newLine++,
120
+ text: line.slice(1),
121
+ });
122
+ }
123
+ }
124
+ if (current)
125
+ files.push(current);
126
+ return files;
127
+ }
128
+ const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
129
+ // ==================== Repository ====================
130
+ app.get('/api/repo', async (_req, resp) => {
131
+ try {
132
+ const root = (await git.revparse(['--show-toplevel'])).trim();
133
+ resp.type('application/json').send({ root, name: basename(root) });
134
+ }
135
+ catch (err) {
136
+ resp.status(400).type('application/json').send({ error: err.message });
137
+ }
138
+ });
139
+ // ==================== Commits ====================
140
+ app.get('/api/commits', async (_req, resp) => {
141
+ try {
142
+ const log = await git.log({
143
+ format: {
144
+ hash: '%H',
145
+ date: '%aI',
146
+ message: '%s',
147
+ refs: '%D',
148
+ body: '%b',
149
+ author_name: '%aN',
150
+ author_email: '%aE',
151
+ parents: '%P',
152
+ },
153
+ });
154
+ const commits = log.all.map((commit) => ({
155
+ ...commit,
156
+ parents: String(commit.parents ?? '')
157
+ .split(' ')
158
+ .filter(Boolean),
159
+ }));
160
+ resp.type('application/json').send(commits);
161
+ }
162
+ catch (err) {
163
+ resp.status(400).type('application/json').send({ error: err.message });
164
+ }
165
+ });
166
+ app.post('/api/commit', async (req, resp) => {
167
+ try {
168
+ const { message, description } = req.body;
169
+ await git.commit([message, ...(description ? [description] : [])], { '--allow-empty': null });
170
+ resp.type('application/json').send({ success: true });
171
+ }
172
+ catch (err) {
173
+ resp.status(400).type('application/json').send({ error: err.message });
174
+ }
175
+ });
176
+ app.post('/api/amend', async (req, resp) => {
177
+ try {
178
+ const { message, description } = req.body;
179
+ await git.commit([message, ...(description ? [description] : [])], {
180
+ '--amend': null,
181
+ '--no-edit': null,
182
+ });
183
+ resp.type('application/json').send({ success: true });
184
+ }
185
+ catch (err) {
186
+ resp.status(400).type('application/json').send({ error: err.message });
187
+ }
188
+ });
189
+ app.post('/api/revert', async (req, resp) => {
190
+ try {
191
+ const { commit } = req.body;
192
+ await git.revert(commit);
193
+ resp.type('application/json').send({ success: true });
194
+ }
195
+ catch (err) {
196
+ resp.status(400).type('application/json').send({ error: err.message });
197
+ }
198
+ });
199
+ app.post('/api/cherry-pick', async (req, resp) => {
200
+ try {
201
+ await git.raw(['cherry-pick', req.body.commit]);
202
+ resp.type('application/json').send({ success: true });
203
+ }
204
+ catch (err) {
205
+ resp.status(400).type('application/json').send({ error: err.message });
206
+ }
207
+ });
208
+ app.post('/api/commit/drop', async (req, resp) => {
209
+ try {
210
+ await git.raw(['reset', '--hard', `${req.body.commit}^`]);
211
+ resp.type('application/json').send({ success: true });
212
+ }
213
+ catch (err) {
214
+ resp.status(400).type('application/json').send({ error: err.message });
215
+ }
216
+ });
217
+ app.post('/api/reset-commit', async (req, resp) => {
218
+ try {
219
+ await git.raw(['reset', '--hard', req.body.commit]);
220
+ resp.type('application/json').send({ success: true });
221
+ }
222
+ catch (err) {
223
+ resp.status(400).type('application/json').send({ error: err.message });
224
+ }
225
+ });
226
+ // ==================== Staging ====================
227
+ app.get('/api/status', async (_req, resp) => {
228
+ try {
229
+ const status = await git.status();
230
+ resp.type('application/json').send(status);
231
+ }
232
+ catch (err) {
233
+ resp.status(400).type('application/json').send({ error: err.message });
234
+ }
235
+ });
236
+ app.post('/api/stage', async (req, resp) => {
237
+ try {
238
+ const { files } = req.body;
239
+ if (Array.isArray(files) && files.length > 0) {
240
+ await git.add(files);
241
+ }
242
+ resp.type('application/json').send({ success: true });
243
+ }
244
+ catch (err) {
245
+ resp.status(400).type('application/json').send({ error: err.message });
246
+ }
247
+ });
248
+ app.post('/api/unstage', async (req, resp) => {
249
+ try {
250
+ const { files } = req.body;
251
+ if (Array.isArray(files) && files.length > 0) {
252
+ await git.reset(['HEAD', ...files]);
253
+ }
254
+ resp.type('application/json').send({ success: true });
255
+ }
256
+ catch (err) {
257
+ resp.status(400).type('application/json').send({ error: err.message });
258
+ }
259
+ });
260
+ app.post('/api/stage-lines', async (req, resp) => {
261
+ try {
262
+ const { file, patch } = req.body;
263
+ // Stage specific lines using git add -p equivalent (patch mode)
264
+ await git.raw('add --patch', { input: patch });
265
+ resp.type('application/json').send({ success: true });
266
+ }
267
+ catch (err) {
268
+ resp.status(400).type('application/json').send({ error: err.message });
269
+ }
270
+ });
271
+ // ==================== Branches ====================
272
+ app.get('/api/branches/all', async (_req, resp) => {
273
+ try {
274
+ const raw = await git.raw([
275
+ 'for-each-ref',
276
+ '--format=%(refname)%09%(objectname)%09%(HEAD)',
277
+ 'refs/heads',
278
+ 'refs/remotes',
279
+ ]);
280
+ const branches = raw
281
+ .split('\n')
282
+ .map((line) => line.trim())
283
+ .filter(Boolean)
284
+ .map((line) => {
285
+ const [refname, commit, headMarker] = line.split('\t');
286
+ const remote = refname.startsWith('refs/remotes/');
287
+ const name = refname.replace(/^refs\/(heads|remotes)\//, '');
288
+ return { name, commit, current: headMarker === '*', remote };
289
+ });
290
+ resp.type('application/json').send(branches);
291
+ }
292
+ catch (err) {
293
+ resp.status(400).type('application/json').send({ error: err.message });
294
+ }
295
+ });
296
+ app.get('/api/branches', async (_req, resp) => {
297
+ try {
298
+ const branchSummary = await git.branchLocal();
299
+ resp.type('application/json').send(branchSummary);
300
+ }
301
+ catch (err) {
302
+ resp.status(400).type('application/json').send({ error: err.message });
303
+ }
304
+ });
305
+ app.post('/api/branch/create', async (req, resp) => {
306
+ try {
307
+ const { name, startPoint } = req.body;
308
+ await git.branch([...(startPoint ? [name, startPoint] : [name])]);
309
+ resp.type('application/json').send({ success: true });
310
+ }
311
+ catch (err) {
312
+ resp.status(400).type('application/json').send({ error: err.message });
313
+ }
314
+ });
315
+ app.post('/api/branch/delete', async (req, resp) => {
316
+ try {
317
+ const { name, force } = req.body;
318
+ await git.deleteLocalBranch(name, force);
319
+ resp.type('application/json').send({ success: true });
320
+ }
321
+ catch (err) {
322
+ resp.status(400).type('application/json').send({ error: err.message });
323
+ }
324
+ });
325
+ app.post('/api/branch/delete-remote', async (req, resp) => {
326
+ try {
327
+ const { remote, branch } = req.body;
328
+ await git.push([remote, '--delete', branch]);
329
+ resp.type('application/json').send({ success: true });
330
+ }
331
+ catch (err) {
332
+ resp.status(400).type('application/json').send({ error: err.message });
333
+ }
334
+ });
335
+ app.post('/api/branch/rename', async (req, resp) => {
336
+ try {
337
+ const { oldName, newName } = req.body;
338
+ await git.branch(['-m', oldName, newName]);
339
+ resp.type('application/json').send({ success: true });
340
+ }
341
+ catch (err) {
342
+ resp.status(400).type('application/json').send({ error: err.message });
343
+ }
344
+ });
345
+ app.post('/api/checkout', async (req, resp) => {
346
+ try {
347
+ const { ref } = req.body;
348
+ await git.checkout(ref);
349
+ resp.type('application/json').send({ success: true });
350
+ }
351
+ catch (err) {
352
+ resp.status(400).type('application/json').send({ error: err.message });
353
+ }
354
+ });
355
+ // ==================== Merge & Rebase ====================
356
+ app.post('/api/merge', async (req, resp) => {
357
+ try {
358
+ const { branch } = req.body;
359
+ await git.merge([branch]);
360
+ resp.type('application/json').send({ success: true });
361
+ }
362
+ catch (err) {
363
+ resp.status(400).type('application/json').send({ error: err.message });
364
+ }
365
+ });
366
+ app.get('/api/archive', async (req, resp) => {
367
+ try {
368
+ const ref = String(req.query?.ref ?? 'HEAD');
369
+ if (!/^[0-9a-fA-F]{7,40}$/.test(ref) && !/^[A-Za-z0-9._/-]+$/.test(ref)) {
370
+ return resp.status(400).type('application/json').send({ error: 'invalid ref' });
371
+ }
372
+ const result = await execFileAsync('git', ['archive', '--format=zip', ref], {
373
+ cwd: repositoryPath,
374
+ encoding: 'buffer',
375
+ maxBuffer: 100 * 1024 * 1024,
376
+ });
377
+ return resp
378
+ .header('Content-Disposition', `attachment; filename="guito-${ref.slice(0, 8)}.zip"`)
379
+ .type('application/zip')
380
+ .send(result.stdout);
381
+ }
382
+ catch (err) {
383
+ return resp.status(400).type('application/json').send({ error: err.message });
384
+ }
385
+ });
386
+ app.post('/api/rebase', async (req, resp) => {
387
+ try {
388
+ const { branch } = req.body;
389
+ await git.rebase([branch]);
390
+ resp.type('application/json').send({ success: true });
391
+ }
392
+ catch (err) {
393
+ resp.status(400).type('application/json').send({ error: err.message });
394
+ }
395
+ });
396
+ app.post('/api/squash', async (req, resp) => {
397
+ try {
398
+ const { commits } = req.body;
399
+ // Squash: rebase -i with squash operation
400
+ await git.rebase(['-i', 'HEAD~' + commits]);
401
+ resp.type('application/json').send({ success: true });
402
+ }
403
+ catch (err) {
404
+ resp.status(400).type('application/json').send({ error: err.message });
405
+ }
406
+ });
407
+ // ==================== Stash ====================
408
+ app.get('/api/stash/list', async (_req, resp) => {
409
+ try {
410
+ const stashList = await git.stashList();
411
+ resp.type('application/json').send(stashList);
412
+ }
413
+ catch (err) {
414
+ resp.status(400).type('application/json').send({ error: err.message });
415
+ }
416
+ });
417
+ app.post('/api/stash/save', async (req, resp) => {
418
+ try {
419
+ const { message } = req.body;
420
+ await git.stash(['save', message || '']);
421
+ resp.type('application/json').send({ success: true });
422
+ }
423
+ catch (err) {
424
+ resp.status(400).type('application/json').send({ error: err.message });
425
+ }
426
+ });
427
+ app.post('/api/stash/apply', async (req, resp) => {
428
+ try {
429
+ const { index } = req.body;
430
+ await git.stash(['apply', `stash@{${index}}`]);
431
+ resp.type('application/json').send({ success: true });
432
+ }
433
+ catch (err) {
434
+ resp.status(400).type('application/json').send({ error: err.message });
435
+ }
436
+ });
437
+ app.post('/api/stash/pop', async (req, resp) => {
438
+ try {
439
+ const { index } = req.body;
440
+ await git.stash(['pop', `stash@{${index}}`]);
441
+ resp.type('application/json').send({ success: true });
442
+ }
443
+ catch (err) {
444
+ resp.status(400).type('application/json').send({ error: err.message });
445
+ }
446
+ });
447
+ app.post('/api/stash/drop', async (req, resp) => {
448
+ try {
449
+ const { index } = req.body;
450
+ await git.stash(['drop', `stash@{${index}}`]);
451
+ resp.type('application/json').send({ success: true });
452
+ }
453
+ catch (err) {
454
+ resp.status(400).type('application/json').send({ error: err.message });
455
+ }
456
+ });
457
+ app.post('/api/stash/show', async (req, resp) => {
458
+ try {
459
+ const { index } = req.body;
460
+ const show = await git.stash(['show', '-p', `stash@{${index}}`]);
461
+ resp.type('application/json').send({ preview: show });
462
+ }
463
+ catch (err) {
464
+ resp.status(400).type('application/json').send({ error: err.message });
465
+ }
466
+ });
467
+ // ==================== Tags ====================
468
+ app.get('/api/tags', async (_req, resp) => {
469
+ try {
470
+ const tags = await git.tags();
471
+ resp.type('application/json').send(tags);
472
+ }
473
+ catch (err) {
474
+ resp.status(400).type('application/json').send({ error: err.message });
475
+ }
476
+ });
477
+ app.post('/api/tag/create', async (req, resp) => {
478
+ try {
479
+ const { name, message, commit } = req.body;
480
+ if (message) {
481
+ await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
482
+ }
483
+ else {
484
+ await git.tag([name, ...(commit ? [commit] : [])]);
485
+ }
486
+ resp.type('application/json').send({ success: true });
487
+ }
488
+ catch (err) {
489
+ resp.status(400).type('application/json').send({ error: err.message });
490
+ }
491
+ });
492
+ app.post('/api/tag/delete', async (req, resp) => {
493
+ try {
494
+ const { name } = req.body;
495
+ await git.tag(['-d', name]);
496
+ resp.type('application/json').send({ success: true });
497
+ }
498
+ catch (err) {
499
+ resp.status(400).type('application/json').send({ error: err.message });
500
+ }
501
+ });
502
+ // ==================== Remote Operations ====================
503
+ app.get('/api/fetch', async (_req, resp) => {
504
+ try {
505
+ await git.fetch();
506
+ resp.type('application/json').send({ success: true });
507
+ }
508
+ catch (err) {
509
+ resp.status(400).type('application/json').send({ error: err.message });
510
+ }
511
+ });
512
+ app.post('/api/pull', async (req, resp) => {
513
+ try {
514
+ const { remote, branch, rebase } = req.body;
515
+ await git.pull(remote || 'origin', branch || undefined, rebase ? { '--rebase': null } : {});
516
+ resp.type('application/json').send({ success: true });
517
+ }
518
+ catch (err) {
519
+ resp.status(400).type('application/json').send({ error: err.message });
520
+ }
521
+ });
522
+ app.post('/api/push', async (req, resp) => {
523
+ try {
524
+ const { remote, branch, force } = req.body;
525
+ await git.push(remote || 'origin', branch || undefined, force ? { '-f': null } : {});
526
+ resp.type('application/json').send({ success: true });
527
+ }
528
+ catch (err) {
529
+ resp.status(400).type('application/json').send({ error: err.message });
530
+ }
531
+ });
532
+ app.post('/api/sync', async (_req, resp) => {
533
+ try {
534
+ await git.pull('origin', undefined, {});
535
+ await git.push('origin', undefined, {});
536
+ resp.type('application/json').send({ success: true });
537
+ }
538
+ catch (err) {
539
+ resp.status(400).type('application/json').send({ error: err.message });
540
+ }
541
+ });
542
+ app.post('/api/prune', async (req, resp) => {
543
+ try {
544
+ const { remote } = req.body;
545
+ await git.remote(['prune', remote || 'origin']);
546
+ resp.type('application/json').send({ success: true });
547
+ }
548
+ catch (err) {
549
+ resp.status(400).type('application/json').send({ error: err.message });
550
+ }
551
+ });
552
+ // ==================== Diff Viewer ====================
553
+ app.post('/api/commit/diff', async (req, resp) => {
554
+ try {
555
+ const { hash } = req.body;
556
+ if (!hash) {
557
+ return resp.status(400).type('application/json').send({ error: 'hash required' });
558
+ }
559
+ // Get the raw unified diff (diff against the first parent for merges)
560
+ const rawDiff = await git.raw([
561
+ 'show',
562
+ '--no-color',
563
+ '--pretty=format:',
564
+ '--find-renames',
565
+ '--first-parent',
566
+ '-m',
567
+ hash,
568
+ ]);
569
+ resp.type('application/json').send({ hash, files: parseUnifiedDiff(rawDiff) });
570
+ }
571
+ catch (err) {
572
+ resp.status(400).type('application/json').send({ error: err.message });
573
+ }
574
+ });
575
+ // ==================== Working Changes ====================
576
+ app.get('/api/working-changes', async (_req, resp) => {
577
+ try {
578
+ const root = await repoRoot();
579
+ // Combined diff of the working tree + index against HEAD.
580
+ const rawDiff = await git.raw(['diff', 'HEAD', '--no-color', '--find-renames']);
581
+ const files = parseUnifiedDiff(rawDiff);
582
+ // Untracked files are not part of `git diff` — add them as new files.
583
+ const status = await git.status();
584
+ for (const file of status.not_added ?? []) {
585
+ let isBinary = false;
586
+ let content = '';
587
+ try {
588
+ const buffer = await readFile(join(root, file));
589
+ if (buffer.includes(0)) {
590
+ isBinary = true;
591
+ }
592
+ else {
593
+ content = buffer.toString('utf8');
594
+ }
595
+ }
596
+ catch {
597
+ content = '';
598
+ }
599
+ if (isBinary) {
600
+ files.push({
601
+ path: file,
602
+ oldPath: '',
603
+ status: 'binary',
604
+ lines: [],
605
+ additions: 0,
606
+ deletions: 0,
607
+ });
608
+ }
609
+ else {
610
+ const lines = content.length > 0 ? content.split('\n') : [];
611
+ files.push({
612
+ path: file,
613
+ oldPath: '',
614
+ status: 'added',
615
+ lines: lines.map((text, index) => ({ type: 'add', newLine: index + 1, text })),
616
+ additions: lines.length,
617
+ deletions: 0,
618
+ });
619
+ }
620
+ }
621
+ const stagedRaw = await git.raw(['diff', '--name-only', '--cached']);
622
+ const unstagedRaw = await git.raw(['diff', '--name-only']);
623
+ resp.type('application/json').send({
624
+ files,
625
+ staged: stagedRaw.split('\n').filter(Boolean),
626
+ unstaged: unstagedRaw.split('\n').filter(Boolean),
627
+ untracked: status.not_added ?? [],
628
+ });
629
+ }
630
+ catch (err) {
631
+ resp.status(400).type('application/json').send({ error: err.message });
632
+ }
633
+ });
634
+ // ==================== File Content ====================
635
+ app.post('/api/file-content', async (req, resp) => {
636
+ try {
637
+ const { path, ref } = req.body;
638
+ if (!path || !ref) {
639
+ return resp.status(400).type('application/json').send({ error: 'path and ref required' });
640
+ }
641
+ if (ref === 'WORKING') {
642
+ const root = await repoRoot();
643
+ const abs = resolve(root, path);
644
+ const rel = relative(root, abs);
645
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
646
+ return resp.status(400).type('application/json').send({ error: 'invalid path' });
647
+ }
648
+ try {
649
+ const buffer = await readFile(abs);
650
+ if (buffer.includes(0)) {
651
+ return resp.type('application/json').send({ binary: true, content: '' });
652
+ }
653
+ return resp.type('application/json').send({ content: buffer.toString('utf8') });
654
+ }
655
+ catch {
656
+ // File no longer exists on disk (deleted).
657
+ return resp.type('application/json').send({ content: '' });
658
+ }
659
+ }
660
+ if (ref === 'EMPTY') {
661
+ return resp.type('application/json').send({ content: '' });
662
+ }
663
+ try {
664
+ const content = await git.raw(['show', `${ref}:${path}`]);
665
+ if (content.includes('\u0000')) {
666
+ return resp.type('application/json').send({ binary: true, content: '' });
667
+ }
668
+ resp.type('application/json').send({ content });
669
+ }
670
+ catch {
671
+ // Path did not exist at that revision (added file).
672
+ resp.type('application/json').send({ content: '' });
673
+ }
674
+ }
675
+ catch (err) {
676
+ resp.status(400).type('application/json').send({ error: err.message });
677
+ }
678
+ });
679
+ // ==================== Discard ====================
680
+ app.post('/api/discard', async (req, resp) => {
681
+ try {
682
+ const { files } = req.body;
683
+ if (!Array.isArray(files) || files.length === 0) {
684
+ return resp.status(400).type('application/json').send({ error: 'files required' });
685
+ }
686
+ const root = await repoRoot();
687
+ const status = await git.status();
688
+ const untracked = new Set(status.not_added ?? []);
689
+ const tracked = files.filter((file) => !untracked.has(file));
690
+ const removed = files.filter((file) => untracked.has(file));
691
+ if (tracked.length > 0) {
692
+ await git.raw(['checkout', 'HEAD', '--', ...tracked]);
693
+ }
694
+ for (const file of removed) {
695
+ await rm(join(root, file), { force: true, recursive: true });
696
+ }
697
+ resp.type('application/json').send({ success: true });
698
+ }
699
+ catch (err) {
700
+ resp.status(400).type('application/json').send({ error: err.message });
701
+ }
702
+ });
703
+ // ==================== Working Tree Actions ====================
704
+ app.post('/api/reset', async (_req, resp) => {
705
+ try {
706
+ await git.raw(['reset', '--hard', 'HEAD']);
707
+ resp.type('application/json').send({ ok: true });
708
+ }
709
+ catch (err) {
710
+ resp.status(400).type('application/json').send({ error: err.message });
711
+ }
712
+ });
713
+ app.post('/api/clean', async (_req, resp) => {
714
+ try {
715
+ await git.raw(['clean', '-fd']);
716
+ resp.type('application/json').send({ ok: true });
717
+ }
718
+ catch (err) {
719
+ resp.status(400).type('application/json').send({ error: err.message });
720
+ }
721
+ });
722
+ const address = await app.listen({ port, ...(host ? { host } : {}) });
723
+ return {
724
+ address,
725
+ close: () => app.close(),
726
+ };
727
+ }