-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathcli.ts
More file actions
261 lines (240 loc) · 9.78 KB
/
Copy pathcli.ts
File metadata and controls
261 lines (240 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
#!/usr/bin/env node
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { spawn, execFileSync } from 'child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { Command } from '@commander-js/extra-typings'
import { startServer } from './server/main.ts'
import type { DiffArgs, DiffEndpoints } from './shared/types.ts'
const program = new Command()
.name('skepsis')
.description('Local diff review UI (auto-detects jj or git)')
.option('-r, --revisions <revsets>', 'Show changes in these revisions')
.option('-f, --from <rev>', 'Show changes from this revision')
.option('-t, --to <rev>', 'Show changes to this revision')
.option('--git', 'force git mode (skip jj detection)')
.option('--dev', 'run with Vite dev server for development')
.option('--host <address>', 'address to bind the HTTP server to', 'localhost')
.argument('[files...]', 'Limit diff to these paths (passed through to jj/git)')
.parse()
const opts = program.opts()
const files = program.processedArgs[0] ?? []
const hostname = opts.host
function detectVcs(): 'jj' | 'git' {
try {
execFileSync('jj', ['root'], { stdio: 'ignore' })
return 'jj'
} catch {
try {
execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
return 'git'
} catch {
throw new Error('Not in a jj or git repository')
}
}
}
const vcs = opts.git ? 'git' : detectVcs()
/* Base rev candidates for the default git diff. A remote's default branch
* (origin/HEAD) is optional — "Having a default branch for a remote is not
* required" (https://git-scm.com/docs/git-remote, set-head) — so fall back to
* common default-branch names, remote-tracking first — a poor man's version
* of what jj's trunk() does. */
const GIT_BASE_CANDIDATES = [
'origin/HEAD',
'origin/main',
'origin/master',
'main',
'master',
]
/** Find the trunk-ish base for the default diff, plus its merge base with
* HEAD. Probing with `git merge-base` both verifies the candidate exists and
* yields the fork-point sha in one step. */
function resolveGitBase(): { base: string; mergeBase: string } {
for (const base of GIT_BASE_CANDIDATES) {
try {
const mergeBase = execFileSync('git', ['merge-base', base, 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
return { base, mergeBase }
} catch (error) {
// A missing candidate or no common ancestor means try the next one, but
// outside a repo or without git installed every candidate fails the
// same way — git's own error beats a misleading "no base revision
// found" that suggests -r/-f flags that would also fail.
const { code, stderr } = error as { code?: string; stderr?: string }
if (code === 'ENOENT' || stderr?.includes('not a git repository')) {
console.error(stderr?.trim() || (error as Error).message)
process.exit(1)
}
}
}
console.error(
`No base revision found for the default diff (tried ${GIT_BASE_CANDIDATES.join(', ')}).\n` +
'Specify a range explicitly with -r or -f/-t.',
)
process.exit(1)
}
/** Split an `A..B` range into its two revs. Returns null for anything that
* isn't a simple two-sided range with both sides non-empty. */
function parseRange(rev: string): { left: string; right: string } | null {
const idx = rev.indexOf('..')
if (idx < 0 || rev.includes('...')) return null
const left = rev.slice(0, idx)
const right = rev.slice(idx + 2)
if (!left || !right || right.includes('..')) return null
return { left, right }
}
function buildDiffSource(): DiffArgs {
if (vcs === 'jj') {
const args: string[] = []
let commentsEnabled: boolean
let endpoints: DiffEndpoints
if (opts.from || opts.to || !opts.revisions) {
// With no range flags at all, --from defaults to a GitHub-PR-style diff
// from the fork point of trunk and @. Same output as `-r 'trunk()..@'`
// for a linear branch, but still works after trunk has been merged into
// the branch, where jj rejects `trunk()..@` ("Cannot diff revsets with
// gaps in"). fork_point() requires jj >= 0.24
// (https://github.com/jj-vcs/jj/releases/tag/v0.24.0).
const from = opts.from ?? (opts.to ? undefined : 'fork_point(trunk() | @)')
if (from) args.push('--from', from)
if (opts.to) args.push('--to', opts.to)
// Comments enabled if --to is @ or omitted (jj defaults --to to @)
commentsEnabled = !opts.to || opts.to === '@'
// jj defaults both --from and --to to @
endpoints = { left: from ?? '@', right: { rev: opts.to ?? '@' } }
} else {
const rev = opts.revisions
args.push('-r', rev)
// Comments enabled if the revset's "to" side is @
commentsEnabled = rev === '@' || rev.endsWith('..@')
const range = parseRange(rev)
if (range) {
endpoints = { left: range.left, right: { rev: range.right } }
} else if (!rev.includes('..')) {
// Single rev: `jj diff -r R` shows R's own change, i.e. R-..R.
endpoints = { left: `${rev}-`, right: { rev } }
} else {
endpoints = null
}
}
return { vcs: 'jj', args, commentsEnabled, files, endpoints }
} else {
let args: string[]
let commentsEnabled: boolean
let endpoints: DiffEndpoints
let displayArgs: string[] | undefined
if (opts.from || opts.to) {
if (opts.to) {
// Explicit --to: commit-to-commit diff, no working copy
args = [opts.from ?? 'HEAD', opts.to]
commentsEnabled = false
endpoints = { left: opts.from ?? 'HEAD', right: { rev: opts.to } }
} else {
// --from only: git diff <from> includes working tree
args = [opts.from!]
commentsEnabled = true
endpoints = { left: opts.from!, right: 'workingCopy' }
}
} else if (!opts.revisions) {
// Default: the git translation of the jj default — diff the working
// tree against the branch's fork point from trunk (the merge base of
// the resolved base and HEAD). Using the merge base keeps upstream
// commits the branch doesn't have from showing up as reversions, and
// ending at the working tree means review comments work out of the box.
// Diffing against the resolved sha rather than `git diff --merge-base
// <base>` pins the diff and hunk expansion to the same commit — they
// can't drift apart if the base moves while the server runs — and
// avoids --merge-base's hard error when there are multiple merge bases.
// The sha is unreadable, so show the equivalent symbolic command in the
// UI and log.
const { base, mergeBase } = resolveGitBase()
args = [mergeBase]
displayArgs = ['--merge-base', base]
commentsEnabled = true
endpoints = { left: mergeBase, right: 'workingCopy' }
} else {
const rev = opts.revisions
// No .. means single ref, which diffs against working tree
commentsEnabled = !rev.includes('..')
args = [rev]
const range = parseRange(rev)
if (range) {
endpoints = { left: range.left, right: { rev: range.right } }
} else if (!rev.includes('..')) {
endpoints = { left: rev, right: 'workingCopy' }
} else {
endpoints = null
}
}
return { vcs: 'git', args, commentsEnabled, files, endpoints, displayArgs }
}
}
const diffSource = buildDiffSource()
const cwd = process.cwd()
const children: ReturnType<typeof spawn>[] = []
function findCheckoutRoot(): string | null {
const sourceRoot = import.meta.dirname
if (existsSync(join(sourceRoot, 'vite.config.ts'))) return sourceRoot
const packageRoot = dirname(sourceRoot)
if (existsSync(join(packageRoot, 'vite.config.ts'))) return packageRoot
return null
}
function cleanup(code = 0): never {
for (const child of children) child.kill()
process.exit(code)
}
function requireCheckoutRoot(): string {
const checkoutRoot = findCheckoutRoot()
if (checkoutRoot === null) {
console.error(
'--dev only works from a skepsis source checkout, not the installed package.\n' +
'Clone https://github.com/oxidecomputer/skepsis and run: node cli.ts --dev',
)
cleanup(1)
}
return checkoutRoot
}
process.on('SIGINT', () => cleanup())
process.on('SIGTERM', () => cleanup())
const checkoutRoot = opts.dev ? requireCheckoutRoot() : undefined
const { port: apiPort } = await startServer({ diffSource, cwd, hostname })
function urlOpenCommand(url: string): { cmd: string; args: string[] } {
switch (process.platform) {
case 'darwin':
return { cmd: 'open', args: [url] }
case 'win32':
return { cmd: 'cmd', args: ['/c', 'start', '', url] }
default:
return { cmd: 'xdg-open', args: [url] }
}
}
// Only auto-open a browser when bound to localhost. With any other host,
// the user is likely on a remote dev box and the browser lives elsewhere
// — opening locally would just spawn an unwanted browser.
const shouldAutoOpen = hostname === 'localhost'
if (opts.dev) {
const viteArgs = ['vite', '--host', hostname]
if (shouldAutoOpen) viteArgs.push('--open')
const vite = spawn('npx', viteArgs, {
cwd: checkoutRoot,
stdio: 'inherit',
env: { ...process.env, API_HOST: hostname, API_PORT: String(apiPort) },
})
children.push(vite)
} else if (shouldAutoOpen) {
const url = `http://localhost:${apiPort}`
const { cmd, args } = urlOpenCommand(url)
const opener = spawn(cmd, args, { detached: true, stdio: 'ignore' })
// The URL is already printed on server startup, so a failed opener (e.g. no
// xdg-open on illumos) needs no message — swallow the error and carry on.
opener.on('error', () => {})
opener.unref()
}