ramulus 0.0.0-stage → 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/README.md +16 -2
- package/bin/ramulus.js +44 -0
- package/lib/git.js +388 -0
- package/lib/server.js +125 -0
- package/package.json +25 -4
- package/ui/assets/index-ArxayBFH.css +1 -0
- package/ui/assets/index-D3gOH5eP.js +101 -0
- package/ui/index.html +14 -0
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ramulus
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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)}
|