jj.ts
1import { execFile } from 'child_process';
2import { promisify } from 'util';
3import type {
4 ChangeInfo,
5 ChangeStack,
6 FileChange,
7 DiffMode,
8 DiffScope
9} from '$lib/types/index';
10import { parseDiff } from './diff-parser.ts';
11import { getRepoDir } from './repo.ts';
12import {
13 getWorktreeApprovals,
14 approveWorktreeFile,
15 unapproveWorktreeFile,
16 getSelectedChangeId,
17 setSelectedChangeId,
18 clearApprovals
19} from './review-store.ts';
20
21const execFileAsync = promisify(execFile);
22
23// jj takes a repository-wide lock for each command. Several API routes issue
24// multiple jj calls in parallel, so serialize them through a queue to avoid
25// "concurrent modification" failures.
26let queue: Promise<unknown> = Promise.resolve();
27
28function jjExec(args: string[]): Promise<string> {
29 const result = queue.then(() =>
30 execFileAsync('jj', args, {
31 cwd: getRepoDir(),
32 encoding: 'utf-8',
33 maxBuffer: 64 * 1024 * 1024
34 })
35 );
36 // Keep the queue alive whether or not the command succeeds.
37 queue = result.then(
38 () => undefined,
39 () => undefined
40 );
41 return result.then((r) => r.stdout);
42}
43
44let jjAvailableCache: boolean | null = null;
45
46export async function isJjAvailable(): Promise<boolean> {
47 if (jjAvailableCache !== null) return jjAvailableCache;
48 try {
49 await execFileAsync('jj', ['--version'], { cwd: getRepoDir() });
50 jjAvailableCache = true;
51 } catch {
52 jjAvailableCache = false;
53 }
54 return jjAvailableCache;
55}
56
57// --- Revision helpers ---
58
59// git-bug stores issues/identities and Lamport clocks in dedicated refs and
60// operation-pack commits (e.g. create-clock-12, edit-clock-34, ops, version-4).
61// Those commits are tooling internals, not code under review, so hide them from
62// the change stack. Ref tips carry a `bugs/*` or `identities/*` bookmark, while
63// intermediate commits have none — but every one of them touches a clock file.
64const BUG_REF_PATTERN = /^(?:bugs|identities)\//;
65const CLOCK_FILE_PREFIXES = ['create-clock-', 'edit-clock-'];
66
67const clockCheck = CLOCK_FILE_PREFIXES.map(
68 (p) => `diff.files().any(|f| f.path().display().starts_with(${JSON.stringify(p)}))`
69).join(' || ');
70
71const T = {
72 changeId: 'change_id.short()',
73 commitId: 'commit_id.short()',
74 fullCommitId: 'commit_id',
75 description: 'description.first_line()',
76 bookmarks: 'bookmarks.join(",")',
77 workingCopy: 'if(current_working_copy, "1", "0")',
78 empty: 'if(empty, "1", "0")',
79 parents: 'parents.map(|p| p.change_id().short()).join(",")',
80 bugClock: `(${clockCheck})`
81};
82
83const CHANGE_TEMPLATE =
84 `${T.changeId} ++ "\\t" ++ ${T.commitId} ++ "\\t" ++ ${T.description} ++ "\\t" ++ ` +
85 `${T.bookmarks} ++ "\\t" ++ ${T.workingCopy} ++ "\\t" ++ ${T.empty} ++ "\\t" ++ ${T.parents} ++ "\\t" ++ ` +
86 `${T.bugClock} ++ "\\n"`;
87
88interface BaseInfo {
89 revset: string;
90 changeId: string;
91 commitId: string;
92 description: string;
93}
94
95let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
96
97function invalidateBaseCache(): void {
98 cachedBaseBranchInfo = null;
99}
100
101/**
102 * Resolve the accepted base. We use the local `main` bookmark (the thing the UI
103 * moves on Approve) rather than `trunk()`, which would follow `main@origin` and
104 * ignore local approvals.
105 */
106async function resolveBase(): Promise<BaseInfo | null> {
107 for (const candidate of ['main', 'main@origin', 'trunk()']) {
108 try {
109 const out = await jjExec([
110 'log',
111 '-r',
112 candidate,
113 '--no-graph',
114 '-T',
115 `${T.changeId} ++ "\\t" ++ ${T.fullCommitId} ++ "\\t" ++ ${T.description}`
116 ]);
117 const line = out.trim().split('\n')[0];
118 if (!line) continue;
119 const [changeId, commitId, description] = line.split('\t');
120 if (!changeId || !commitId) continue;
121 // trunk() falls back to the root commit when there is no main line.
122 if (/^0+$/.test(commitId)) return null;
123 return { revset: candidate, changeId, commitId, description: description ?? '' };
124 } catch {
125 // candidate doesn't resolve — try the next one
126 }
127 }
128 return null;
129}
130
131async function getWorkingCopyChangeId(): Promise<string | null> {
132 try {
133 const out = await jjExec(['log', '-r', '@', '--no-graph', '-T', T.changeId]);
134 return out.trim().split('\n')[0] || null;
135 } catch {
136 return null;
137 }
138}
139
140function parseChanges(raw: string): ChangeInfo[] {
141 const out: ChangeInfo[] = [];
142 for (const line of raw.split('\n')) {
143 if (!line.trim()) continue;
144 const [changeId, commitId, description, bookmarks, wc, empty, parents, bugClock] =
145 line.split('\t');
146 if (!changeId || !commitId) continue;
147 if (empty === '1') continue; // skip empty working-copy commits
148 const bookmarkList = (bookmarks || '').split(',').filter(Boolean);
149 // Skip git-bug issue/identity commits and Lamport-clock updates.
150 if (bugClock === 'true' || bookmarkList.some((b) => BUG_REF_PATTERN.test(b))) continue;
151 out.push({
152 changeId,
153 commitId,
154 description: description || '',
155 bookmarks: bookmarkList,
156 isWorkingCopy: wc === '1',
157 parents: (parents || '').split(',').filter(Boolean)
158 });
159 }
160 return out;
161}
162
163// Maps new path -> status/oldPath by inspecting Git-format diff headers.
164function parseGitStatuses(raw: string): Map<string, { status: FileChange['status']; oldPath?: string }> {
165 const map = new Map<string, { status: FileChange['status']; oldPath?: string }>();
166 const chunks = raw.split(/^diff --git /m).filter(Boolean);
167 for (const chunk of chunks) {
168 const lines = chunk.split('\n');
169 const header = lines[0]?.match(/a\/(.+?) b\/(.+)/);
170 if (!header) continue;
171 const oldPath = header[1];
172 const newPath = header[2];
173
174 let status: FileChange['status'] = 'modified';
175 if (lines.some((l) => l.startsWith('new file mode'))) status = 'added';
176 else if (lines.some((l) => l.startsWith('deleted file mode'))) status = 'deleted';
177 else if (oldPath !== newPath) status = 'renamed';
178
179 map.set(newPath, { status, oldPath: status === 'renamed' ? oldPath : undefined });
180 }
181 return map;
182}
183
184function filesFromRaw(raw: string): FileChange[] {
185 const parsed = parseDiff(raw);
186 const statuses = parseGitStatuses(raw);
187 const approvals = getWorktreeApprovals();
188
189 return parsed.map((f) => {
190 const meta = statuses.get(f.path);
191 let additions = 0;
192 let deletions = 0;
193 for (const hunk of f.hunks) {
194 for (const line of hunk.lines) {
195 if (line.type === 'add') additions++;
196 else if (line.type === 'del') deletions++;
197 }
198 }
199 return {
200 path: f.path,
201 oldPath: meta?.oldPath ?? f.oldPath,
202 status: meta?.status ?? 'modified',
203 staged: false,
204 approved: approvals.has(f.path),
205 additions,
206 deletions
207 };
208 });
209}
210
211// Diff revision arguments. `jj diff -r @` already handles merge commits: when @
212// has multiple parents jj auto-merges them and diffs the merged result against
213// @, which is exactly "changes since the parents". Do not "simplify" this to an
214// explicit parents() revset. When a change is selected for review we diff that
215// revision instead of the working copy.
216async function revArgs(scope?: DiffScope): Promise<string[]> {
217 const rev = getSelectedChangeId() || '@';
218 if (scope === 'worktree') {
219 const { mergeBase } = await getBaseBranchInfo();
220 if (mergeBase) return ['--from', mergeBase, '--to', rev];
221 }
222 return ['-r', rev];
223}
224
225async function diffFor(paths: string[], scope?: DiffScope): Promise<string> {
226 const args = ['diff', '--git', '--color=never', ...(await revArgs(scope))];
227 if (paths.length > 0) args.push('--', ...paths);
228 try {
229 return await jjExec(args);
230 } catch {
231 return '';
232 }
233}
234
235export async function getStatus(): Promise<FileChange[]> {
236 return filesFromRaw(await diffFor([], 'uncommitted'));
237}
238
239export async function getWorktreeStatus(): Promise<FileChange[]> {
240 return filesFromRaw(await diffFor([], 'worktree'));
241}
242
243export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
244 if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
245
246 const base = await resolveBase();
247 if (!base) {
248 cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
249 } else {
250 cachedBaseBranchInfo = {
251 baseBranch: base.revset === 'trunk()' ? 'trunk' : 'main',
252 mergeBase: base.commitId
253 };
254 }
255 return cachedBaseBranchInfo;
256}
257
258export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<string> {
259 const selected = getSelectedChangeId();
260 if (scope === 'worktree') {
261 const { mergeBase } = await getBaseBranchInfo();
262 if (mergeBase) return mergeBase;
263 }
264 return selected ? `${selected}-` : '@-';
265}
266
267/** Revision whose file contents are the "new" side of the diff, or null for the working tree. */
268export async function getNewRef(): Promise<string | null> {
269 return getSelectedChangeId();
270}
271
272export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
273 if (mode === 'unstaged') {
274 return getUnstagedDiff(filePath, scope);
275 }
276 return diffFor(filePath ? [filePath] : [], scope);
277}
278
279// jj has no staging area. "Needs Review" therefore means "not yet approved" —
280// approval is tracked in memory rather than in an index.
281async function getUnstagedDiff(filePath?: string, scope?: DiffScope): Promise<string> {
282 const approved = getWorktreeApprovals();
283
284 if (filePath) {
285 if (approved.has(filePath)) return '';
286 return diffFor([filePath], scope);
287 }
288
289 const status = scope === 'worktree' ? await getWorktreeStatus() : await getStatus();
290 const unapproved = status.filter((f) => !f.approved).map((f) => f.path);
291 if (unapproved.length === 0) return '';
292 return diffFor(unapproved, scope);
293}
294
295export async function getFileContent(filePath: string, ref: string = '@-'): Promise<string> {
296 const rev = ref === 'HEAD' ? '@-' : ref;
297 try {
298 return await jjExec(['file', 'show', '-r', rev, '--', filePath]);
299 } catch {
300 return '';
301 }
302}
303
304// --- Stacked change navigation ---
305
306export async function getChangeStack(): Promise<ChangeStack> {
307 // The base can move outside this process (or on Approve), so refresh it each
308 // time the stack is requested. Polling calls this regularly.
309 invalidateBaseCache();
310
311 const base = await resolveBase();
312 const workingCopy = await getWorkingCopyChangeId();
313
314 let changes: ChangeInfo[] = [];
315 if (base) {
316 const raw = await jjExec([
317 'log',
318 '-r',
319 `${base.changeId}..`,
320 '--no-graph',
321 '-T',
322 CHANGE_TEMPLATE
323 ]);
324 changes = parseChanges(raw);
325 }
326
327 // Drop a stale selection (approved, abandoned, or filtered as empty).
328 let selected = getSelectedChangeId();
329 if (selected && !changes.some((c) => c.changeId === selected)) {
330 selected = null;
331 setSelectedChangeId(null);
332 }
333
334 return {
335 main: base
336 ? { changeId: base.changeId, commitId: base.commitId, description: base.description }
337 : null,
338 current: selected ?? workingCopy,
339 selected,
340 workingCopy,
341 changes
342 };
343}
344
345export async function selectChange(changeId: string): Promise<ChangeStack> {
346 try {
347 const out = await jjExec(['log', '-r', changeId, '--no-graph', '-T', T.changeId]);
348 if (!out.trim()) throw new Error('unknown');
349 } catch {
350 throw new Error(`Unknown change: ${changeId}`);
351 }
352 setSelectedChangeId(changeId);
353 clearApprovals();
354 return getChangeStack();
355}
356
357export async function approveChange(changeId?: string): Promise<ChangeStack> {
358 const stack = await getChangeStack();
359 const target = changeId || stack.selected || stack.workingCopy || null;
360 if (!target) {
361 throw new Error('No change to approve.');
362 }
363 // Forward-only: only changes that are descendants of the current main can be
364 // approved (they are exactly the entries in `main..`).
365 if (!stack.changes.some((c) => c.changeId === target)) {
366 throw new Error(
367 'Cannot approve: this change is not a descendant of main. main can only move forward.'
368 );
369 }
370
371 await jjExec(['bookmark', 'set', 'main', '-r', target]);
372 invalidateBaseCache();
373 clearApprovals();
374
375 // The approved change is now main; advance the view to its child if any.
376 setSelectedChangeId(null);
377 const updated = await getChangeStack();
378 const child = updated.changes.find((c) => c.parents.includes(target));
379 if (child) setSelectedChangeId(child.changeId);
380
381 return getChangeStack();
382}
383
384export async function approveFile(filePath: string, _scope?: DiffScope): Promise<void> {
385 approveWorktreeFile(filePath);
386}
387
388export async function unapproveFile(filePath: string, _scope?: DiffScope): Promise<void> {
389 unapproveWorktreeFile(filePath);
390}
391
392export async function resetFile(filePath: string): Promise<void> {
393 await jjExec(['restore', '--from', '@-', '--', filePath]);
394}
395
396export async function resetHunk(_filePath: string, _hunkHeader: string): Promise<void> {
397 throw new Error('Hunk reset is not supported in jj repositories; reset the whole file instead.');
398}
399
400export async function commitStaged(_message: string): Promise<string> {
401 throw new Error('jj changes are committed automatically; there is nothing to commit.');
402}
403
404export async function commitAll(_message: string): Promise<string> {
405 throw new Error('jj changes are committed automatically; there is nothing to commit.');
406}
407
408export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> {
409 const repoDir = getRepoDir();
410 const repoName = repoDir.split('/').pop() || 'unknown';
411
412 let branch = '@';
413 try {
414 const bookmarks = (
415 await jjExec(['log', '-r', '@', '--no-graph', '-T', 'bookmarks.join(" ")'])
416 ).trim();
417 if (bookmarks) {
418 branch = bookmarks.split(/\s+/)[0];
419 } else {
420 const changeId = (
421 await jjExec(['log', '-r', '@', '--no-graph', '-T', 'commit_id.short()'])
422 ).trim();
423 if (changeId) branch = changeId;
424 }
425 } catch {
426 // Leave the placeholder branch
427 }
428 return { branch, repoName };
429}