megit-app 0.1.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.
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/bin/megit.js +54 -0
- package/dist/assets/DiffView-CJb6qgum.js +143 -0
- package/dist/assets/DiffView-DhpiFlgV.css +1 -0
- package/dist/assets/TerminalPanel-BrP-ENHg.css +1 -0
- package/dist/assets/TerminalPanel-Cwtdgirw.js +37 -0
- package/dist/assets/index-CfQ1U4Ld.js +249 -0
- package/dist/assets/index-DZyh3SKi.css +1 -0
- package/dist/index.html +17 -0
- package/dist/logo.svg +7 -0
- package/dist-server/avatars.js +83 -0
- package/dist-server/config.js +24 -0
- package/dist-server/index.js +805 -0
- package/dist-server/parse.js +144 -0
- package/dist-server/term.js +113 -0
- package/dist-server/watch.js +104 -0
- package/package.json +69 -0
- package/scripts/fix-pty-perms.mjs +21 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
+
import { loadConfig, saveConfig, isPermutation } from './config.js';
|
|
8
|
+
import { resolveAvatar, parseGithubRemote } from './avatars.js';
|
|
9
|
+
import { mergeMatches, parseBranchHeader, parseLog, parseMatches, parseMeta, parseNameStatus, parseStatus, stashIndex, LOG_FORMAT, META_FORMAT } from './parse.js';
|
|
10
|
+
import { subscribe } from './watch.js';
|
|
11
|
+
import { wireTerminal, hasPty } from './term.js';
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
// The server listens on loopback only, but that alone doesn't stop a page on
|
|
15
|
+
// attacker.tld from rebinding its DNS to 127.0.0.1: the browser then treats this
|
|
16
|
+
// API as same-origin and CORS never applies. Pinning Host closes that — a rebound
|
|
17
|
+
// request still carries the attacker's hostname. (/api/term does the same with Origin.)
|
|
18
|
+
app.use((req, res, next) => {
|
|
19
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(req.headers.host ?? '')) {
|
|
20
|
+
res.status(403).json({ error: 'forbidden host' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
// git treats a leading-dash rev as an option (`git diff --output=<file>` writes files),
|
|
26
|
+
// so revs from the client are whitelisted rather than escaped
|
|
27
|
+
const isSha = (s) => /^[0-9a-f]{4,40}$/.test(s);
|
|
28
|
+
// No git invocation may block on a prompt: a passphrase-protected key, an expired
|
|
29
|
+
// token or a missing credential helper has to fail fast. Without these, a push
|
|
30
|
+
// waits on stdin that no one is attached to and the request never returns.
|
|
31
|
+
const GIT_ENV = {
|
|
32
|
+
...process.env,
|
|
33
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
34
|
+
GIT_ASKPASS: 'echo',
|
|
35
|
+
SSH_ASKPASS: 'echo',
|
|
36
|
+
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
|
37
|
+
};
|
|
38
|
+
// core.quotePath=false: git otherwise C-quotes non-ASCII paths in every kind of
|
|
39
|
+
// output, and a quoted literal fed back as a pathspec matches nothing. The `-z`
|
|
40
|
+
// readers below don't need it, but `git show`/`ls-files`/error messages do.
|
|
41
|
+
const QUOTE_PATH = ['-c', 'core.quotePath=false'];
|
|
42
|
+
function git(repo, args, okCodes = [0], timeout = 0, env) {
|
|
43
|
+
return new Promise((res, rej) => {
|
|
44
|
+
execFile('git', ['-C', repo, ...QUOTE_PATH, ...args], { maxBuffer: 50 * 1024 * 1024, env: { ...GIT_ENV, ...env }, timeout }, (err, stdout, stderr) => {
|
|
45
|
+
if (err && err.killed) {
|
|
46
|
+
rej(new Error(`git ${args[0]} timed out after ${timeout / 1000}s`));
|
|
47
|
+
}
|
|
48
|
+
else if (err && !okCodes.includes(typeof err.code === 'number' ? err.code : 1)) {
|
|
49
|
+
rej(new Error(stderr.trim() || err.message));
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
res(stdout);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const repoGuard = (req, res, next) => {
|
|
57
|
+
const repo = String(req.query.repo ?? '');
|
|
58
|
+
if (!loadConfig().repos.includes(repo)) {
|
|
59
|
+
res.status(400).json({ error: 'unknown repo' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!existsSync(repo)) {
|
|
63
|
+
res.status(410).json({ error: 'repository path no longer exists' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
next();
|
|
67
|
+
};
|
|
68
|
+
// Every route that hands the client a Config goes through this, so the terminal
|
|
69
|
+
// capability can't be clobbered by a later /api/active or /api/repos response.
|
|
70
|
+
const withCaps = (c) => ({ ...c, hasTerminal: hasPty() });
|
|
71
|
+
app.get('/api/config', (_req, res) => res.json(withCaps(loadConfig())));
|
|
72
|
+
app.post('/api/repos', async (req, res) => {
|
|
73
|
+
const path = resolve(String(req.body.path ?? ''));
|
|
74
|
+
try {
|
|
75
|
+
await git(path, ['rev-parse', '--git-dir']);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
res.status(400).json({ error: `not a git repository: ${path}` });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const cfg = loadConfig();
|
|
82
|
+
if (!cfg.repos.includes(path))
|
|
83
|
+
cfg.repos.push(path);
|
|
84
|
+
cfg.activeRepo = path;
|
|
85
|
+
saveConfig(cfg);
|
|
86
|
+
res.json(withCaps(cfg));
|
|
87
|
+
});
|
|
88
|
+
app.delete('/api/repos', (req, res) => {
|
|
89
|
+
const path = String(req.query.repo ?? '');
|
|
90
|
+
const cfg = loadConfig();
|
|
91
|
+
cfg.repos = cfg.repos.filter(r => r !== path);
|
|
92
|
+
if (cfg.activeRepo === path)
|
|
93
|
+
cfg.activeRepo = cfg.repos[0] ?? null;
|
|
94
|
+
saveConfig(cfg);
|
|
95
|
+
res.json(withCaps(cfg));
|
|
96
|
+
});
|
|
97
|
+
app.put('/api/active', (req, res) => {
|
|
98
|
+
const cfg = loadConfig();
|
|
99
|
+
const repo = String(req.body.repo ?? '');
|
|
100
|
+
if (cfg.repos.includes(repo)) {
|
|
101
|
+
cfg.activeRepo = repo;
|
|
102
|
+
saveConfig(cfg);
|
|
103
|
+
}
|
|
104
|
+
res.json(withCaps(cfg));
|
|
105
|
+
});
|
|
106
|
+
app.put('/api/repos/order', (req, res) => {
|
|
107
|
+
const repos = req.body?.repos;
|
|
108
|
+
const cfg = loadConfig();
|
|
109
|
+
if (!Array.isArray(repos) || !isPermutation(repos, cfg.repos)) {
|
|
110
|
+
res.status(400).json({ error: 'invalid repo order' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
cfg.repos = repos;
|
|
114
|
+
saveConfig(cfg);
|
|
115
|
+
res.json(withCaps(cfg));
|
|
116
|
+
});
|
|
117
|
+
app.get('/api/fs', (req, res) => {
|
|
118
|
+
const path = resolve(String(req.query.path ?? homedir()));
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
res.status(400).json({ error: e.message });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const dirs = entries
|
|
128
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
129
|
+
.map(e => ({ name: e.name, path: join(path, e.name), isRepo: existsSync(join(path, e.name, '.git')) }))
|
|
130
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
131
|
+
const parent = dirname(path);
|
|
132
|
+
res.json({ path, parent: parent === path ? null : parent, dirs, isRepo: existsSync(join(path, '.git')) });
|
|
133
|
+
});
|
|
134
|
+
app.get('/api/graph', repoGuard, async (req, res) => {
|
|
135
|
+
const repo = String(req.query.repo);
|
|
136
|
+
const skip = Number(req.query.skip) || 0;
|
|
137
|
+
// capped: the client asks for as many as it has loaded, which grows a page at a
|
|
138
|
+
// time, but an uncapped limit turns one request into the whole history (4.2 MB /
|
|
139
|
+
// 380 ms on a 14.8k-commit repo). 5000 rows is far past where the DOM gives out.
|
|
140
|
+
const limit = Math.min(5000, Math.max(1, Number(req.query.limit) || 200));
|
|
141
|
+
try {
|
|
142
|
+
const stashRaw = await git(repo, ['stash', 'list', '--format=%H%x1f%P%x1f%ct%x1f%s']).catch(() => '');
|
|
143
|
+
const stashes = stashRaw.split('\n').filter(Boolean).map(l => {
|
|
144
|
+
const [hash, parents, date, subject] = l.split('\x1f');
|
|
145
|
+
return { hash, parent: parents.split(' ')[0], date: Number(date), subject };
|
|
146
|
+
});
|
|
147
|
+
const [raw, remoteRaw, originUrl] = await Promise.all([
|
|
148
|
+
// Whitelist the tips, don't use --all: --all means every ref under refs/, which drags in
|
|
149
|
+
// tool-written namespaces (agent checkpoints, refs/prefetch from git maintenance, refs/bisect)
|
|
150
|
+
// that are noise in the graph — and each parentless one burns a lane. HEAD covers detached.
|
|
151
|
+
// Stash bases are added as explicit tips — a reset/dropped branch can leave a base
|
|
152
|
+
// reachable only through its stash, and it must still show in the graph. The stash commits
|
|
153
|
+
// themselves stay out (refs/stash isn't a branch/tag/remote) and render as stash rows.
|
|
154
|
+
// --date-order: children still precede parents (lane layout invariant), but
|
|
155
|
+
// branches interleave by commit date, GitKraken-style — --topo-order would list
|
|
156
|
+
// HEAD's whole branch chain before any other branch appears.
|
|
157
|
+
git(repo, ['log', 'HEAD', '--branches', '--tags', '--remotes', ...stashes.map(s => s.parent), '--date-order', `--skip=${skip}`, `--max-count=${limit + 1}`, `--format=${LOG_FORMAT}`]),
|
|
158
|
+
git(repo, ['remote']),
|
|
159
|
+
git(repo, ['remote', 'get-url', 'origin']).catch(() => ''),
|
|
160
|
+
]);
|
|
161
|
+
const commits = parseLog(raw);
|
|
162
|
+
const remotes = remoteRaw.split('\n').filter(Boolean);
|
|
163
|
+
const gh = parseGithubRemote(originUrl);
|
|
164
|
+
res.json({ commits: commits.slice(0, limit), hasMore: commits.length > limit, remotes, stashes, githubUrl: gh ? `https://github.com/${gh.owner}/${gh.repo}` : null });
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
const msg = e.message;
|
|
168
|
+
if (/does not have any commits yet/.test(msg)) {
|
|
169
|
+
res.json({ commits: [], hasMore: false, remotes: [] });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
res.status(500).json({ error: msg });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// The graph's ref whitelist, repeated rather than shared: the two lists must agree, and
|
|
176
|
+
// a comment is cheaper than a seam. A search hit must be a commit the graph can show.
|
|
177
|
+
const SEARCH_TIPS = ['HEAD', '--branches', '--tags', '--remotes'];
|
|
178
|
+
// Opt-in path only — the client filters its loaded rows for free and reaches here just
|
|
179
|
+
// when the user clicks "search all history".
|
|
180
|
+
app.get('/api/search', repoGuard, async (req, res) => {
|
|
181
|
+
const repo = String(req.query.repo);
|
|
182
|
+
const q = String(req.query.q ?? '').trim();
|
|
183
|
+
// an empty query is not a search: answer without touching git
|
|
184
|
+
if (!q) {
|
|
185
|
+
res.json({ matches: [], truncated: false });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// -F: a typed '.', '(' or '*' is a literal (verified on git 2.50.1 for --grep and
|
|
189
|
+
// --author alike). Without it a query holding '(' exits non-zero, and the regex
|
|
190
|
+
// engine's worst case becomes the client's to pick.
|
|
191
|
+
// -i: covers --grep and --author alike.
|
|
192
|
+
// --max-count=501: q='a' matches most of a 14k history; 500 is all this reports, and
|
|
193
|
+
// the 501st row is what proves there were more.
|
|
194
|
+
const scan = (pattern) => git(repo, ['log', ...SEARCH_TIPS, '--date-order', '-i', '-F', pattern, '--max-count=501', '--format=%H%x1f%ct'])
|
|
195
|
+
.then(parseMatches)
|
|
196
|
+
.catch(() => []);
|
|
197
|
+
try {
|
|
198
|
+
const [byMsg, byAuthor, byHash] = await Promise.all([
|
|
199
|
+
scan(`--grep=${q}`),
|
|
200
|
+
scan(`--author=${q}`),
|
|
201
|
+
// `--` terminates revs: without it an abbreviated sha that is also a filename is
|
|
202
|
+
// ambiguous. An unknown or ambiguous prefix exits non-zero → no hash match.
|
|
203
|
+
isSha(q)
|
|
204
|
+
? git(repo, ['log', '-1', '--format=%H%x1f%ct', q, '--']).then(parseMatches).catch(() => [])
|
|
205
|
+
: Promise.resolve([]),
|
|
206
|
+
]);
|
|
207
|
+
res.json(mergeMatches([byMsg, byAuthor, byHash]));
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
res.status(500).json({ error: e.message });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
app.get('/api/avatar', repoGuard, async (req, res) => {
|
|
214
|
+
const email = String(req.query.email ?? '');
|
|
215
|
+
if (!email) {
|
|
216
|
+
res.status(400).json({ error: 'email required' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
res.json({ url: await resolveAvatar(String(req.query.repo), email, git) });
|
|
220
|
+
});
|
|
221
|
+
app.get('/api/status', repoGuard, async (req, res) => {
|
|
222
|
+
try {
|
|
223
|
+
// --branch prepends the branch/upstream/ahead-behind headers to the output this
|
|
224
|
+
// already parses — the toolbar's Pull/Push badges for no extra git process
|
|
225
|
+
const raw = await git(String(req.query.repo), ['status', '--porcelain=v2', '-uall', '--branch', '-z']);
|
|
226
|
+
res.json({ files: parseStatus(raw), branch: parseBranchHeader(raw) });
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
res.status(500).json({ error: e.message });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// resolves to a sha, or null when the ref doesn't exist (--quiet: exit 1, empty output)
|
|
233
|
+
async function revParse(repo, ref) {
|
|
234
|
+
const out = await git(repo, ['rev-parse', '--verify', '--quiet', ref], [0, 1]);
|
|
235
|
+
return out.trim() || null;
|
|
236
|
+
}
|
|
237
|
+
// Uncommitted work goes to a stash before anything that moves the worktree, so no
|
|
238
|
+
// path can fail on (or silently carry over) a dirty tree. Returns whether it stashed.
|
|
239
|
+
async function stashIfDirty(repo, why) {
|
|
240
|
+
if ((await git(repo, ['status', '--porcelain'])).trim() === '')
|
|
241
|
+
return false;
|
|
242
|
+
await git(repo, ['stash', 'push', '-u', '-m', `megit: ${why}`]);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
app.post('/api/checkout', repoGuard, async (req, res) => {
|
|
246
|
+
const repo = String(req.query.repo);
|
|
247
|
+
const branch = String(req.body.branch ?? '');
|
|
248
|
+
const reset = req.body.reset === true;
|
|
249
|
+
// reject option-like names so the branch can never be parsed as a git flag
|
|
250
|
+
if (!branch || branch.startsWith('-')) {
|
|
251
|
+
res.status(400).json({ error: 'invalid branch name' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const dirty = (await git(repo, ['status', '--porcelain'])).trim() !== '';
|
|
256
|
+
const stash = (why) => stashIfDirty(repo, why);
|
|
257
|
+
const remotes = (await git(repo, ['remote'])).split('\n').filter(Boolean);
|
|
258
|
+
let remoteRef = null;
|
|
259
|
+
for (const r of remotes) {
|
|
260
|
+
if (await revParse(repo, `refs/remotes/${r}/${branch}`)) {
|
|
261
|
+
remoteRef = `${r}/${branch}`;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const hasLocal = !!(await revParse(repo, `refs/heads/${branch}`));
|
|
266
|
+
if (!remoteRef || !hasLocal) {
|
|
267
|
+
// plain `checkout <name>` DWIMs a remote-only branch into a local tracking branch
|
|
268
|
+
await stash(`WIP before checkout ${branch}`);
|
|
269
|
+
await git(repo, ['checkout', branch]);
|
|
270
|
+
res.json({ ok: true, stashed: dirty });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const localOnly = Number((await git(repo, ['rev-list', '--count', `${remoteRef}..refs/heads/${branch}`])).trim());
|
|
274
|
+
const remoteOnly = Number((await git(repo, ['rev-list', '--count', `refs/heads/${branch}..${remoteRef}`])).trim());
|
|
275
|
+
if (localOnly === 0) {
|
|
276
|
+
// equal or strictly behind: checkout, then fast-forward to the remote
|
|
277
|
+
await stash(`WIP before checkout ${branch}`);
|
|
278
|
+
await git(repo, ['checkout', branch]);
|
|
279
|
+
if (remoteOnly > 0)
|
|
280
|
+
await git(repo, ['merge', '--ff-only', remoteRef]);
|
|
281
|
+
res.json({ ok: true, forwarded: remoteOnly, stashed: dirty });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!reset) {
|
|
285
|
+
// diverged (or ahead): the client asks the user before anything destructive
|
|
286
|
+
res.json({ diverged: true, remoteRef, ahead: localOnly, behind: remoteOnly });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await stash(`${branch} before reset to ${remoteRef}`);
|
|
290
|
+
await git(repo, ['checkout', '-B', branch, remoteRef]);
|
|
291
|
+
res.json({ ok: true, reset: true, stashed: dirty });
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
res.status(409).json({ error: e.message });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
app.post('/api/stash', repoGuard, async (req, res) => {
|
|
298
|
+
const repo = String(req.query.repo);
|
|
299
|
+
const hash = String(req.body.hash ?? '');
|
|
300
|
+
const action = req.body.action;
|
|
301
|
+
if (action !== 'push' && (!isSha(hash) || !['pop', 'drop', 'retitle'].includes(action))) {
|
|
302
|
+
res.status(400).json({ error: 'invalid stash request' });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
if (action === 'push') {
|
|
307
|
+
const message = String(req.body.message ?? '').trim();
|
|
308
|
+
// -u: the WIP row counts untracked files, so stashing without them would
|
|
309
|
+
// leave the row standing after a "stash everything" action.
|
|
310
|
+
// --message=<msg> (not -m <msg>): the value can't be mistaken for a flag.
|
|
311
|
+
await git(repo, ['stash', 'push', '-u', ...(message ? [`--message=${message}`] : [])]);
|
|
312
|
+
res.json({ ok: true });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// the index is derived from git's own list here, never from the request, so
|
|
316
|
+
// no client string reaches argv and a concurrent drop can't misaddress this one
|
|
317
|
+
const idx = stashIndex(await git(repo, ['stash', 'list', '--format=%H']), hash);
|
|
318
|
+
if (idx < 0) {
|
|
319
|
+
res.status(409).json({ error: 'stash no longer exists — it was already popped or dropped' });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (action === 'retitle') {
|
|
323
|
+
const message = String(req.body.message ?? '').trim();
|
|
324
|
+
if (!message) {
|
|
325
|
+
res.status(400).json({ error: 'empty stash message' });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// A stash's label is its own commit message, so editing it means rewriting the
|
|
329
|
+
// commit — same tree, same parents (3 when untracked files went in), same
|
|
330
|
+
// author/committer identity and dates, so the row keeps its place in the graph.
|
|
331
|
+
const [tree, parents, an, ae, ad, cn, ce, cd] = (await git(repo, ['show', '-s', '--format=%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI', hash])).trim().split('\x1f');
|
|
332
|
+
const rewritten = (await git(repo, ['commit-tree', tree, ...parents.split(' ').flatMap(p => ['-p', p]), '-m', message], [0], 0, {
|
|
333
|
+
GIT_AUTHOR_NAME: an, GIT_AUTHOR_EMAIL: ae, GIT_AUTHOR_DATE: ad,
|
|
334
|
+
GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce, GIT_COMMITTER_DATE: cd,
|
|
335
|
+
})).trim();
|
|
336
|
+
// store lands at stash@{0} and pushes the rest down one, so the original is at
|
|
337
|
+
// idx + 1 (it can't be found by sha — both entries carry the old one's sha until
|
|
338
|
+
// the drop). ponytail: the edited stash keeps its new place at the top of
|
|
339
|
+
// `git stash list`; the graph orders stashes by date, so nothing moves there.
|
|
340
|
+
await git(repo, ['stash', 'store', `--message=${message}`, rewritten]);
|
|
341
|
+
await git(repo, ['stash', 'drop', `stash@{${idx + 1}}`]);
|
|
342
|
+
res.json({ ok: true, hash: rewritten });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// ponytail: plain `pop`, no --index — restoring the staged/unstaged split fails
|
|
346
|
+
// outright when the index can't be reapplied. Add --index if that split matters.
|
|
347
|
+
await git(repo, ['stash', action, `stash@{${idx}}`]);
|
|
348
|
+
res.json({ ok: true });
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
// conflicting pop leaves the stash in place and markers in the tree — git says so
|
|
352
|
+
res.status(409).json({ error: e.message });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
const NET_TIMEOUT = 30_000;
|
|
356
|
+
// same shape the client's api() throws — an Error carrying the status to send
|
|
357
|
+
const httpError = (status, msg) => Object.assign(new Error(msg), { status });
|
|
358
|
+
// An existing branch is only ever addressed by a string git itself printed —
|
|
359
|
+
// the request supplies a name, and it has to match one of these exactly.
|
|
360
|
+
const knownRefs = async (repo, ...args) => (await git(repo, ['for-each-ref', '--format=%(refname:short)', ...args])).split('\n').filter(Boolean);
|
|
361
|
+
const mustExist = (refs, name, kind) => {
|
|
362
|
+
const s = String(name ?? '');
|
|
363
|
+
if (!refs.includes(s))
|
|
364
|
+
throw httpError(400, `unknown ${kind}: ${s}`);
|
|
365
|
+
return s;
|
|
366
|
+
};
|
|
367
|
+
// A new name is the one string git hasn't produced itself: reject option-like
|
|
368
|
+
// names so it can't be read as a flag, then let git's own validator rule on the
|
|
369
|
+
// rest rather than re-deriving refname syntax in a regex here.
|
|
370
|
+
async function newRefName(repo, raw, kind) {
|
|
371
|
+
const name = String(raw ?? '').trim();
|
|
372
|
+
if (!name || name.startsWith('-'))
|
|
373
|
+
throw httpError(400, `invalid ${kind} name`);
|
|
374
|
+
try {
|
|
375
|
+
await git(repo, ['check-ref-format', `refs/${kind === 'tag' ? 'tags' : 'heads'}/${name}`]);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
throw httpError(400, `'${name}' is not a valid ${kind} name`);
|
|
379
|
+
}
|
|
380
|
+
return name;
|
|
381
|
+
}
|
|
382
|
+
// A commit sha is checked for shape, then resolved: what reaches a mutating
|
|
383
|
+
// command is the sha git printed back, and an unknown one fails here.
|
|
384
|
+
async function mustResolve(repo, raw) {
|
|
385
|
+
const hash = String(raw ?? '');
|
|
386
|
+
if (!isSha(hash))
|
|
387
|
+
throw httpError(400, 'invalid commit');
|
|
388
|
+
const sha = await revParse(repo, `${hash}^{commit}`);
|
|
389
|
+
if (!sha)
|
|
390
|
+
throw httpError(400, `unknown commit: ${hash}`);
|
|
391
|
+
return sha;
|
|
392
|
+
}
|
|
393
|
+
app.post('/api/branch', repoGuard, async (req, res) => {
|
|
394
|
+
const repo = String(req.query.repo);
|
|
395
|
+
const action = String(req.body.action ?? '');
|
|
396
|
+
const locals = () => knownRefs(repo, 'refs/heads');
|
|
397
|
+
try {
|
|
398
|
+
switch (action) {
|
|
399
|
+
case 'create': {
|
|
400
|
+
const at = String(req.body.at ?? '');
|
|
401
|
+
if (!isSha(at))
|
|
402
|
+
throw httpError(400, 'invalid commit');
|
|
403
|
+
// create only, no checkout: /api/checkout stays the single path that has
|
|
404
|
+
// to reason about a dirty worktree
|
|
405
|
+
await git(repo, ['branch', await newRefName(repo, req.body.name, 'branch'), at]);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'rename': {
|
|
409
|
+
const branch = mustExist(await locals(), req.body.branch, 'branch');
|
|
410
|
+
await git(repo, ['branch', '-m', branch, await newRefName(repo, req.body.name, 'branch')]);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case 'delete': {
|
|
414
|
+
const branch = mustExist(await locals(), req.body.branch, 'branch');
|
|
415
|
+
const current = (await git(repo, ['branch', '--show-current'])).trim();
|
|
416
|
+
if (branch === current)
|
|
417
|
+
throw httpError(409, 'cannot delete the checked-out branch');
|
|
418
|
+
// -d refuses to drop unmerged work; the client re-asks and sends force
|
|
419
|
+
await git(repo, ['branch', req.body.force === true ? '-D' : '-d', branch]);
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case 'deleteTag': {
|
|
423
|
+
// ponytail: local tag only — the graph's chips come from local refs, so a
|
|
424
|
+
// remote tag would need `push --delete`, a network action nobody asked for
|
|
425
|
+
const tag = mustExist(await knownRefs(repo, 'refs/tags'), req.body.tag, 'tag');
|
|
426
|
+
await git(repo, ['tag', '-d', tag]);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'merge':
|
|
430
|
+
case 'rebase': {
|
|
431
|
+
const branch = mustExist(await locals(), req.body.branch, 'branch');
|
|
432
|
+
// --autostash, matching the auto-stash /api/checkout does — git's flag,
|
|
433
|
+
// not our own stash dance. A conflict stops and stays stopped: the files
|
|
434
|
+
// show up in the WIP row and get resolved in the terminal.
|
|
435
|
+
await git(repo, [action, '--autostash', branch]);
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case 'upstream': {
|
|
439
|
+
const branch = mustExist(await locals(), req.body.branch, 'branch');
|
|
440
|
+
const upstream = mustExist(await knownRefs(repo, 'refs/remotes'), req.body.upstream, 'remote branch');
|
|
441
|
+
await git(repo, ['branch', `--set-upstream-to=${upstream}`, branch]);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'pull':
|
|
445
|
+
await git(repo, ['pull', '--ff-only', '--autostash'], [0], NET_TIMEOUT);
|
|
446
|
+
break;
|
|
447
|
+
case 'push': {
|
|
448
|
+
const current = (await git(repo, ['branch', '--show-current'])).trim();
|
|
449
|
+
if (!current)
|
|
450
|
+
throw httpError(409, 'detached HEAD — nothing to push');
|
|
451
|
+
const upstream = (await git(repo, ['rev-parse', '--abbrev-ref', `${current}@{upstream}`], [0, 128])).trim();
|
|
452
|
+
const remotes = (await git(repo, ['remote'])).split('\n').filter(Boolean);
|
|
453
|
+
// never --force. A first push with one remote sets the upstream, which is
|
|
454
|
+
// what the missing-upstream error would have told the user to do by hand
|
|
455
|
+
const args = upstream || remotes.length !== 1 ? ['push'] : ['push', '-u', remotes[0], current];
|
|
456
|
+
await git(repo, args, [0], NET_TIMEOUT);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
default:
|
|
460
|
+
throw httpError(400, `unknown action: ${action}`);
|
|
461
|
+
}
|
|
462
|
+
res.json({ ok: true });
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
const err = e;
|
|
466
|
+
res.status(err.status ?? 409).json({ error: err.message });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
app.post('/api/commit', repoGuard, async (req, res) => {
|
|
470
|
+
const repo = String(req.query.repo);
|
|
471
|
+
const action = String(req.body.action ?? '');
|
|
472
|
+
try {
|
|
473
|
+
const sha = await mustResolve(repo, req.body.hash);
|
|
474
|
+
const short = sha.slice(0, 7);
|
|
475
|
+
switch (action) {
|
|
476
|
+
case 'checkout':
|
|
477
|
+
// detaches HEAD; the client says so before asking for it
|
|
478
|
+
await stashIfDirty(repo, `WIP before checkout ${short}`);
|
|
479
|
+
await git(repo, ['checkout', sha]);
|
|
480
|
+
break;
|
|
481
|
+
case 'cherry-pick':
|
|
482
|
+
case 'revert':
|
|
483
|
+
// --no-edit: no editor can open here. A conflict stops mid-operation and
|
|
484
|
+
// stays that way — the files land in the WIP row, the terminal finishes it.
|
|
485
|
+
await git(repo, [action, '--no-edit', sha]);
|
|
486
|
+
break;
|
|
487
|
+
case 'tag':
|
|
488
|
+
await git(repo, ['tag', await newRefName(repo, req.body.name, 'tag'), sha]);
|
|
489
|
+
break;
|
|
490
|
+
case 'amend': {
|
|
491
|
+
// only the tip is amendable — anything older needs a rebase, which this isn't
|
|
492
|
+
if (sha !== (await git(repo, ['rev-parse', 'HEAD'])).trim())
|
|
493
|
+
throw httpError(409, 'only the latest commit can be edited');
|
|
494
|
+
if (!(await git(repo, ['branch', '--show-current'])).trim())
|
|
495
|
+
throw httpError(409, 'detached HEAD — check out a branch first');
|
|
496
|
+
const message = String(req.body.message ?? '').trim();
|
|
497
|
+
if (!message)
|
|
498
|
+
throw httpError(400, 'empty commit message');
|
|
499
|
+
if (req.body.force !== true) {
|
|
500
|
+
// rewriting a pushed commit leaves the remote needing a force push, which
|
|
501
|
+
// megit never does — so the client has to ask again before this proceeds
|
|
502
|
+
const onRemote = (await git(repo, ['branch', '-r', '--contains', sha]))
|
|
503
|
+
.split('\n').map(s => s.trim())
|
|
504
|
+
// drop the "origin/HEAD -> origin/main" symref line: it's a pointer, not a second branch
|
|
505
|
+
.filter(r => r && !r.includes(' -> '));
|
|
506
|
+
if (onRemote.length)
|
|
507
|
+
throw httpError(409, `already pushed to ${onRemote.join(', ')}`);
|
|
508
|
+
}
|
|
509
|
+
// --only: a plain --amend folds whatever is staged into the rewritten commit,
|
|
510
|
+
// so editing a message with unrelated work staged would quietly commit it.
|
|
511
|
+
// --no-verify: nothing changes in the tree, so pre-commit has nothing to check.
|
|
512
|
+
// --message=: the value can't be read as a flag, and carries newlines fine.
|
|
513
|
+
await git(repo, ['commit', '--amend', '--only', '--no-verify', `--message=${message}`], [0], NET_TIMEOUT);
|
|
514
|
+
res.json({ ok: true, hash: (await git(repo, ['rev-parse', 'HEAD'])).trim() });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
case 'reset': {
|
|
518
|
+
const mode = req.body.mode;
|
|
519
|
+
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard')
|
|
520
|
+
throw httpError(400, 'invalid reset mode');
|
|
521
|
+
// --hard is the only action here that can destroy uncommitted work, so it
|
|
522
|
+
// doesn't: the worktree goes to a stash first and the reset is recoverable
|
|
523
|
+
if (mode === 'hard')
|
|
524
|
+
await stashIfDirty(repo, `WIP before reset to ${short}`);
|
|
525
|
+
await git(repo, ['reset', `--${mode}`, sha]);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
default:
|
|
529
|
+
throw httpError(400, `unknown action: ${action}`);
|
|
530
|
+
}
|
|
531
|
+
res.json({ ok: true });
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
const err = e;
|
|
535
|
+
res.status(err.status ?? 409).json({ error: err.message });
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
app.post('/api/wip', repoGuard, async (req, res) => {
|
|
539
|
+
const repo = String(req.query.repo);
|
|
540
|
+
const action = String(req.body.action ?? '');
|
|
541
|
+
try {
|
|
542
|
+
// Every pathspec is matched against a status read taken right now, and goes
|
|
543
|
+
// after `--`. That matters most for discard: it runs `git clean`, so a path
|
|
544
|
+
// the request invented would be arbitrary file deletion.
|
|
545
|
+
const mustBeDirty = async () => {
|
|
546
|
+
const path = String(req.body.path ?? '');
|
|
547
|
+
const dirty = parseStatus(await git(repo, ['status', '--porcelain=v2', '-uall', '-z']));
|
|
548
|
+
const entry = dirty.find(f => f.path === path);
|
|
549
|
+
if (!entry)
|
|
550
|
+
throw httpError(400, `no uncommitted change at: ${path}`);
|
|
551
|
+
return entry;
|
|
552
|
+
};
|
|
553
|
+
switch (action) {
|
|
554
|
+
case 'stage':
|
|
555
|
+
await git(repo, ['add', '--', (await mustBeDirty()).path]);
|
|
556
|
+
break;
|
|
557
|
+
case 'unstage':
|
|
558
|
+
await git(repo, ['restore', '--staged', '--', (await mustBeDirty()).path]);
|
|
559
|
+
break;
|
|
560
|
+
case 'stage-all':
|
|
561
|
+
await git(repo, ['add', '-A']);
|
|
562
|
+
break;
|
|
563
|
+
case 'unstage-all':
|
|
564
|
+
await git(repo, ['restore', '--staged', '--', '.']);
|
|
565
|
+
break;
|
|
566
|
+
case 'discard-file': {
|
|
567
|
+
const entry = await mustBeDirty();
|
|
568
|
+
// an untracked file has nothing to restore from — discarding it means deleting it.
|
|
569
|
+
// Tracked: restore the worktree from the index, so a staged part survives.
|
|
570
|
+
if (entry.y === '?')
|
|
571
|
+
await git(repo, ['clean', '-f', '--', entry.path]);
|
|
572
|
+
else
|
|
573
|
+
await git(repo, ['restore', '--', entry.path]);
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case 'discard':
|
|
577
|
+
// everything back to HEAD: tracked files restored on both sides, untracked
|
|
578
|
+
// removed. No -x — ignored files (node_modules, .env) are not "changes".
|
|
579
|
+
await git(repo, ['restore', '--staged', '--worktree', '--', '.']);
|
|
580
|
+
await git(repo, ['clean', '-fd']);
|
|
581
|
+
break;
|
|
582
|
+
case 'stash': {
|
|
583
|
+
const message = String(req.body.message ?? '').trim();
|
|
584
|
+
const msg = message ? [`--message=${message}`] : [];
|
|
585
|
+
if (req.body.scope === 'staged') {
|
|
586
|
+
// --staged stashes exactly the index and leaves the worktree edits alone
|
|
587
|
+
await git(repo, ['stash', 'push', '--staged', ...msg]);
|
|
588
|
+
}
|
|
589
|
+
else if (req.body.scope === 'unstaged') {
|
|
590
|
+
// ponytail: --keep-index leaves the staged set in place, which is what the
|
|
591
|
+
// button promises — but the stash it writes holds the whole worktree, staged
|
|
592
|
+
// content included. Exact split would be push --staged, push -u, pop --index
|
|
593
|
+
// stash@{1}; three commands with a half-failed middle state. Upgrade if the
|
|
594
|
+
// superset ever bites when popping.
|
|
595
|
+
await git(repo, ['stash', 'push', '-u', '--keep-index', ...msg]);
|
|
596
|
+
}
|
|
597
|
+
else
|
|
598
|
+
throw httpError(400, 'invalid stash scope');
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
case 'commit': {
|
|
602
|
+
const message = String(req.body.message ?? '').trim();
|
|
603
|
+
if (!message)
|
|
604
|
+
throw httpError(400, 'empty commit message');
|
|
605
|
+
// hooks run as they would in a terminal — this commit has real content —
|
|
606
|
+
// but a hook that blocks on stdin gets killed rather than wedging the request
|
|
607
|
+
await git(repo, ['commit', `--message=${message}`], [0], NET_TIMEOUT);
|
|
608
|
+
res.json({ ok: true, hash: (await git(repo, ['rev-parse', 'HEAD'])).trim() });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
default:
|
|
612
|
+
throw httpError(400, `unknown action: ${action}`);
|
|
613
|
+
}
|
|
614
|
+
res.json({ ok: true });
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
const err = e;
|
|
618
|
+
res.status(err.status ?? 409).json({ error: err.message });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
app.get('/api/events', repoGuard, (req, res) => {
|
|
622
|
+
const repo = String(req.query.repo);
|
|
623
|
+
res.writeHead(200, {
|
|
624
|
+
'Content-Type': 'text/event-stream',
|
|
625
|
+
'Cache-Control': 'no-cache',
|
|
626
|
+
Connection: 'keep-alive',
|
|
627
|
+
});
|
|
628
|
+
res.write(': connected\n\n');
|
|
629
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 30_000);
|
|
630
|
+
let unsub = null;
|
|
631
|
+
const cleanup = () => {
|
|
632
|
+
clearInterval(ping);
|
|
633
|
+
unsub?.();
|
|
634
|
+
unsub = null;
|
|
635
|
+
};
|
|
636
|
+
try {
|
|
637
|
+
unsub = subscribe(repo, () => res.write('data: changed\n\n'), () => {
|
|
638
|
+
cleanup();
|
|
639
|
+
res.end();
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// watcher couldn't start (EMFILE, permissions) — client degrades to manual refresh
|
|
644
|
+
cleanup();
|
|
645
|
+
res.end();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
req.on('close', cleanup);
|
|
649
|
+
});
|
|
650
|
+
async function firstParent(repo, hash) {
|
|
651
|
+
const out = await git(repo, ['rev-list', '--parents', '-n1', hash]);
|
|
652
|
+
return out.trim().split(' ')[1] ?? null;
|
|
653
|
+
}
|
|
654
|
+
app.get('/api/commit', repoGuard, async (req, res) => {
|
|
655
|
+
const repo = String(req.query.repo);
|
|
656
|
+
const hash = String(req.query.hash ?? '');
|
|
657
|
+
if (!isSha(hash)) {
|
|
658
|
+
res.status(400).json({ error: 'invalid hash' });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
// one show call yields message/author/committer AND the parent list for the diff
|
|
663
|
+
const meta = parseMeta(await git(repo, ['show', '-s', `--format=${META_FORMAT}`, hash]));
|
|
664
|
+
const parent = meta.parents[0] ?? null;
|
|
665
|
+
const raw = parent
|
|
666
|
+
? await git(repo, ['diff', '--name-status', '-z', parent, hash])
|
|
667
|
+
: await git(repo, ['diff-tree', '-r', '--root', '--no-commit-id', '--name-status', '-z', hash]);
|
|
668
|
+
res.json({ files: parseNameStatus(raw), meta });
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
res.status(500).json({ error: e.message });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
const DIFF_CAP = 1024 * 1024;
|
|
675
|
+
app.get('/api/diff', repoGuard, async (req, res) => {
|
|
676
|
+
const repo = String(req.query.repo);
|
|
677
|
+
const hash = req.query.hash ? String(req.query.hash) : null;
|
|
678
|
+
const file = String(req.query.file ?? '');
|
|
679
|
+
if (hash !== null && !isSha(hash)) {
|
|
680
|
+
res.status(400).json({ error: 'invalid hash' });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// `context` widens -U so the client can expand hunk gaps from one response
|
|
684
|
+
const n = Number(req.query.context);
|
|
685
|
+
const ctx = Number.isInteger(n) && n > 0 ? [`-U${Math.min(n, 1e6)}`] : [];
|
|
686
|
+
try {
|
|
687
|
+
let diff;
|
|
688
|
+
if (hash) {
|
|
689
|
+
const parent = await firstParent(repo, hash);
|
|
690
|
+
diff = parent
|
|
691
|
+
? await git(repo, ['diff', ...ctx, parent, hash, '--', file])
|
|
692
|
+
: await git(repo, ['show', '--format=', ...ctx, hash, '--', file]);
|
|
693
|
+
}
|
|
694
|
+
else if (req.query.side === 'staged') {
|
|
695
|
+
// HEAD vs index only — the merged HEAD-vs-worktree diff below would be empty
|
|
696
|
+
// for a file that's fully staged, and misleading for a partially staged one
|
|
697
|
+
diff = await git(repo, ['diff', '--cached', ...ctx, '--', file]);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
// index vs worktree for the unstaged side, HEAD vs worktree when no side is given
|
|
701
|
+
diff = await git(repo, ['diff', ...ctx, ...(req.query.side === 'worktree' ? [] : ['HEAD']), '--', file]);
|
|
702
|
+
// untracked file: not in HEAD diff; --no-index exits 1 when files differ
|
|
703
|
+
if (!diff.trim())
|
|
704
|
+
diff = await git(repo, ['diff', '--no-index', ...ctx, '--', '/dev/null', file], [0, 1]);
|
|
705
|
+
}
|
|
706
|
+
if (req.query.force !== '1' && diff.length > DIFF_CAP) {
|
|
707
|
+
res.json({ tooLarge: true, size: diff.length });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
res.json({ diff });
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
res.status(500).json({ error: e.message });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
// raw bytes of one blob, for rendering image diffs as two <img>. `which` picks
|
|
717
|
+
// the before/after side; a missing blob (added or deleted file) is a 404 the
|
|
718
|
+
// client renders as an empty pane.
|
|
719
|
+
const MIME = {
|
|
720
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
721
|
+
webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp',
|
|
722
|
+
};
|
|
723
|
+
function gitBuf(repo, args) {
|
|
724
|
+
return new Promise((res, rej) => {
|
|
725
|
+
execFile('git', ['-C', repo, ...args], { maxBuffer: 50 * 1024 * 1024, env: GIT_ENV, encoding: 'buffer' }, (err, stdout, stderr) => {
|
|
726
|
+
if (err)
|
|
727
|
+
rej(new Error(stderr.toString().trim() || err.message));
|
|
728
|
+
else
|
|
729
|
+
res(stdout);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
app.get('/api/blob', repoGuard, async (req, res) => {
|
|
734
|
+
const repo = String(req.query.repo);
|
|
735
|
+
const hash = req.query.hash ? String(req.query.hash) : null;
|
|
736
|
+
const file = String(req.query.file ?? '');
|
|
737
|
+
const side = String(req.query.side ?? '');
|
|
738
|
+
const old = req.query.which === 'old';
|
|
739
|
+
if (hash !== null && !isSha(hash)) {
|
|
740
|
+
res.status(400).json({ error: 'invalid hash' });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const mime = MIME[file.split('.').pop()?.toLowerCase() ?? ''];
|
|
744
|
+
if (!mime) {
|
|
745
|
+
res.status(400).json({ error: 'unsupported file type' });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
let buf;
|
|
750
|
+
if (hash) {
|
|
751
|
+
const rev = old ? await firstParent(repo, hash) : hash;
|
|
752
|
+
if (!rev) {
|
|
753
|
+
res.status(404).end();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
buf = await gitBuf(repo, ['show', `${rev}:${file}`]);
|
|
757
|
+
}
|
|
758
|
+
else if (old) {
|
|
759
|
+
// staged pane compares against HEAD; unstaged compares against the index
|
|
760
|
+
buf = await gitBuf(repo, ['show', `${side === 'worktree' ? '' : 'HEAD'}:${file}`]);
|
|
761
|
+
}
|
|
762
|
+
else if (side === 'staged') {
|
|
763
|
+
buf = await gitBuf(repo, ['show', `:${file}`]);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
// worktree content isn't in any git object — read it off disk, but only
|
|
767
|
+
// from inside the (already guarded) repo
|
|
768
|
+
// realpath, not resolve: a cloned repo can contain a symlink pointing
|
|
769
|
+
// outside itself, and git will happily materialize it on checkout
|
|
770
|
+
const abs = await realpath(resolve(repo, file));
|
|
771
|
+
const root = await realpath(repo);
|
|
772
|
+
if (!abs.startsWith(root + sep)) {
|
|
773
|
+
res.status(400).json({ error: 'path outside repo' });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
buf = await readFile(abs);
|
|
777
|
+
}
|
|
778
|
+
// repo content is untrusted (an SVG can carry script); <img> never runs it,
|
|
779
|
+
// but a direct navigation to this URL would — sandbox it either way
|
|
780
|
+
res.set('Content-Security-Policy', "sandbox; default-src 'none'").set('X-Content-Type-Options', 'nosniff');
|
|
781
|
+
res.type(mime).send(buf);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
res.status(404).end();
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
const dist = join(import.meta.dirname, '..', 'dist');
|
|
788
|
+
if (existsSync(dist)) {
|
|
789
|
+
app.use(express.static(dist));
|
|
790
|
+
app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(join(dist, 'index.html')));
|
|
791
|
+
}
|
|
792
|
+
const port = Number(process.env.PORT) || 3411;
|
|
793
|
+
// exported so bin/megit.js can wait for 'listening' before opening the browser.
|
|
794
|
+
// The banner hangs off the 'listening' event rather than app.listen's callback:
|
|
795
|
+
// express 5 runs that callback even when the bind failed, which would announce a
|
|
796
|
+
// URL that never came up.
|
|
797
|
+
export const server = app.listen(port, '127.0.0.1');
|
|
798
|
+
server.on('listening', () => console.log(`megit API on http://127.0.0.1:${port}`));
|
|
799
|
+
server.on('error', (e) => {
|
|
800
|
+
if (e.code !== 'EADDRINUSE')
|
|
801
|
+
throw e;
|
|
802
|
+
console.error(`megit: port ${port} is already in use — set PORT to a free port, e.g. PORT=3412 megit`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
});
|
|
805
|
+
wireTerminal(server);
|