ramulus 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # Ramulus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Every branch of your repository, in one graph. Ramulus starts a small local server on top of the `git` you already have and opens the UI in your browser.
4
+
5
+ ```sh
6
+ npx ramulus # in a repository
7
+ npx ramulus ~/code/app # or point it at one
8
+ ```
9
+
10
+ Options: `--port N` to pick a port, `--no-open` to only print the URL.
11
+
12
+ - Needs Node 20 or newer and `git` on your PATH. Works on macOS, Windows and Linux.
13
+ - Everything runs through your installed git, so SSH keys, credential helpers, hooks and LFS behave exactly as on the command line.
14
+ - The server listens on 127.0.0.1 only and every request carries a per-run token, so other pages and other machines cannot reach your repository.
15
+ - Commit graph with lanes, branches, tags, stashes, staging with diffs, commit and amend, merge, rebase, cherry-pick, revert, reset, fetch, pull, push, and a banner with Continue and Abort when a merge or rebase stops on conflicts.
16
+
17
+ Source and the native desktop app: https://github.com/a-ahandani/ramulus
package/bin/ramulus.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto'
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync } from 'node:fs'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { toplevel } from '../lib/git.js'
8
+ import { createServer } from '../lib/server.js'
9
+
10
+ const args = process.argv.slice(2)
11
+ if (args.includes('-h') || args.includes('--help')) {
12
+ console.log('Usage: ramulus [path] [--port N] [--no-open]\n\nServes the Ramulus UI for the repository at path (default: current directory).')
13
+ process.exit(0)
14
+ }
15
+ const portArg = args.indexOf('--port')
16
+ const port = portArg >= 0 ? Number(args[portArg + 1]) : 0
17
+ const noOpen = args.includes('--no-open')
18
+ const pathArg = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--port') ?? '.'
19
+
20
+ let dir
21
+ try {
22
+ dir = await toplevel(resolve(pathArg))
23
+ } catch (e) {
24
+ console.error(`Not a git repository: ${resolve(pathArg)}\n${e.message}`)
25
+ process.exit(1)
26
+ }
27
+
28
+ const uiDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui')
29
+ if (!existsSync(join(uiDir, 'index.html'))) {
30
+ console.error('The UI build is missing. Run `npm run build` in the cli folder first.')
31
+ process.exit(1)
32
+ }
33
+
34
+ const token = randomBytes(16).toString('hex')
35
+ const server = createServer({ dir, uiDir, token })
36
+ server.listen(port, '127.0.0.1', () => {
37
+ const url = `http://127.0.0.1:${server.address().port}/#${token}`
38
+ console.log(`Ramulus is serving ${dir}\n${url}\nPress Ctrl+C to stop.`)
39
+ if (!noOpen) {
40
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
41
+ const cmdArgs = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
42
+ spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
43
+ }
44
+ })
package/lib/git.js ADDED
@@ -0,0 +1,388 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const NUL = '\0'
6
+ const RS = '\x1e'
7
+
8
+ export function run(dir, args, { input, allowFail = false } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = execFile(
11
+ 'git',
12
+ args,
13
+ { cwd: dir, maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } },
14
+ (err, stdout, stderr) => {
15
+ if (err && !allowFail) {
16
+ const msg = (stderr || stdout || err.message).toString().trim()
17
+ reject(new Error(msg || `git ${args[0]} failed`))
18
+ } else {
19
+ resolve({ ok: !err, stdout: stdout.toString(), stderr: stderr.toString() })
20
+ }
21
+ },
22
+ )
23
+ if (input !== undefined) child.stdin.end(input)
24
+ else child.stdin.end()
25
+ })
26
+ }
27
+
28
+ function safeName(name) {
29
+ if (typeof name !== 'string' || name.length === 0 || name.startsWith('-')) throw new Error(`Invalid name: ${JSON.stringify(name)}`)
30
+ return name
31
+ }
32
+
33
+ function safePaths(paths) {
34
+ if (!Array.isArray(paths) || paths.length === 0) throw new Error('No files given')
35
+ return paths.map((p) => {
36
+ if (typeof p !== 'string' || p.length === 0) throw new Error('Invalid path')
37
+ return p
38
+ })
39
+ }
40
+
41
+ export async function toplevel(dir) {
42
+ return (await run(dir, ['rev-parse', '--show-toplevel'])).stdout.trim()
43
+ }
44
+
45
+ async function stateOf(dir) {
46
+ const gitDir = (await run(dir, ['rev-parse', '--git-dir'])).stdout.trim()
47
+ const at = (p) => existsSync(join(dir, gitDir, p))
48
+ if (at('MERGE_HEAD')) return 'merge'
49
+ if (at('rebase-merge') || at('rebase-apply')) return 'rebase'
50
+ if (at('CHERRY_PICK_HEAD')) return 'cherry-pick'
51
+ if (at('REVERT_HEAD')) return 'revert'
52
+ return null
53
+ }
54
+
55
+ export function parseStatus(porcelainZ) {
56
+ const out = { staged: [], unstaged: [] }
57
+ const items = porcelainZ.split(NUL)
58
+ for (let i = 0; i < items.length; i++) {
59
+ const item = items[i]
60
+ if (item.length < 4) continue
61
+ const x = item[0]
62
+ const y = item[1]
63
+ let path = item.slice(3)
64
+ if (x === 'R' || x === 'C') {
65
+ i++
66
+ }
67
+ if (x === '?') {
68
+ out.unstaged.push({ path, kind: '?' })
69
+ continue
70
+ }
71
+ if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) {
72
+ out.unstaged.push({ path, kind: 'M', conflict: true })
73
+ continue
74
+ }
75
+ if (x !== ' ') out.staged.push({ path, kind: x === 'A' ? 'A' : x === 'D' ? 'D' : 'M' })
76
+ if (y !== ' ') out.unstaged.push({ path, kind: y === 'D' ? 'D' : 'M' })
77
+ }
78
+ return out
79
+ }
80
+
81
+ export async function status(dir) {
82
+ const { stdout } = await run(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
83
+ return { ...parseStatus(stdout), state: await stateOf(dir) }
84
+ }
85
+
86
+ export function parseLog(text, stashInfo) {
87
+ const commits = []
88
+ for (const rec of text.split(RS)) {
89
+ const t = rec.replace(/^\n/, '')
90
+ if (!t.trim()) continue
91
+ const [id, parents, author, email, time, summary, body = ''] = t.split(NUL)
92
+ if (!id) continue
93
+ if (stashInfo.internals.has(id)) continue
94
+ const stash = stashInfo.messages.get(id)
95
+ const ps = parents ? parents.split(' ') : []
96
+ commits.push({
97
+ id,
98
+ parents: stash !== undefined ? ps.slice(0, 1) : ps,
99
+ author,
100
+ email,
101
+ time: Number(time),
102
+ summary: stash ?? summary,
103
+ body: body.trim(),
104
+ kind: stash !== undefined ? 'stash' : ps.length > 1 ? 'merge' : 'commit',
105
+ })
106
+ }
107
+ return commits
108
+ }
109
+
110
+ export async function load(dir, limit = 2000) {
111
+ const headBranch = (await run(dir, ['symbolic-ref', '-q', '--short', 'HEAD'], { allowFail: true })).stdout.trim() || undefined
112
+ const headOid = (await run(dir, ['rev-parse', '-q', '--verify', 'HEAD'], { allowFail: true })).stdout.trim() || undefined
113
+ const refsByOid = {}
114
+ const addRef = (oid, label) => (refsByOid[oid] ??= []).push(label)
115
+ const local = []
116
+ const remote = []
117
+ const tags = []
118
+ const refs = await run(dir, ['for-each-ref', '--format=%(refname)%00%(objectname)%00%(*objectname)%00%(symref)', 'refs/heads', 'refs/remotes', 'refs/tags'])
119
+ for (const line of refs.stdout.split('\n')) {
120
+ if (!line) continue
121
+ const [refname, objectname, peeled, symref] = line.split(NUL)
122
+ if (symref) continue
123
+ const oid = peeled || objectname
124
+ if (refname.startsWith('refs/heads/')) {
125
+ const name = refname.slice(11)
126
+ local.push({ name, oid, isHead: name === headBranch })
127
+ addRef(oid, { name, kind: 'head', current: name === headBranch })
128
+ } else if (refname.startsWith('refs/remotes/')) {
129
+ const name = refname.slice(13)
130
+ remote.push({ name, oid, isHead: false })
131
+ addRef(oid, { name, kind: 'remote' })
132
+ } else if (refname.startsWith('refs/tags/')) {
133
+ const name = refname.slice(10)
134
+ tags.push({ name, oid })
135
+ addRef(oid, { name, kind: 'tag' })
136
+ }
137
+ }
138
+ const stashes = []
139
+ const stashList = await run(dir, ['stash', 'list', '--format=%H%x00%s'], { allowFail: true })
140
+ for (const line of stashList.stdout.split('\n')) {
141
+ if (!line) continue
142
+ const [oid, message] = line.split(NUL)
143
+ stashes.push({ index: stashes.length, message, oid })
144
+ }
145
+ const internals = new Set()
146
+ if (stashes.length > 0) {
147
+ const parents = await run(dir, ['rev-list', '--no-walk', '--parents', ...stashes.map((s) => s.oid)])
148
+ for (const line of parents.stdout.split('\n')) {
149
+ const [, ...ps] = line.trim().split(' ')
150
+ ps.slice(1).forEach((p) => internals.add(p))
151
+ }
152
+ }
153
+ const log = await run(dir, [
154
+ 'log',
155
+ '--date-order',
156
+ `--max-count=${limit + 1 + internals.size}`,
157
+ '--format=%H%x00%P%x00%an%x00%ae%x00%ct%x00%s%x00%b%x1e',
158
+ '--all',
159
+ ], { allowFail: true })
160
+ const commits = parseLog(log.stdout, { internals, messages: new Map(stashes.map((s) => [s.oid, s.message])) })
161
+ const hasMore = commits.length > limit
162
+ return {
163
+ name: dir.split(/[\\/]/).filter(Boolean).pop(),
164
+ headBranch,
165
+ headOid,
166
+ commits: commits.slice(0, limit),
167
+ refsByOid,
168
+ local,
169
+ remote,
170
+ tags,
171
+ stashes,
172
+ status: await status(dir),
173
+ hasMore,
174
+ native: true,
175
+ }
176
+ }
177
+
178
+ async function parentOf(dir, oid) {
179
+ return (await run(dir, ['rev-parse', '-q', '--verify', `${oid}^`], { allowFail: true })).stdout.trim() || undefined
180
+ }
181
+
182
+ export async function commitFiles(dir, oid) {
183
+ const parent = await parentOf(dir, oid)
184
+ const args = parent ? ['diff-tree', '-r', '-z', '-M', '--name-status', parent, oid] : ['diff-tree', '--root', '-r', '-z', '-M', '--name-status', oid]
185
+ const { stdout } = await run(dir, args)
186
+ const parts = stdout.split(NUL)
187
+ const out = []
188
+ for (let i = 0; i < parts.length - 1; i += 2) {
189
+ const code = parts[i][0]
190
+ let path = parts[i + 1]
191
+ if (code === 'R' || code === 'C') {
192
+ path = parts[i + 2]
193
+ i++
194
+ }
195
+ if (!path) continue
196
+ out.push({ path, kind: code === 'A' ? 'A' : code === 'D' ? 'D' : 'M' })
197
+ }
198
+ return out
199
+ }
200
+
201
+ export function parseDiff(text) {
202
+ const lines = []
203
+ let inHunk = false
204
+ for (const raw of text.split('\n')) {
205
+ if (raw.startsWith('@@')) {
206
+ inHunk = true
207
+ lines.push({ type: 'hunk', text: raw })
208
+ } else if (raw.startsWith('Binary files')) {
209
+ lines.push({ type: 'note', text: 'Binary file' })
210
+ } else if (!inHunk) {
211
+ continue
212
+ } else if (raw.startsWith('+')) lines.push({ type: 'add', text: raw.slice(1) })
213
+ else if (raw.startsWith('-')) lines.push({ type: 'del', text: raw.slice(1) })
214
+ else if (raw.startsWith(' ')) lines.push({ type: 'ctx', text: raw.slice(1) })
215
+ else if (raw.startsWith('\\')) lines.push({ type: 'note', text: raw })
216
+ }
217
+ return lines
218
+ }
219
+
220
+ export async function diff(dir, area, path) {
221
+ safePaths([path])
222
+ let args
223
+ if (area.kind === 'commit') {
224
+ const parent = await parentOf(dir, area.oid)
225
+ args = parent ? ['diff', '--no-color', '-M', parent, area.oid, '--', path] : ['show', '--format=', '--no-color', area.oid, '--', path]
226
+ } else if (area.kind === 'staged') {
227
+ args = ['diff', '--no-color', '--cached', '--', path]
228
+ } else {
229
+ const tracked = (await run(dir, ['ls-files', '--error-unmatch', '--', path], { allowFail: true })).ok
230
+ args = tracked ? ['diff', '--no-color', '--', path] : ['diff', '--no-color', '--no-index', '--', '/dev/null', path]
231
+ }
232
+ const { stdout } = await run(dir, args, { allowFail: true })
233
+ return parseDiff(stdout)
234
+ }
235
+
236
+ export async function stage(dir, paths) {
237
+ await run(dir, ['add', '-A', '--', ...safePaths(paths)])
238
+ }
239
+
240
+ export async function unstage(dir, paths) {
241
+ await run(dir, ['reset', '-q', '--', ...safePaths(paths)])
242
+ }
243
+
244
+ export async function discard(dir, files) {
245
+ const tracked = files.filter((f) => f.kind !== '?').map((f) => f.path)
246
+ const untracked = files.filter((f) => f.kind === '?').map((f) => f.path)
247
+ if (tracked.length) await run(dir, ['checkout', '-q', '--', ...safePaths(tracked)])
248
+ if (untracked.length) await run(dir, ['clean', '-f', '-q', '--', ...safePaths(untracked)])
249
+ }
250
+
251
+ export async function commit(dir, message, amend) {
252
+ if (!message || !message.trim()) throw new Error('A commit message is required')
253
+ await run(dir, ['commit', '-q', '-F', '-', ...(amend ? ['--amend'] : [])], { input: message })
254
+ }
255
+
256
+ export async function checkoutBranch(dir, name) {
257
+ await run(dir, ['switch', '-q', safeName(name)])
258
+ }
259
+
260
+ export async function checkoutRemote(dir, name) {
261
+ safeName(name)
262
+ const i = name.indexOf('/')
263
+ const local = name.slice(i + 1)
264
+ const exists = (await run(dir, ['rev-parse', '-q', '--verify', `refs/heads/${local}`], { allowFail: true })).ok
265
+ await run(dir, exists ? ['switch', '-q', local] : ['switch', '-q', '-c', local, '--track', name])
266
+ }
267
+
268
+ export async function checkoutCommit(dir, oid) {
269
+ await run(dir, ['switch', '-q', '--detach', safeName(oid)])
270
+ }
271
+
272
+ export async function createBranch(dir, name, at, checkout) {
273
+ await run(dir, ['branch', '--', safeName(name), safeName(at)])
274
+ if (checkout) await run(dir, ['switch', '-q', name])
275
+ }
276
+
277
+ export async function deleteBranch(dir, name) {
278
+ await run(dir, ['branch', '-D', '--', safeName(name)])
279
+ }
280
+
281
+ export async function renameBranch(dir, oldName, newName) {
282
+ await run(dir, ['branch', '-m', '--', safeName(oldName), safeName(newName)])
283
+ }
284
+
285
+ export async function createTag(dir, name, at, message) {
286
+ if (message && message.trim()) await run(dir, ['tag', '-a', '-F', '-', '--', safeName(name), safeName(at)], { input: message })
287
+ else await run(dir, ['tag', '--', safeName(name), safeName(at)])
288
+ }
289
+
290
+ export async function deleteTag(dir, name) {
291
+ await run(dir, ['tag', '-d', '--', safeName(name)])
292
+ }
293
+
294
+ export async function stashSave(dir, message) {
295
+ await run(dir, ['stash', 'push', '-u', '-q', ...(message && message.trim() ? ['-m', message] : [])])
296
+ }
297
+
298
+ export async function stashApply(dir, index, pop) {
299
+ await run(dir, ['stash', pop ? 'pop' : 'apply', '-q', `stash@{${Number(index)}}`])
300
+ }
301
+
302
+ export async function stashDrop(dir, index) {
303
+ await run(dir, ['stash', 'drop', '-q', `stash@{${Number(index)}}`])
304
+ }
305
+
306
+ export async function merge(dir, branch) {
307
+ const r = await run(dir, ['merge', '--no-edit', safeName(branch)], { allowFail: true })
308
+ if (!r.ok) {
309
+ if ((await stateOf(dir)) === 'merge') return 'conflicts'
310
+ throw new Error((r.stderr || r.stdout).trim())
311
+ }
312
+ return /Already up to date/i.test(r.stdout) ? 'up-to-date' : /Fast-forward/.test(r.stdout) ? 'fast-forward' : 'merged'
313
+ }
314
+
315
+ export async function rebase(dir, onto) {
316
+ const r = await run(dir, ['-c', 'core.editor=true', 'rebase', '--', safeName(onto)], { allowFail: true })
317
+ if (!r.ok) {
318
+ if ((await stateOf(dir)) === 'rebase') return 'conflicts'
319
+ throw new Error((r.stderr || r.stdout).trim())
320
+ }
321
+ return 'done'
322
+ }
323
+
324
+ export async function cherryPick(dir, oid) {
325
+ const r = await run(dir, ['cherry-pick', safeName(oid)], { allowFail: true })
326
+ if (!r.ok) {
327
+ if ((await stateOf(dir)) === 'cherry-pick') return 'conflicts'
328
+ throw new Error((r.stderr || r.stdout).trim())
329
+ }
330
+ return 'done'
331
+ }
332
+
333
+ export async function revert(dir, oid) {
334
+ const r = await run(dir, ['revert', '--no-edit', safeName(oid)], { allowFail: true })
335
+ if (!r.ok) {
336
+ if ((await stateOf(dir)) === 'revert') return 'conflicts'
337
+ throw new Error((r.stderr || r.stdout).trim())
338
+ }
339
+ return 'done'
340
+ }
341
+
342
+ export async function reset(dir, oid, mode) {
343
+ if (!['soft', 'mixed', 'hard'].includes(mode)) throw new Error('Invalid reset mode')
344
+ await run(dir, ['reset', '-q', `--${mode}`, safeName(oid)])
345
+ }
346
+
347
+ export async function abort(dir) {
348
+ const state = await stateOf(dir)
349
+ if (!state) return
350
+ await run(dir, [state, '--abort'])
351
+ }
352
+
353
+ export async function continueOp(dir) {
354
+ const state = await stateOf(dir)
355
+ if (!state) return
356
+ if (state === 'merge') await run(dir, ['-c', 'core.editor=true', 'commit', '-q', '--no-edit'])
357
+ else await run(dir, ['-c', 'core.editor=true', state, '--continue'])
358
+ }
359
+
360
+ export async function fetchAll(dir) {
361
+ await run(dir, ['fetch', '--all', '--prune'])
362
+ }
363
+
364
+ export async function pull(dir, mode) {
365
+ const flag = mode === 'rebase' ? '--rebase' : mode === 'ff-only' ? '--ff-only' : '--no-rebase'
366
+ await run(dir, ['pull', flag])
367
+ }
368
+
369
+ export async function push(dir) {
370
+ const upstream = (await run(dir, ['rev-parse', '-q', '--abbrev-ref', '@{u}'], { allowFail: true })).ok
371
+ if (upstream) {
372
+ await run(dir, ['push'])
373
+ return
374
+ }
375
+ const branch = (await run(dir, ['symbolic-ref', '-q', '--short', 'HEAD'])).stdout.trim()
376
+ const remotes = (await run(dir, ['remote'])).stdout.trim().split('\n').filter(Boolean)
377
+ const remote = remotes.includes('origin') ? 'origin' : remotes[0]
378
+ if (!remote) throw new Error('This repository has no remote to push to')
379
+ await run(dir, ['push', '-u', '--', remote, branch])
380
+ }
381
+
382
+ export function isRepo(dir) {
383
+ try {
384
+ return statSync(dir).isDirectory()
385
+ } catch {
386
+ return false
387
+ }
388
+ }
package/lib/server.js ADDED
@@ -0,0 +1,125 @@
1
+ import { createReadStream, existsSync, statSync, watch } from 'node:fs'
2
+ import http from 'node:http'
3
+ import { extname, join, normalize } from 'node:path'
4
+ import * as git from './git.js'
5
+
6
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json' }
7
+
8
+ const OPS = {
9
+ load: (dir, b) => git.load(dir, b.limit),
10
+ status: (dir) => git.status(dir),
11
+ commitFiles: (dir, b) => git.commitFiles(dir, b.oid),
12
+ diff: (dir, b) => git.diff(dir, b.area, b.path),
13
+ stage: (dir, b) => git.stage(dir, b.paths),
14
+ unstage: (dir, b) => git.unstage(dir, b.paths),
15
+ discard: (dir, b) => git.discard(dir, b.files),
16
+ commit: (dir, b) => git.commit(dir, b.message, b.amend),
17
+ checkoutBranch: (dir, b) => git.checkoutBranch(dir, b.name),
18
+ checkoutRemote: (dir, b) => git.checkoutRemote(dir, b.name),
19
+ checkoutCommit: (dir, b) => git.checkoutCommit(dir, b.oid),
20
+ createBranch: (dir, b) => git.createBranch(dir, b.name, b.at, b.checkout),
21
+ deleteBranch: (dir, b) => git.deleteBranch(dir, b.name),
22
+ renameBranch: (dir, b) => git.renameBranch(dir, b.oldName, b.newName),
23
+ createTag: (dir, b) => git.createTag(dir, b.name, b.at, b.message),
24
+ deleteTag: (dir, b) => git.deleteTag(dir, b.name),
25
+ stashSave: (dir, b) => git.stashSave(dir, b.message),
26
+ stashApply: (dir, b) => git.stashApply(dir, b.index, b.pop),
27
+ stashDrop: (dir, b) => git.stashDrop(dir, b.index),
28
+ merge: (dir, b) => git.merge(dir, b.branch),
29
+ rebase: (dir, b) => git.rebase(dir, b.onto),
30
+ cherryPick: (dir, b) => git.cherryPick(dir, b.oid),
31
+ revert: (dir, b) => git.revert(dir, b.oid),
32
+ reset: (dir, b) => git.reset(dir, b.oid, b.mode),
33
+ abort: (dir) => git.abort(dir),
34
+ continue: (dir) => git.continueOp(dir),
35
+ fetchAll: (dir) => git.fetchAll(dir),
36
+ pull: (dir, b) => git.pull(dir, b.mode),
37
+ push: (dir) => git.push(dir),
38
+ }
39
+
40
+ function readBody(req) {
41
+ return new Promise((resolve, reject) => {
42
+ const chunks = []
43
+ req.on('data', (c) => chunks.push(c))
44
+ req.on('end', () => {
45
+ try {
46
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {})
47
+ } catch (e) {
48
+ reject(e)
49
+ }
50
+ })
51
+ req.on('error', reject)
52
+ })
53
+ }
54
+
55
+ function sendJson(res, status, data) {
56
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
57
+ res.end(JSON.stringify(data ?? null))
58
+ }
59
+
60
+ export function createServer({ dir, uiDir, token }) {
61
+ const clients = new Set()
62
+ let timer
63
+ const notify = () => {
64
+ clearTimeout(timer)
65
+ timer = setTimeout(() => {
66
+ for (const res of clients) res.write('data: changed\n\n')
67
+ }, 400)
68
+ }
69
+ try {
70
+ watch(dir, { recursive: true }, (_, file) => {
71
+ const f = (file ?? '').toString().replaceAll('\\', '/')
72
+ if (f.includes('.git/objects') || f.endsWith('.lock') || f.includes('node_modules')) return
73
+ notify()
74
+ })
75
+ } catch {
76
+ /* recursive watching is unavailable on this platform; the UI still has a refresh button */
77
+ }
78
+
79
+ const server = http.createServer(async (req, res) => {
80
+ const host = req.headers.host ?? ''
81
+ if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) {
82
+ res.writeHead(403).end('forbidden host')
83
+ return
84
+ }
85
+ const url = new URL(req.url, 'http://localhost')
86
+ if (url.pathname === '/api/events') {
87
+ if (url.searchParams.get('token') !== token) {
88
+ res.writeHead(401).end()
89
+ return
90
+ }
91
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' })
92
+ res.write(': connected\n\n')
93
+ clients.add(res)
94
+ req.on('close', () => clients.delete(res))
95
+ return
96
+ }
97
+ if (url.pathname.startsWith('/api/')) {
98
+ if (req.headers['x-ramulus-token'] !== token) {
99
+ sendJson(res, 401, { error: 'bad token' })
100
+ return
101
+ }
102
+ const op = OPS[url.pathname.slice(5)]
103
+ if (!op || req.method !== 'POST') {
104
+ sendJson(res, 404, { error: 'unknown operation' })
105
+ return
106
+ }
107
+ try {
108
+ const body = await readBody(req)
109
+ sendJson(res, 200, { result: (await op(dir, body)) ?? null })
110
+ } catch (e) {
111
+ sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
112
+ }
113
+ return
114
+ }
115
+ const rel = url.pathname === '/' ? '/index.html' : url.pathname
116
+ const file = join(uiDir, normalize(rel).replace(/^(\.\.[/\\])+/, ''))
117
+ if (!file.startsWith(uiDir) || !existsSync(file) || !statSync(file).isFile()) {
118
+ res.writeHead(404).end('not found')
119
+ return
120
+ }
121
+ res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-cache' })
122
+ createReadStream(file).pipe(res)
123
+ })
124
+ return server
125
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "ramulus",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Every branch of your repository in one graph. Runs a local server on top of your installed git and opens the UI in your browser.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/a-ahandani/ramulus.git"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "ramulus": "bin/ramulus.js"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "lib",
17
+ "ui"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "build": "cd ../web && npm run build && rm -rf ../cli/ui && cp -R dist ../cli/ui",
24
+ "prepack": "npm run build",
25
+ "test": "node --test test/*.test.js"
26
+ }
27
+ }
@@ -0,0 +1 @@
1
+ :root{--app-bg:#15171c;--toolbar-bg:#23262e;--panel0:#1d2026;--panel1:#272b33;--panel2:#333841;--border:#ffffff14;--text-bright:#f4f5f7;--text:#f4f5f7cc;--text-2:#f4f5f794;--text-3:#f4f5f761;--blue:#5b9cf6;--green:#3fb950;--red:#e5534b;--orange:#f08a24;--yellow:#e3b341;--lane-0:#5b9cf6;--lane-1:#3fb950;--lane-2:#f08a24;--lane-3:#e05fb8;--lane-4:#9a7cf5;--lane-5:#2cb5a8;--lane-6:#e5534b;--lane-7:#e3b341;--lane-8:#4ac8e8;--lane-9:#b4c93e;--row-h:26px;--font:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--mono:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}*{box-sizing:border-box}html,body,#app{background:var(--app-bg);height:100%;color:var(--text);font:13px/1.4 var(--font);margin:0;overflow:hidden}button,input,textarea,select{font:inherit;color:inherit}button{background:var(--panel1);cursor:pointer;border:1px solid #0000;border-radius:4px;padding:4px 10px}button:hover:not(:disabled){background:var(--panel2)}button:disabled{color:var(--text-3);cursor:default}button.primary{background:#5b9cf659}button.success{color:var(--text-bright);background:#3fb95059}button.danger{background:#e5534b59}button.ghost{background:0 0}button.on{background:#5b9cf666}input,textarea{border:1px solid var(--border);background:#0003;border-radius:4px;width:100%;padding:5px 8px}input:focus,textarea:focus{outline:1px solid var(--blue)}.app{grid-template-rows:48px 1fr;height:100%;display:grid}.toolbar{background:var(--toolbar-bg);align-items:center;gap:6px;padding:0 10px;display:flex}.toolbar .repo{color:var(--text-bright);margin-right:4px;font-size:14px;font-weight:600}.toolbar .branch{color:var(--text);margin-right:10px}.toolbar .spacer{flex:1}.toolbar input{width:220px}.toolbar .busy{color:var(--text-2)}.body{grid-template-columns:240px 1fr 440px;min-height:0;display:grid}.body.no-left{grid-template-columns:0 1fr 440px}.body.no-right{grid-template-columns:240px 1fr 0}.body.no-left.no-right{grid-template-columns:0 1fr 0}.left,.right{background:var(--panel0);min-height:0;overflow:auto}.left{border-right:1px solid var(--border)}.right{border-left:1px solid var(--border);flex-direction:column;display:flex}.section{height:26px;color:var(--text-2);cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;padding:0 10px;font-size:11px;display:flex}.section:hover{background:#ffffff08}.section .count{color:var(--text-3);margin-left:auto}.section .arrow{width:14px}.item{cursor:default;white-space:nowrap;align-items:center;gap:8px;height:24px;padding:0 8px 0 14px;display:flex;overflow:hidden}.item:hover{background:#5b9cf61f}.item.selected{background:#5b9cf638}.item .dot{border-radius:50%;flex:none;width:6px;height:6px}.item .name{text-overflow:ellipsis;overflow:hidden}.item.strong .name{color:var(--text-bright)}.item .meta{color:var(--text-2);margin-left:auto;font-size:11px}.item.indent{padding-left:26px}.remote-name{color:var(--text-2);padding:2px 20px;font-size:12px}.empty{color:var(--text-3);padding:4px 20px;font-size:12px}.graph{background:var(--app-bg);flex-direction:column;min-height:0;display:flex}.graph-header,.row{grid-template-columns:var(--cols);align-items:center;display:grid}.graph-header{background:var(--panel0);border-bottom:1px solid var(--border);height:24px;color:var(--text-2);font-size:11px}.graph-header>div,.row>div{white-space:nowrap;text-overflow:ellipsis;padding-left:8px;overflow:hidden}.rows{flex:1;position:relative;overflow:auto}.row{height:var(--row-h);position:absolute;left:0;right:0}.row:hover{background:#5b9cf61f}.row.selected{background:#5b9cf638}.row.selected .msg{color:var(--text-bright)}.row .refs{gap:4px;padding-left:6px;display:flex}.pill{height:18px;color:var(--text);white-space:nowrap;text-overflow:ellipsis;cursor:default;border-radius:3px;flex:none;align-items:center;gap:5px;max-width:100%;padding:0 6px;font-size:12px;display:inline-flex;overflow:hidden}.pill.current{color:var(--text-bright);font-weight:600}.pill .ic{border-radius:50%;flex:none;width:7px;height:7px}.pill .ic.remote{border:1.5px solid;background:0 0!important}.pill .ic.tag{border-radius:1px;width:6px;height:6px;transform:rotate(45deg)}.row .lanes{height:100%;padding:0}.row .lanes svg{display:block}.row .msg.wip,.row .msg.stash{color:var(--text-2)}.row .date,.row .sha{color:var(--text-3);font-size:12px}.row .sha{font-family:var(--mono)}.avatar{color:#fff;border-radius:50%;flex:none;justify-content:center;align-items:center;width:20px;height:20px;font-size:9px;font-weight:600;display:inline-flex;overflow:hidden}.avatar img{object-fit:cover;width:100%;height:100%}.more{height:var(--row-h);color:var(--text-2);align-items:center;gap:8px;padding-left:180px;display:flex;position:absolute;left:0;right:0}.more a{color:var(--blue);cursor:pointer}.detail-head{padding:12px}.detail-head h2{color:var(--text-bright);white-space:pre-wrap;margin:0 0 8px;font-size:15px;font-weight:600}.detail-head .who{align-items:center;gap:8px;display:flex}.detail-head .who .avatar{width:26px;height:26px;font-size:11px}.detail-head .when{color:var(--text-2);font-size:11px}.detail-head .hashes{flex-wrap:wrap;gap:8px;margin-top:6px;display:flex}.detail-head .hashes button{font-family:var(--mono);padding:2px 6px;font-size:11px}.detail-head .body{white-space:pre-wrap;color:var(--text);max-height:120px;margin-top:8px;overflow:auto}.hr{border-top:1px solid var(--border)}.files{flex:none;max-height:40%;overflow:auto}.files.staging{max-height:45%}.fhead{height:24px;color:var(--text-2);align-items:center;padding:0 8px;font-size:12px;font-weight:600;display:flex}.fhead button{margin-left:auto;padding:1px 8px;font-size:11px}.file{cursor:default;align-items:center;gap:8px;height:24px;padding:0 6px 0 10px;display:flex}.file:hover{background:#5b9cf61f}.file.selected{background:#5b9cf638}.file .k{font-family:var(--mono);width:10px;font-size:12px}.file .n{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.file .d{color:var(--text-3);white-space:nowrap;text-overflow:ellipsis;font-size:11px;overflow:hidden}.file .acts{gap:4px;margin-left:auto;display:none}.file:hover .acts,.file.selected .acts{display:flex}.file .acts button{padding:1px 6px;font-size:11px}.gap{height:10px}.diff{font-family:var(--mono);flex:1;min-height:0;font-size:12px;overflow:auto}.diff .path{color:var(--text-2);padding:4px 8px;font-size:12px}.diff .line{white-space:pre;padding:0 8px 0 22px;line-height:18px;position:relative}.diff .line:before{position:absolute;left:8px}.diff .add{color:var(--text-bright);background:#3fb9502e}.diff .add:before{content:"+"}.diff .del{color:var(--text-bright);background:#e5534b2e}.diff .del:before{content:"-"}.diff .hunk{color:#4ac8e8e6;background:#5b9cf61f}.diff .note{color:var(--text-3);padding:8px}.placeholder{color:var(--text-3);flex:1;justify-content:center;align-items:center;display:flex}.commit-box{background:var(--panel1);flex-direction:column;gap:6px;padding:10px;display:flex}.commit-box .label{color:var(--text-2);font-size:12px;font-weight:600}.commit-box textarea{resize:vertical;min-height:60px}.commit-box .actions{align-items:center;gap:8px;display:flex}.commit-box .actions .right{background:0 0;border:0;margin-left:auto;display:block}.warn{color:var(--orange);font-size:12px}.menu{z-index:50;background:var(--panel1);border:1px solid var(--border);border-radius:4px;min-width:220px;padding:4px;position:fixed;box-shadow:0 8px 24px #0006}.menu button{text-align:left;background:0 0;border-radius:3px;width:100%;padding:4px 8px;display:block}.menu button:hover{background:#5b9cf633}.menu button.danger{color:var(--red)}.menu .sep{border-top:1px solid var(--border);margin:4px 0}.overlay{z-index:40;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.dialog{background:var(--panel1);border:1px solid var(--border);border-radius:6px;flex-direction:column;gap:8px;width:420px;padding:16px;display:flex}.dialog h3{color:var(--text-bright);margin:0;font-size:14px}.dialog .buttons{justify-content:flex-end;gap:8px;margin-top:6px;display:flex}.dialog label{align-items:center;gap:6px;display:flex}.dialog label input[type=checkbox]{width:auto}.dialog .error{color:var(--red);font-size:12px}.dialog pre{white-space:pre-wrap;font-family:var(--mono);max-height:240px;margin:0;font-size:12px;overflow:auto}.toast{background:var(--panel2);color:var(--text-bright);z-index:60;border:1px solid #ffffff1a;border-radius:6px;padding:10px 14px;position:fixed;bottom:16px;right:16px}.welcome{flex-direction:column;justify-content:center;align-items:center;gap:12px;height:100%;display:flex}.welcome h1{color:var(--text-bright);margin:0;font-size:30px}.welcome p{color:var(--text-2);margin:0}.welcome .recent{flex-direction:column;align-items:center;gap:4px;margin-top:16px;display:flex}.welcome .recent a{color:var(--blue);cursor:pointer}.welcome .note{text-align:center;max-width:460px;color:var(--text-3);font-size:12px}.banner{color:var(--orange);background:#f08a2426;flex:none;align-items:center;gap:8px;padding:8px 10px;font-weight:600;display:flex}.banner span{flex:1}.toolbar select{background:var(--panel1);border:1px solid #0000;border-radius:4px}.welcome code{font-family:var(--mono);color:var(--text)}